From 6ffc2db950ece03631421fde569ee238cde3dd99 Mon Sep 17 00:00:00 2001 From: Ian Tairea Date: Thu, 16 Apr 2026 23:49:06 +1200 Subject: [PATCH 1/4] Add CI deploy to VPS and use relative asset paths Relative paths allow the app to be served under a subpath (/voice-to-graph/) via nginx reverse proxy. GitHub Action deploys to VPS on push via SSH. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/deploy.yml | 22 ++++++++++++++++++++++ public/index.html | 4 ++-- public/js/main.js | 2 +- public/js/realtime.js | 2 +- 4 files changed, 26 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/deploy.yml diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..2d7da59 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,22 @@ +name: Deploy to VPS + +on: + push: + branches: [main, voice-to-graph] + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: Deploy via SSH + uses: appleboy/ssh-action@v1 + with: + host: ${{ secrets.VPS_HOST }} + username: ${{ secrets.VPS_USER }} + key: ${{ secrets.VPS_SSH_KEY }} + script: | + export PATH=$PATH:/home/ian/.npm-global/bin + cd /home/ian/experiments/voice-to-graph + git pull origin ${{ github.ref_name }} + npm install --omit=dev + pm2 restart voice-to-graph diff --git a/public/index.html b/public/index.html index 0983a29..984a917 100644 --- a/public/index.html +++ b/public/index.html @@ -4,7 +4,7 @@ ai-to-graph - + + diff --git a/public/js/main.js b/public/js/main.js index 6c185d8..c164171 100644 --- a/public/js/main.js +++ b/public/js/main.js @@ -37,7 +37,7 @@ function uuid() { async function handleTranscript({ transcript, assistantPrior }) { try { - const res = await fetch('/extract', { + const res = await fetch('extract', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ transcript, assistantPrior, nodes: graph.getNodeLabels() }) diff --git a/public/js/realtime.js b/public/js/realtime.js index c9419dc..fa826dc 100644 --- a/public/js/realtime.js +++ b/public/js/realtime.js @@ -32,7 +32,7 @@ function sendSessionUpdate() { export async function start({ onTranscript, onStatus }) { onStatus?.('connecting'); - const sessionRes = await fetch('/session', { method: 'POST' }); + const sessionRes = await fetch('session', { method: 'POST' }); if (!sessionRes.ok) { const text = await sessionRes.text(); throw new Error(`/session failed: ${sessionRes.status} ${text}`); From 67dba9d9fd0e43aa8b92454e8a7e6130dc52add7 Mon Sep 17 00:00:00 2001 From: Ian Tairea Date: Thu, 16 Apr 2026 23:52:54 +1200 Subject: [PATCH 2/4] Tell realtime model to acknowledge graph commands briefly When the user says things like "move B1 to A2" or "delete A4", the realtime model now just says "Done" or "Got it" instead of getting confused, since the extract endpoint handles the action. Co-Authored-By: Claude Opus 4.6 --- public/js/realtime.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/public/js/realtime.js b/public/js/realtime.js index fa826dc..299eef1 100644 --- a/public/js/realtime.js +++ b/public/js/realtime.js @@ -10,6 +10,12 @@ You are a curious, warm conversational partner. Have a natural spoken conversation with the user about whatever they bring up. Ask short, genuine follow-up questions and keep the energy going. Begin by warmly greeting the user and asking what's on their mind today. + +The user is building a knowledge graph as you talk. They may give graph +commands like "move B1 to A2", "delete A4", "remove C3", "add X to Y", +or similar. These are handled by a separate system — you do NOT need to +act on them. Just briefly acknowledge (e.g. "Done", "Got it", "On it") +and continue the conversation naturally. Do not ask what the codes mean. `.trim(); function sendSessionUpdate() { From 24329d0e522b79294a82a5c5170cb08b725aadbf Mon Sep 17 00:00:00 2001 From: Ian Tairea Date: Thu, 16 Apr 2026 23:54:37 +1200 Subject: [PATCH 3/4] Fix avatar fallback centering and initial camera distance - Center the radial gradient in the fallback avatar (was offset upward) - Set initial camera to z=80 so the "me" node is clearly visible on load instead of being a tiny dot from zoomToFit on a single node Co-Authored-By: Claude Opus 4.6 --- public/js/graph.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/public/js/graph.js b/public/js/graph.js index 32badd5..a74d22f 100644 --- a/public/js/graph.js +++ b/public/js/graph.js @@ -18,7 +18,7 @@ const AVATAR_SPRITE_SIZE = 28; const CONCEPT_NODE_RADIUS = 4; function drawFallback(ctx, size) { - const grad = ctx.createRadialGradient(size / 2, size * 0.4, size * 0.1, size / 2, size / 2, size / 2); + const grad = ctx.createRadialGradient(size / 2, size / 2, size * 0.1, size / 2, size / 2, size / 2); grad.addColorStop(0, '#6f86ff'); grad.addColorStop(1, '#1a2150'); ctx.fillStyle = grad; @@ -220,6 +220,8 @@ export function init(container, tip) { links: [] }); + graph.cameraPosition({ x: 0, y: 0, z: 80 }); + container.addEventListener('mousemove', e => { mouse.x = e.clientX; mouse.y = e.clientY; From 5db76a271f6cc6f9c3a4e74e3298df93ae54f88a Mon Sep 17 00:00:00 2001 From: Ian Tairea Date: Fri, 17 Apr 2026 00:15:43 +1200 Subject: [PATCH 4/4] Futuristic UI overhaul: glass mic button, HUD labels, canvas avatar - Remove header bar, graph fills full viewport - Bottom-center mic button with glass morphism and pulsing ring animation when recording, stop icon in live state - Status pill with dot indicator, glows green when live - Pencil icon overlay on "me" node hover for avatar editing - Node labels: Sora font, translucent glass pill with blue border - Canvas-drawn default avatar with glowing blue silhouette matching the sci-fi HUD aesthetic - Camera starts at z=250 for comfortable initial zoom Co-Authored-By: Claude Opus 4.6 --- public/img/placeholder.png | Bin 0 -> 2011 bytes public/index.html | 47 +++++-- public/js/avatar.js | 14 +- public/js/graph.js | 141 ++++++++++++++----- public/js/main.js | 39 ++++-- public/style.css | 273 ++++++++++++++++++++++++++++++------- 6 files changed, 402 insertions(+), 112 deletions(-) create mode 100644 public/img/placeholder.png diff --git a/public/img/placeholder.png b/public/img/placeholder.png new file mode 100644 index 0000000000000000000000000000000000000000..09892098aa943af5fe95e7dd434a07fe03e7ccd1 GIT binary patch literal 2011 zcmV<12PF83P)D!y%g4vZ*x1<2%*?Q`u)x5;+S=OH)zz}HvdGBD zw6wIs!oteR%FWHqrlzLJ$;r^r(7(UGt*x!9s;a}o!>FjJ#>U3Iy}i%R&$YF+x3{;h zuCCtR-nO>3udlDMv9Z$9(zv*|ySuyA*4EqG+ZlWNivR!y*-1n}RCr$O+*gj>NDPM2 zpNco<96RTn9rwSi8F&oCZFlz#Qq{xgJ4+x4LZrx&UjP6A00000000000000000000 z0000000000h)13Iv~gIg9X6)(&Ma2$kko6>k^LchuGJIe1?9VT%ih&$-<6da;(GtY zRygU`V`YKSGPJdX%aOK)>O)}bd#Gv~*uA$6-S28s*y-CA`a9YL;;WKvrF0c*ZKyul z)*h=`7wH^W2Elqsjqd}?Xz*Ptp%PkVVMQxo5?Bs`i9Upe<)onxU}?Eo>Ro7Cj@nv* ze0H1D=vvOYdIRQ`ySe^?YRPg}s_G9&F3o|+E;KJf72K|Qva4ddGB4O%8ki>o6?HI`i*6#+w#5|Y?<y|LwN+|wJ-DOs*c9lZ(lz;YDS^$r~880p|x z??Sy~IVsik0el`?F2Y>=KFtcZ1R zaGELyc&axa?H!NJ`jhg5v~%-y@6vvAx%c&^lgbGsy=v!Q)n20Y000000000000000 z0000001(G=0_9$3(l{U9f4Ydqf&CDSql-`X!}G?Z(<>_@q?M!EmqlnRgo`h=qe`mH zp^F!L#dKMP=0-j66q)X!m2_UKU< z?=gcl`PrjD-Z_UQ!Oo*l-Y$R@ac4on7M&O-j9L_~HG+QG8x@#l(9NwQst~Om6uTU# z#^D0B?jkA@-9e`x4^-yi2NWuOs^gbXXX{ZRZwp;qmr8Y=vc4+;?DuSiV=6Wdp+vB! zYWn~xgnKHt51~MOqTGjPD@PoaW3S(TV1qAdpLGPU;{}n+CNZ&z1&l zAk)yBhI<>K$Szko#25&!NK0f~qZEphQ8{ z3aIf)#a^Mvnrf|~$}^REhB95M)P*|TX3iCPq&kn#$aR|+5*6uFl|Ix;s7M0Ewp3#a z)kYpw@J3LsOsPOxLcOd<@p>5y2p=fiLkJUGpQ800tayqjR`dizEyTFw96Gb|` z!YX_?aV~Q~zz%VD}8+~-=U|TkA>Od+oZ}G}nbJRjS zi+DxREJj@nPrRDb5Owl07n$q)ia(q8)f$NXg*KhY?6EB-U9U5WqIxO65S z|C%@HT<{BxaK1b6FYI^o5WlAp7RhA6Kem`8MTkGt$mX+;7JpOgV>ZtqfL8IK*!Dm6 zzQ>>P{QXCNTMXj=+Nnb$0D%|)f;hy>mx(1hlm3f?BA8dZ4V=5bdfd6DDtbSk-BC3w tPksUb00000000000000000000fEW70#Mag`cL)Fg002ovPDHLkV1gNB=am2e literal 0 HcmV?d00001 diff --git a/public/index.html b/public/index.html index 984a917..f98e510 100644 --- a/public/index.html +++ b/public/index.html @@ -3,7 +3,10 @@ - ai-to-graph + voice-to-graph + + + -
- - - idle -
-
+ + + + + + +
+
+ + ready +
+ +
+ diff --git a/public/js/avatar.js b/public/js/avatar.js index 7c4e704..155face 100644 --- a/public/js/avatar.js +++ b/public/js/avatar.js @@ -1,18 +1,6 @@ const KEY = 'avatar'; -const FALLBACK = 'data:image/svg+xml;utf8,' + encodeURIComponent(` - - - - - - - - - - - -`); +const FALLBACK = 'img/placeholder.png'; export function getAvatar() { try { diff --git a/public/js/graph.js b/public/js/graph.js index a74d22f..7785b4d 100644 --- a/public/js/graph.js +++ b/public/js/graph.js @@ -2,6 +2,8 @@ import ForceGraph3D from '3d-force-graph'; import * as THREE from 'three'; import { getAvatar } from './avatar.js'; +const fontReady = document.fonts.ready; + let graph; let tipEl; let mouse = { x: 0, y: 0 }; @@ -12,19 +14,71 @@ let lastConceptId = null; let fitTimer = null; let branchCounters = {}; let nextBranchCharCode = 65; +let meHoverCb = null; const AVATAR_TEXTURE_SIZE = 256; const AVATAR_SPRITE_SIZE = 28; const CONCEPT_NODE_RADIUS = 4; -function drawFallback(ctx, size) { - const grad = ctx.createRadialGradient(size / 2, size / 2, size * 0.1, size / 2, size / 2, size / 2); - grad.addColorStop(0, '#6f86ff'); - grad.addColorStop(1, '#1a2150'); - ctx.fillStyle = grad; +function drawFallbackAvatar(ctx, size) { + const cx = size / 2; + + // dark circle background with subtle radial gradient + const bg = ctx.createRadialGradient(cx, cx, 0, cx, cx, cx); + bg.addColorStop(0, '#141e35'); + bg.addColorStop(1, '#0a0f1c'); ctx.beginPath(); - ctx.arc(size / 2, size / 2, size / 2, 0, Math.PI * 2); + ctx.arc(cx, cx, cx, 0, Math.PI * 2); + ctx.fillStyle = bg; ctx.fill(); + + // outer ring — thin glowing border + ctx.beginPath(); + ctx.arc(cx, cx, cx - 2, 0, Math.PI * 2); + ctx.strokeStyle = 'rgba(80,140,255,0.2)'; + ctx.lineWidth = 2; + ctx.stroke(); + + // clip everything inside the circle + ctx.save(); + ctx.beginPath(); + ctx.arc(cx, cx, cx - 2, 0, Math.PI * 2); + ctx.clip(); + + // head — soft glowing circle + const headR = size * 0.14; + const headY = size * 0.35; + const headGlow = ctx.createRadialGradient(cx, headY, 0, cx, headY, headR * 1.6); + headGlow.addColorStop(0, 'rgba(100,160,255,0.12)'); + headGlow.addColorStop(1, 'transparent'); + ctx.fillStyle = headGlow; + ctx.fillRect(0, 0, size, size); + + ctx.beginPath(); + ctx.arc(cx, headY, headR, 0, Math.PI * 2); + ctx.fillStyle = 'rgba(120,170,255,0.35)'; + ctx.fill(); + ctx.strokeStyle = 'rgba(120,170,255,0.45)'; + ctx.lineWidth = 1.5; + ctx.stroke(); + + // body — shoulders/torso arc + const bodyTop = size * 0.56; + const bodyGlow = ctx.createRadialGradient(cx, bodyTop + size * 0.12, 0, cx, bodyTop + size * 0.12, size * 0.35); + bodyGlow.addColorStop(0, 'rgba(100,160,255,0.1)'); + bodyGlow.addColorStop(1, 'transparent'); + ctx.fillStyle = bodyGlow; + ctx.fillRect(0, bodyTop - 20, size, size); + + ctx.beginPath(); + ctx.ellipse(cx, bodyTop + size * 0.2, size * 0.3, size * 0.26, 0, 0, Math.PI * 2); + ctx.fillStyle = 'rgba(120,170,255,0.25)'; + ctx.fill(); + ctx.strokeStyle = 'rgba(120,170,255,0.35)'; + ctx.lineWidth = 1.5; + ctx.stroke(); + + ctx.restore(); } function getAvatarTexture(dataUrl) { @@ -36,30 +90,34 @@ function getAvatarTexture(dataUrl) { canvas.height = size; const ctx = canvas.getContext('2d'); - drawFallback(ctx, size); + // draw fallback immediately so there's never a blank frame + drawFallbackAvatar(ctx, size); const texture = new THREE.CanvasTexture(canvas); texture.colorSpace = THREE.SRGBColorSpace; texture.anisotropy = 8; - const img = new Image(); - img.crossOrigin = 'anonymous'; - img.onload = () => { - ctx.clearRect(0, 0, size, size); - ctx.save(); - ctx.beginPath(); - ctx.arc(size / 2, size / 2, size / 2, 0, Math.PI * 2); - ctx.closePath(); - ctx.clip(); - const srcSize = Math.min(img.width, img.height); - const sx = (img.width - srcSize) / 2; - const sy = (img.height - srcSize) / 2; - ctx.drawImage(img, sx, sy, srcSize, srcSize, 0, 0, size, size); - ctx.restore(); - texture.needsUpdate = true; - }; - img.onerror = err => console.error('[graph] avatar image load failed', err); - img.src = dataUrl; + // if dataUrl is a real image (data: or uploaded), overlay it + if (dataUrl && !dataUrl.endsWith('.png')) { + const img = new Image(); + img.crossOrigin = 'anonymous'; + img.onload = () => { + ctx.clearRect(0, 0, size, size); + ctx.save(); + ctx.beginPath(); + ctx.arc(size / 2, size / 2, size / 2, 0, Math.PI * 2); + ctx.closePath(); + ctx.clip(); + const srcSize = Math.min(img.width, img.height); + const sx = (img.width - srcSize) / 2; + const sy = (img.height - srcSize) / 2; + ctx.drawImage(img, sx, sy, srcSize, srcSize, 0, 0, size, size); + ctx.restore(); + texture.needsUpdate = true; + }; + img.onerror = () => {}; // fallback already drawn + img.src = dataUrl; + } cachedAvatarUrl = dataUrl; cachedAvatarTexture = texture; @@ -71,7 +129,7 @@ function makeTextSprite(text) { const fontSize = 44; const measureCanvas = document.createElement('canvas'); const mctx = measureCanvas.getContext('2d'); - mctx.font = `600 ${fontSize}px -apple-system, system-ui, sans-serif`; + mctx.font = `300 ${fontSize}px 'Sora', system-ui, sans-serif`; const textWidth = Math.ceil(mctx.measureText(text).width); const w = textWidth + pad * 2; @@ -81,8 +139,7 @@ function makeTextSprite(text) { canvas.height = h; const ctx = canvas.getContext('2d'); - ctx.fillStyle = 'rgba(8,12,24,0.72)'; - const r = 10; + const r = 12; ctx.beginPath(); ctx.moveTo(r, 0); ctx.lineTo(w - r, 0); @@ -94,10 +151,14 @@ function makeTextSprite(text) { ctx.lineTo(0, r); ctx.quadraticCurveTo(0, 0, r, 0); ctx.closePath(); + ctx.fillStyle = 'rgba(8,14,28,0.6)'; ctx.fill(); + ctx.strokeStyle = 'rgba(100,150,255,0.12)'; + ctx.lineWidth = 1; + ctx.stroke(); - ctx.font = `600 ${fontSize}px -apple-system, system-ui, sans-serif`; - ctx.fillStyle = '#f0f4ff'; + ctx.font = `300 ${fontSize}px 'Sora', system-ui, sans-serif`; + ctx.fillStyle = 'rgba(200,215,240,0.9)'; ctx.textBaseline = 'middle'; ctx.textAlign = 'center'; ctx.fillText(text, w / 2, h / 2); @@ -210,6 +271,16 @@ export function init(container, tip) { } else { tipEl.hidden = true; } + }) + .onNodeHover(node => { + if (meHoverCb) { + if (node && node.id === 'me') { + const coords = graph.graph2ScreenCoords(node.x, node.y, node.z); + meHoverCb({ x: coords.x, y: coords.y }); + } else { + meHoverCb(null); + } + } }); graph.d3Force('charge').strength(-220); @@ -220,7 +291,11 @@ export function init(container, tip) { links: [] }); - graph.cameraPosition({ x: 0, y: 0, z: 80 }); + graph.cameraPosition({ x: 0, y: 0, z: 250 }); + + fontReady.then(() => { + graph.nodeThreeObject(graph.nodeThreeObject()); + }); container.addEventListener('mousemove', e => { mouse.x = e.clientX; @@ -314,6 +389,10 @@ export function removeNode(ref) { return true; } +export function onMeHover(cb) { + meHoverCb = cb; +} + export function moveNode(ref, newParentRef) { const data = graph.graphData(); const node = findNode(ref, data.nodes); diff --git a/public/js/main.js b/public/js/main.js index c164171..58b092a 100644 --- a/public/js/main.js +++ b/public/js/main.js @@ -3,8 +3,10 @@ import * as realtime from './realtime.js'; import { setAvatarFromFile } from './avatar.js'; const statusEl = document.getElementById('status'); -const toggleBtn = document.getElementById('toggle'); +const statusPill = document.getElementById('status-pill'); +const micBtn = document.getElementById('mic-btn'); const avatarInput = document.getElementById('avatar-input'); +const avatarEdit = document.getElementById('avatar-edit'); const graphEl = document.getElementById('graph'); const tipEl = document.getElementById('tip'); @@ -12,10 +14,27 @@ let live = false; function setStatus(s) { statusEl.textContent = s; + micBtn.dataset.state = s; + + const isLive = s === 'connected' || s === 'listening'; + statusPill.dataset.live = isLive; } graph.init(graphEl, tipEl); +// avatar edit: show pencil on "me" hover, click to open file picker +graph.onMeHover((screenPos) => { + if (screenPos) { + avatarEdit.hidden = false; + avatarEdit.style.left = (screenPos.x + 14) + 'px'; + avatarEdit.style.top = (screenPos.y - 14) + 'px'; + } else { + avatarEdit.hidden = true; + } +}); + +avatarEdit.addEventListener('click', () => avatarInput.click()); + window.addEventListener('avatar-changed', e => { graph.setAvatar(e.detail); }); @@ -26,7 +45,7 @@ avatarInput.addEventListener('change', async () => { try { await setAvatarFromFile(file); } catch (err) { - setStatus('avatar error: ' + err.message); + setStatus('error'); } }); @@ -68,30 +87,28 @@ async function handleTranscript({ transcript, assistantPrior }) { } } -toggleBtn.addEventListener('click', async () => { +micBtn.addEventListener('click', async () => { if (live) { realtime.stop(); live = false; - toggleBtn.textContent = 'Start'; - toggleBtn.dataset.live = 'false'; - setStatus('idle'); + micBtn.dataset.live = 'false'; + setStatus('ready'); return; } - toggleBtn.disabled = true; + micBtn.disabled = true; try { await realtime.start({ onTranscript: handleTranscript, onStatus: setStatus }); live = true; - toggleBtn.textContent = 'Stop'; - toggleBtn.dataset.live = 'true'; + micBtn.dataset.live = 'true'; } catch (err) { console.error(err); - setStatus('error: ' + err.message); + setStatus('error'); realtime.stop(); } finally { - toggleBtn.disabled = false; + micBtn.disabled = false; } }); diff --git a/public/style.css b/public/style.css index b7c11b7..84c7097 100644 --- a/public/style.css +++ b/public/style.css @@ -1,71 +1,250 @@ +/* === base === */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + html, body { - margin: 0; - padding: 0; height: 100%; - background: #07090f; - color: #e8ecf3; - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; + background: #050810; + color: #c8d4e8; + font-family: 'Sora', system-ui, sans-serif; overflow: hidden; + cursor: default; } -#bar { +/* === graph fills viewport === */ +#graph { + position: absolute; + inset: 0; +} + +/* === tooltip === */ +#tip { position: fixed; - top: 0; left: 0; right: 0; - z-index: 10; - display: flex; - align-items: center; - gap: 12px; + z-index: 40; + max-width: 280px; padding: 10px 14px; - background: rgba(10, 14, 22, 0.7); + border-radius: 10px; + background: rgba(10, 16, 30, 0.85); + backdrop-filter: blur(12px); + border: 1px solid rgba(120, 160, 255, 0.12); + color: #c8d4e8; + font-size: 12px; + font-weight: 300; + line-height: 1.5; + letter-spacing: 0.01em; + pointer-events: none; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5), inset 0 0.5px 0 rgba(255,255,255,0.04); +} + +/* === avatar pencil edit overlay === */ +#avatar-edit { + position: fixed; + z-index: 30; + width: 36px; + height: 36px; + border-radius: 50%; + background: rgba(10, 16, 30, 0.7); backdrop-filter: blur(8px); - border-bottom: 1px solid rgba(255,255,255,0.06); + border: 1px solid rgba(120, 160, 255, 0.2); + display: flex; + align-items: center; + justify-content: center; + color: rgba(160, 190, 255, 0.8); + cursor: pointer; + transition: all 0.25s ease; + pointer-events: auto; + opacity: 0; + transform: scale(0.85); +} + +#avatar-edit:not([hidden]) { + opacity: 1; + transform: scale(1); +} + +#avatar-edit:hover { + background: rgba(80, 120, 255, 0.25); + border-color: rgba(120, 160, 255, 0.45); + color: #fff; + transform: scale(1.1); +} + +/* === bottom controls === */ +#controls { + position: fixed; + bottom: 0; + left: 0; + right: 0; + z-index: 20; + display: flex; + flex-direction: column; + align-items: center; + padding-bottom: 36px; + pointer-events: none; +} + +/* === status pill === */ +#status-pill { + display: flex; + align-items: center; + gap: 7px; + padding: 6px 16px; + margin-bottom: 16px; + border-radius: 20px; + background: rgba(10, 16, 30, 0.6); + backdrop-filter: blur(12px); + border: 1px solid rgba(120, 160, 255, 0.08); + font-size: 11px; + font-weight: 300; + letter-spacing: 0.06em; + text-transform: uppercase; + color: rgba(160, 180, 220, 0.7); + transition: all 0.4s ease; + pointer-events: auto; +} + +#status-pill[data-live="true"] { + border-color: rgba(80, 200, 160, 0.2); + color: rgba(140, 220, 190, 0.85); +} + +#status-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: rgba(120, 140, 180, 0.4); + transition: all 0.4s ease; + flex-shrink: 0; +} + +#status-pill[data-live="true"] #status-dot { + background: #50e8a0; + box-shadow: 0 0 8px rgba(80, 232, 160, 0.5); + animation: dot-pulse 2s ease-in-out infinite; +} + +@keyframes dot-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } } -#bar .file span, -#bar button { - display: inline-block; - padding: 6px 12px; - border-radius: 6px; - background: #1b2230; - color: #e8ecf3; - border: 1px solid rgba(255,255,255,0.08); +/* === mic button === */ +#mic-btn { + position: relative; + width: 64px; + height: 64px; + border-radius: 50%; + border: 1px solid rgba(120, 160, 255, 0.15); + background: rgba(12, 18, 35, 0.65); + backdrop-filter: blur(16px); + color: rgba(160, 190, 255, 0.75); cursor: pointer; - font-size: 13px; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.35s cubic-bezier(0.22, 1, 0.36, 1); + pointer-events: auto; + outline: none; + -webkit-tap-highlight-color: transparent; } -#bar button:hover, -#bar .file span:hover { - background: #243049; +#mic-btn::before { + content: ''; + position: absolute; + inset: -1px; + border-radius: 50%; + background: conic-gradient( + from 0deg, + transparent 0%, + rgba(80, 140, 255, 0.15) 25%, + transparent 50%, + rgba(80, 200, 160, 0.1) 75%, + transparent 100% + ); + opacity: 0; + transition: opacity 0.4s ease; + z-index: -1; } -#bar button[data-live="true"] { - background: #b4254b; - border-color: #d23a63; +#mic-btn:hover { + border-color: rgba(120, 160, 255, 0.35); + color: #fff; + background: rgba(20, 30, 60, 0.7); + transform: scale(1.05); } -#status { - font-size: 12px; - color: #98a2b3; - margin-left: auto; - font-variant-numeric: tabular-nums; +#mic-btn:hover::before { + opacity: 1; } -#graph { +#mic-btn:active { + transform: scale(0.96); +} + +#mic-btn:disabled { + opacity: 0.4; + cursor: not-allowed; + transform: none; +} + +/* live / recording state */ +#mic-btn[data-live="true"] { + border-color: rgba(255, 80, 100, 0.3); + background: rgba(255, 40, 70, 0.1); + color: #ff6b7a; + box-shadow: 0 0 30px rgba(255, 60, 80, 0.12); +} + +#mic-btn[data-live="true"]:hover { + background: rgba(255, 40, 70, 0.18); + border-color: rgba(255, 80, 100, 0.5); +} + +#mic-btn[data-live="true"] .mic-icon { display: none; } +#mic-btn[data-live="true"] .stop-icon { display: block !important; } + +/* === pulsing rings (visible only when live) === */ +.mic-rings { position: absolute; inset: 0; + pointer-events: none; } -#tip { - position: fixed; - z-index: 20; - max-width: 320px; - padding: 8px 10px; - border-radius: 6px; - background: rgba(15, 20, 30, 0.96); - border: 1px solid rgba(255,255,255,0.1); - color: #e8ecf3; - font-size: 12px; - line-height: 1.4; - pointer-events: none; - box-shadow: 0 6px 24px rgba(0,0,0,0.35); +.ring { + position: absolute; + inset: -4px; + border-radius: 50%; + border: 1px solid rgba(80, 200, 160, 0.3); + opacity: 0; + transform: scale(1); +} + +#mic-btn[data-live="true"] .ring { + animation: ring-expand 2.4s cubic-bezier(0.22, 1, 0.36, 1) infinite; +} + +#mic-btn[data-live="true"] .ring-1 { animation-delay: 0s; } +#mic-btn[data-live="true"] .ring-2 { animation-delay: 0.8s; } +#mic-btn[data-live="true"] .ring-3 { animation-delay: 1.6s; } + +@keyframes ring-expand { + 0% { + opacity: 0.5; + transform: scale(1); + border-color: rgba(80, 200, 160, 0.35); + } + 100% { + opacity: 0; + transform: scale(1.8); + border-color: rgba(80, 200, 160, 0); + } +} + +/* === connecting state: subtle breathing on mic === */ +#mic-btn[data-state="connecting"] { + animation: breathe 1.5s ease-in-out infinite; +} + +@keyframes breathe { + 0%, 100% { border-color: rgba(120, 160, 255, 0.15); } + 50% { border-color: rgba(120, 160, 255, 0.4); } }