feat: 2.14.0 — Eithan terminal toolbar, voice UX, Dross improvements framework

- Terminal renamed Eithan: mobile font A−/A+ (per-URL ttyd opts), same-origin
  xterm Copy/Paste buttons, scroll-to-live, touch-default 17px
- Dross voice: no keyboard pop after transcribe (fine-pointer only focus),
  autogrow textarea to ~5 lines, live amplitude meter on the mic while recording
- Dross improvements: propose_improvement tool (CSS layer, exfil-sanitized,
  owner-approved, per-improvement rollback/restore), public /improvements.css,
  Settings panel. External MCP registry unchanged (no tool leak).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
root
2026-06-11 23:35:32 +10:00
parent 859dedb668
commit 3bd8ea399c
18 changed files with 338 additions and 23 deletions

View File

@@ -6,7 +6,7 @@ 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' };
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) {
@@ -36,6 +36,13 @@ export async function renderDrossBubble() {
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',
@@ -81,6 +88,23 @@ export async function renderDrossBubble() {
};
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);
const tick = () => {
if (!recording) { actx.close().catch(() => {}); mic.style.removeProperty('--voicelevel'); return; }
analyser.getByteTimeDomainData(buf);
let peak = 0;
for (const v of buf) peak = Math.max(peak, Math.abs(v - 128));
mic.style.setProperty('--voicelevel', (peak / 128).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);
}
@@ -97,7 +121,14 @@ export async function renderDrossBubble() {
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; input.focus(); }
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);

View File

@@ -123,7 +123,7 @@ export function renderSidebar(root) {
el('div', { class: 'sb-title' }, 'Navigate'),
navItem('Sacred Valley', '/sacred-valley'),
navItem('Speedtest', '/speedtest'),
navItem('Terminal', '/terminal'),
navItem('Eithan', '/terminal'),
navItem('Search', '/search'),
inboxItem,
navItem('Jobs', '/jobs'),