CSS animations override normal declarations — the old box-shadow pulse painted over the level-driven shadow. .metered now disables the fallback pulse; added sqrt gain so speech registers visibly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
168 lines
9.2 KiB
JavaScript
168 lines
9.2 KiB
JavaScript
// public/components/dross_bubble.js
|
||
// Global floating Dross companion. Replaces the per-Space right rail.
|
||
import { el, mount } from '../dom.js';
|
||
import { api } from '../api.js';
|
||
import { state } from '../state.js';
|
||
import { wireAgentChat } from './agent_chat.js';
|
||
import { drossAvatar } from './dross_avatar.js';
|
||
|
||
const TOOL_LABELS = { search: '🔍 searching', read: '📄 reading', context: '🧭 looking at this view', propose_change: '📝 drafting a change', propose_improvement: '🎨 drafting an improvement to the Void' };
|
||
let cfg = { avatar: 'soft-eye', accent: '#a86adf', persona: '', voiceMode: 'review' };
|
||
|
||
function applyAccent(node, hex) {
|
||
node.style.setProperty('--dross', hex);
|
||
}
|
||
|
||
export async function renderDrossBubble() {
|
||
try { cfg = { ...cfg, ...(await api.get('/api/dross/settings')) }; } catch { /* defaults */ }
|
||
|
||
const fab = el('div', { class: 'dross-fab', title: 'Dross' },
|
||
el('div', { class: 'dross-ping', style: { display: 'none' } }, ''), drossAvatar(cfg.avatar, 60));
|
||
const log = el('div', { class: 'dross-log' });
|
||
const input = el('textarea', { rows: 1, placeholder: 'Ask Dross…' });
|
||
const sendBtn = el('button', { class: 'dross-send', title: 'Send' },
|
||
el('span', { html: '<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 2 11 13M22 2l-7 20-4-9-9-4 20-7z"/></svg>' }));
|
||
const micLabel = el('span', {}, 'Tap to record');
|
||
const mic = el('button', { class: 'dross-mic', title: 'Record a voice note' },
|
||
el('span', { html: '<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="3" width="6" height="11" rx="3"/><path d="M5 11a7 7 0 0 0 14 0"/><line x1="12" y1="18" x2="12" y2="22"/><line x1="8" y1="22" x2="16" y2="22"/></svg>' }), micLabel);
|
||
const closeBtn = el('button', { class: 'dross-x', title: 'Close' }, '⤬');
|
||
const header = el('div', { class: 'dross-hd' }, drossAvatar(cfg.avatar, 30),
|
||
el('div', { class: 'dross-who' }, 'Dross', el('small', {}, 'always here, regrettably')), closeBtn);
|
||
const collapse = el('div', { class: 'dross-collapse', title: 'Collapse' },
|
||
el('span', { class: 'grip' }), el('span', {}, '⌄ collapse'), el('span', { class: 'grip' }));
|
||
const panel = el('div', { class: 'dross-panel' }, header, log,
|
||
el('div', { class: 'dross-inwrap' }, input, el('div', { class: 'dross-btnrow' }, mic, sendBtn)), collapse);
|
||
|
||
document.getElementById('shell').append(fab, panel);
|
||
applyAccent(fab, cfg.accent); applyAccent(panel, cfg.accent);
|
||
|
||
// autogrow: 1 line at rest, expands with content up to ~5 lines
|
||
function autogrow() {
|
||
input.style.height = 'auto';
|
||
input.style.height = Math.min(input.scrollHeight, 120) + 'px';
|
||
}
|
||
input.addEventListener('input', autogrow);
|
||
|
||
const chat = wireAgentChat({
|
||
logEl: log, inputEl: input, sendBtnEl: sendBtn,
|
||
historyUrl: '/api/dross', turnUrl: '/api/dross/turn',
|
||
agentName: 'Dross', showDrafts: true, toolLabels: TOOL_LABELS,
|
||
turnBody: (text) => ({ text, view: state.view || null })
|
||
});
|
||
let loaded = false;
|
||
|
||
function openPanel() {
|
||
const r = fab.getBoundingClientRect();
|
||
panel.classList.add('open'); fab.style.display = 'none';
|
||
const pr = panel.getBoundingClientRect();
|
||
const left = Math.max(8, Math.min(r.right - pr.width, innerWidth - pr.width - 8));
|
||
const top = Math.max(8, Math.min(r.bottom - pr.height, innerHeight - pr.height - 8));
|
||
panel.style.right = 'auto'; panel.style.bottom = 'auto'; panel.style.left = left + 'px'; panel.style.top = top + 'px';
|
||
if (!loaded) { loaded = true; chat.load(); }
|
||
// NB: do NOT auto-focus the input — on mobile that pops the keyboard every
|
||
// time Dross opens. The keyboard should only appear when the user taps the box.
|
||
}
|
||
function closePanel() { panel.classList.remove('open'); fab.style.display = 'block'; }
|
||
fab.addEventListener('click', () => { if (fab._moved) { fab._moved = false; return; } openPanel(); });
|
||
closeBtn.addEventListener('click', closePanel);
|
||
collapse.addEventListener('click', closePanel);
|
||
// Topbar ◆ button (and any caller) can summon/dismiss Dross.
|
||
window.addEventListener('dross-toggle', () => panel.classList.contains('open') ? closePanel() : openPanel());
|
||
|
||
drag(fab, fab, true); drag(header, panel, false);
|
||
|
||
// ---- voice: tap mic to record, tap again to stop → transcribe → review-and-send ----
|
||
let media = null, chunks = [], recording = false;
|
||
function setMic(label, rec) { micLabel.textContent = label; mic.classList.toggle('rec', !!rec); }
|
||
async function startRec() {
|
||
try {
|
||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||
chunks = [];
|
||
const opt = (window.MediaRecorder && MediaRecorder.isTypeSupported('audio/webm;codecs=opus'))
|
||
? { mimeType: 'audio/webm;codecs=opus' } : {};
|
||
media = new MediaRecorder(stream, opt);
|
||
media.ondataavailable = (e) => { if (e.data && e.data.size) chunks.push(e.data); };
|
||
media.onstop = async () => {
|
||
stream.getTracks().forEach(t => t.stop());
|
||
await sendClip(new Blob(chunks, { type: media.mimeType || 'audio/webm' }));
|
||
};
|
||
media.start();
|
||
recording = true; setMic('● Recording… tap to stop', true);
|
||
// live level meter: actual mic amplitude drives the pulse (visual proof it hears you)
|
||
try {
|
||
const actx = new (window.AudioContext || window.webkitAudioContext)();
|
||
const src = actx.createMediaStreamSource(stream);
|
||
const analyser = actx.createAnalyser(); analyser.fftSize = 256;
|
||
src.connect(analyser);
|
||
const buf = new Uint8Array(analyser.frequencyBinCount);
|
||
mic.classList.add('metered'); // disables the fallback pulse; amplitude takes over
|
||
const tick = () => {
|
||
if (!recording) {
|
||
actx.close().catch(() => {});
|
||
mic.style.removeProperty('--voicelevel'); mic.classList.remove('metered');
|
||
return;
|
||
}
|
||
analyser.getByteTimeDomainData(buf);
|
||
let peak = 0;
|
||
for (const v of buf) peak = Math.max(peak, Math.abs(v - 128));
|
||
// sqrt curve + gain: normal speech peaks ~0.1–0.4 raw, which read as barely-alive
|
||
mic.style.setProperty('--voicelevel', Math.min(1, Math.sqrt(peak / 48)).toFixed(3));
|
||
requestAnimationFrame(tick);
|
||
};
|
||
tick();
|
||
} catch { /* meter is decorative — recording works without it */ }
|
||
} catch {
|
||
setMic('Mic blocked', false); setTimeout(() => setMic('Tap to record', false), 1800);
|
||
}
|
||
}
|
||
function stopRec() {
|
||
if (media && recording) { recording = false; setMic('Transcribing…', false); media.stop(); }
|
||
}
|
||
async function sendClip(blob) {
|
||
try {
|
||
const fd = new FormData(); fd.append('audio', blob, 'clip.webm');
|
||
const res = await fetch('/api/voice/transcribe', {
|
||
method: 'POST', headers: { Authorization: 'Bearer ' + (localStorage.getItem('void_token') || '') }, body: fd
|
||
});
|
||
if (!res.ok) throw new Error('stt');
|
||
const { text } = await res.json();
|
||
setMic('Tap to record', false);
|
||
if (text) {
|
||
input.value = input.value ? (input.value + ' ' + text) : text;
|
||
autogrow();
|
||
// Focus only on fine-pointer devices — on mobile this popped the keyboard
|
||
// right after every voice note (owner-reported). A brief highlight instead.
|
||
if (matchMedia('(pointer: fine)').matches) input.focus();
|
||
else { input.classList.add('flash'); setTimeout(() => input.classList.remove('flash'), 900); }
|
||
}
|
||
// voiceMode 'handsfree'/'action' (Phase 2b+) would branch here.
|
||
} catch {
|
||
setMic('Transcribe failed', false); setTimeout(() => setMic('Tap to record', false), 2000);
|
||
}
|
||
}
|
||
mic.addEventListener('click', () => recording ? stopRec() : startRec());
|
||
|
||
window.addEventListener('dross-settings-changed', async () => {
|
||
try { cfg = { ...cfg, ...(await api.get('/api/dross/settings')) }; } catch { return; }
|
||
applyAccent(fab, cfg.accent); applyAccent(panel, cfg.accent);
|
||
mount(fab, el('div', { class: 'dross-ping', style: { display: 'none' } }), drossAvatar(cfg.avatar, 60));
|
||
header.replaceChild(drossAvatar(cfg.avatar, 30), header.firstChild);
|
||
});
|
||
}
|
||
|
||
function drag(handle, target, isFab) {
|
||
handle.addEventListener('pointerdown', (e) => {
|
||
if (e.target.closest('.dross-x') || e.target.closest('.dross-mic') || e.target.closest('.dross-send')) return;
|
||
e.preventDefault();
|
||
const r = target.getBoundingClientRect(); const sx = e.clientX, sy = e.clientY; let moved = false;
|
||
target.style.right = 'auto'; target.style.bottom = 'auto'; target.style.left = r.left + 'px'; target.style.top = r.top + 'px';
|
||
const mv = (ev) => {
|
||
const dx = ev.clientX - sx, dy = ev.clientY - sy; if (Math.abs(dx) + Math.abs(dy) > 4) moved = true;
|
||
target.style.left = Math.max(4, Math.min(innerWidth - r.width - 4, r.left + dx)) + 'px';
|
||
target.style.top = Math.max(4, Math.min(innerHeight - r.height - 4, r.top + dy)) + 'px';
|
||
};
|
||
const up = () => { document.removeEventListener('pointermove', mv); document.removeEventListener('pointerup', up); if (isFab) target._moved = moved; };
|
||
document.addEventListener('pointermove', mv); document.addEventListener('pointerup', up);
|
||
});
|
||
}
|