← Commit history

Open-source release: source + README + hero + skills (v0.2.0)

John Lauer ·16c2655016 ·4mo ago ·parent 4a2e36e
6 files changed +2760
screenshots/hero.pngadded
⋯ 1 unchanged line ⋯
src/history.rsadded+162
@@ -0,0 +1,162 @@+//! History store for the TTS playground webview.+//!+//! One directory per user under `~/.adom/tts/history/`. Each clip is two+//! files: `<id>.mp3` and `<id>.json` (the metadata sidecar). The webview+//! UI scans this dir every few seconds so clips dropped in externally+//! (via `adom-tts push` or `adom-tts say --history`) show up without+//! restarting the server.+//!+//! ID format: `YYYYMMDD-HHMMSS-<sha8>`. Sortable by timestamp, unique+//! on collision via the first 8 hex chars of the audio hash.++use anyhow::{Context, Result};+use chrono::Utc;+use serde::{Deserialize, Serialize};+use sha2::{Digest, Sha256};+use std::path::{Path, PathBuf};++#[derive(Debug, Clone, Serialize, Deserialize)]+pub struct Clip {+    pub id: String,+    pub text: String,+    pub voice: String,+    pub rate: String,+    pub created_at: String,+    pub size_bytes: u64,+    pub source: String,+    #[serde(default, skip_serializing_if = "Option::is_none")]+    pub tts_hash: Option<String>,+}++pub fn history_dir() -> Result<PathBuf> {+    let home = std::env::var("HOME").context("no $HOME")?;+    let dir = PathBuf::from(home).join(".adom").join("tts").join("history");+    std::fs::create_dir_all(&dir)?;+    Ok(dir)+}++pub fn save(+    audio: &[u8],+    text: &str,+    voice: &str,+    rate: &str,+    source: &str,+    tts_hash: Option<String>,+) -> Result<Clip> {+    let dir = history_dir()?;+    let mut hasher = Sha256::new();+    hasher.update(audio);+    let hash = hex8(&hasher.finalize());+    let id = format!("{}-{}", Utc::now().format("%Y%m%d-%H%M%S"), hash);++    let mp3 = dir.join(format!("{id}.mp3"));+    let meta = dir.join(format!("{id}.json"));+    std::fs::write(&mp3, audio)?;++    let clip = Clip {+        id: id.clone(),+        text: text.to_string(),+        voice: voice.to_string(),+        rate: rate.to_string(),+        created_at: Utc::now().to_rfc3339(),+        size_bytes: audio.len() as u64,+        source: source.to_string(),+        tts_hash,+    };+    std::fs::write(&meta, serde_json::to_vec_pretty(&clip)?)?;+    Ok(clip)+}++pub fn list() -> Result<Vec<Clip>> {+    let dir = history_dir()?;+    let mut clips = Vec::new();+    for entry in std::fs::read_dir(&dir)? {+        let entry = entry?;+        let path = entry.path();+        if path.extension().and_then(|s| s.to_str()) != Some("json") {+            continue;+        }+        let bytes = match std::fs::read(&path) {+            Ok(b) => b,+            Err(_) => continue,+        };+        let clip: Clip = match serde_json::from_slice(&bytes) {+            Ok(c) => c,+            Err(_) => continue,+        };+        // Skip orphans (json with no matching mp3).+        if !dir.join(format!("{}.mp3", clip.id)).exists() {+            continue;+        }+        clips.push(clip);+    }+    clips.sort_by(|a, b| b.created_at.cmp(&a.created_at));+    Ok(clips)+}++/// Newest mp3 in the history dir (by mtime). Used by `adom-tts play last`.+pub fn latest_mp3() -> Result<Option<PathBuf>> {+    let dir = history_dir()?;+    let mut best: Option<(std::time::SystemTime, PathBuf)> = None;+    for entry in std::fs::read_dir(&dir)? {+        let entry = entry?;+        let path = entry.path();+        if path.extension().and_then(|s| s.to_str()) != Some("mp3") {+            continue;+        }+        let meta = match entry.metadata() { Ok(m) => m, Err(_) => continue };+        let mtime = meta.modified().unwrap_or(std::time::UNIX_EPOCH);+        match &best {+            Some((t, _)) if *t >= mtime => {}+            _ => best = Some((mtime, path)),+        }+    }+    Ok(best.map(|(_, p)| p))+}++pub fn audio_path(id: &str) -> Result<PathBuf> {+    let dir = history_dir()?;+    let p = dir.join(format!("{id}.mp3"));+    if !p.exists() {+        anyhow::bail!("clip not found: {id}");+    }+    // Reject path traversal — id must not escape history dir.+    let canon = p.canonicalize()?;+    if !canon.starts_with(dir.canonicalize()?) {+        anyhow::bail!("clip id out of history dir");+    }+    Ok(p)+}++pub fn delete(id: &str) -> Result<()> {+    let dir = history_dir()?;+    let mp3 = dir.join(format!("{id}.mp3"));+    let meta = dir.join(format!("{id}.json"));+    // Path-traversal guard (same as audio_path).+    if let Ok(c) = mp3.canonicalize() {+        if !c.starts_with(dir.canonicalize()?) {+            anyhow::bail!("clip id out of history dir");+        }+    }+    let _ = std::fs::remove_file(&mp3);+    let _ = std::fs::remove_file(&meta);+    Ok(())+}++/// Adopt an arbitrary .mp3 from disk into the history store. Used by+/// `adom-tts push <path>` so clips synthesized by other tools show up+/// in the playground.+pub fn push_file(src: &Path, text: &str, voice: &str, rate: &str) -> Result<Clip> {+    let audio = std::fs::read(src).with_context(|| format!("read {}", src.display()))?;+    save(&audio, text, voice, rate, "external", None)+}++fn hex8(bytes: &[u8]) -> String {+    const HEX: &[u8; 16] = b"0123456789abcdef";+    let mut s = String::with_capacity(8);+    for b in bytes.iter().take(4) {+        s.push(HEX[(b >> 4) as usize] as char);+        s.push(HEX[(b & 0xf) as usize] as char);+    }+    s+}
src/main.rsadded+446
@@ -0,0 +1,446 @@+//! adom-tts — thin Rust CLI for service-tts.+//!+//! Shells every `edge-tts` call through the shared service so (1) Adom+//! pronunciation overrides get auto-applied, (2) repeat calls hit the+//! source-hash cache, (3) swapping backends later is a no-op for callers.+//!+//! Prod URL is baked at compile time from service-tts/service.json via+//! build.rs; override per session via `$ADOM_TTS_API`.+//!+//! Mirrors the service-kicad / step2glb / adom-parts-search CLI pattern.++use anyhow::{anyhow, Context, Result};+use clap::{Parser, Subcommand};+use std::path::PathBuf;++mod config;+mod history;+mod play;+mod server;++use config::Surface;++const PROD_URL: &str = env!("SERVICE_TTS_URL");+const VERSION: &str = env!("CARGO_PKG_VERSION");++fn base_url() -> String {+    std::env::var("ADOM_TTS_API")+        .unwrap_or_else(|_| PROD_URL.to_string())+        .trim_end_matches('/').to_string()+}++fn http_client() -> reqwest::blocking::Client {+    reqwest::blocking::Client::builder()+        .user_agent(concat!("adom-tts/", env!("CARGO_PKG_VERSION")))+        .timeout(std::time::Duration::from_secs(60))+        .build().expect("http client")+}++#[derive(Parser)]+#[command(version = VERSION, about = "Shared Adom TTS CLI — see service-tts container")]+struct Cli {+    #[command(subcommand)]+    cmd: Cmd,+}++#[derive(Subcommand)]+enum Cmd {+    /// GET /health — reports service version + edge-tts version+    Health,+    /// GET /version — service metadata+    Version,+    /// Print which URL this binary is hitting (bake + env status)+    Config {+        /// Show the resolved play surface and config file location.+        #[arg(long)]+        show: bool,+    },++    /// Synthesize speech and write audio/mpeg to `--out <path>`.+    ///+    /// Pronunciation overrides auto-applied server-side — callers do NOT need+    /// to pre-phoneticize Adom terms like "adom-tsci". Pass+    /// `--no-pronunciations` for raw-text synthesis.+    Say {+        /// Text to synthesize. Use `-` to read stdin.+        text: String,+        #[arg(long)]+        out: PathBuf,+        #[arg(long, default_value = "en-US-AndrewNeural")]+        voice: String,+        #[arg(long, default_value = "+0%")]+        rate: String,+        #[arg(long)]+        no_pronunciations: bool,+        /// Also register the clip in ~/.adom/tts/history/ so it shows up+        /// in the TTS Playground webview app (`adom-tts serve`).+        #[arg(long)]+        history: bool,+        /// After synthesis, auto-play the mp3 in a Hydrogen webview tab+        /// (or a pup browser window — see `--surface`). Detaches by+        /// default so the CLI returns immediately.+        #[arg(long)]+        play: bool,+        /// Override the play surface for this call only ("hydrogen" or "pup").+        #[arg(long, value_parser = parse_surface_arg)]+        surface: Option<Surface>,+        /// With `--play`, block until playback completes (or 5-min cap).+        #[arg(long)]+        wait: bool,+    },++    /// Auto-play an mp3 (just-synthesized clip, history id, or `last`).+    ///+    /// Surface picked from --surface > $ADOM_TTS_SURFACE > ~/.adom/tts/config.toml > "hydrogen".+    /// `<target>` accepts: a path, a 12-char history hash (or full id), or+    /// the literal `last` for the newest clip in ~/.adom/tts/history/.+    Play {+        target: String,+        #[arg(long, value_parser = parse_surface_arg)]+        surface: Option<Surface>,+        /// Block until the audio's `ended` event arrives or timeout.+        #[arg(long)]+        wait: bool,+    },+    /// GET /tts/voices — list edge-tts voices as JSON+    Voices,+    /// GET /tts/pronunciations — list the override table+    Pronunciations,+    /// Drop SKILL.md + completions (tool-publisher Tier B install pattern)+    Install,++    /// Start the TTS Playground webview server.+    ///+    /// Opens a Hydrogen webview tab with a UI for typing text, picking a+    /// voice, synthesizing clips, and replaying anything already in+    /// ~/.adom/tts/history/.+    Serve {+        #[arg(long, default_value_t = 8795)]+        port: u16,+        /// Skip opening the Hydrogen webview tab (useful for headless tests).+        #[arg(long)]+        no_tab: bool,+        /// Dev mode (enables extra logging — reserved for future eval channel).+        #[arg(long)]+        dev: bool,+    },++    /// Manage the shared pronunciation overrides.+    #[command(subcommand)]+    Pron(PronCmd),++    /// Copy an existing .mp3 into the playground history store.+    ///+    /// Use when another tool (or a prior `adom-tts say` run) has already+    /// produced an mp3 and you want it to show up in the playground list.+    Push {+        /// Path to the .mp3 file.+        file: PathBuf,+        /// Text label for the clip (shown in the UI).+        #[arg(long, default_value = "")]+        text: String,+        #[arg(long, default_value = "en-US-AndrewNeural")]+        voice: String,+        #[arg(long, default_value = "+0%")]+        rate: String,+    },+}++#[derive(Subcommand)]+enum PronCmd {+    /// GET /tts/pronunciations — print the table loaded on the service.+    List,++    /// Add an entry to the gallia tts-pronunciation skill.+    ///+    /// Writes to gallia/skills/tts-pronunciation/pronunciations.json if a+    /// local gallia checkout is found; otherwise queues the entry at+    /// ~/.adom/tts/proposed-pronunciations.json and prints the JSON to+    /// paste manually.+    Add {+        /// The term as it appears in code / UIs / captions.+        written: String,+        /// Phonetic spelling for the TTS engine.+        narration: String,+        #[arg(long, default_value = "")]+        reason: String,+    },+}++fn parse_surface_arg(s: &str) -> Result<Surface, String> {+    use std::str::FromStr;+    Surface::from_str(s).map_err(|e| e.to_string())+}++fn main() -> Result<()> {+    let cli = Cli::parse();+    match cli.cmd {+        Cmd::Health          => cmd_get("/health"),+        Cmd::Version         => cmd_get("/version"),+        Cmd::Config { show } => cmd_config(show),+        Cmd::Voices          => cmd_get("/tts/voices"),+        Cmd::Pronunciations  => cmd_get("/tts/pronunciations"),+        Cmd::Say { text, out, voice, rate, no_pronunciations, history, play, surface, wait } =>+            cmd_say(&text, &out, &voice, &rate, !no_pronunciations, history, play, surface, wait),+        Cmd::Install         => cmd_install(),+        Cmd::Serve { port, no_tab, dev } => cmd_serve(port, !no_tab, dev),+        Cmd::Push { file, text, voice, rate } => cmd_push(&file, &text, &voice, &rate),+        Cmd::Play { target, surface, wait } => play::run(&target, surface, wait),+        Cmd::Pron(PronCmd::List) => cmd_get("/tts/pronunciations"),+        Cmd::Pron(PronCmd::Add { written, narration, reason }) =>+            cmd_pron_add(&written, &narration, &reason),+    }+}++fn cmd_pron_add(written: &str, narration: &str, reason: &str) -> Result<()> {+    let written = written.trim();+    let narration = narration.trim();+    if written.is_empty() || narration.is_empty() {+        return Err(anyhow!("written and narration are required"));+    }+    let reason = if reason.trim().is_empty() {+        "Added via adom-tts pron add.".to_string()+    } else { reason.trim().to_string() };+    let today = chrono::Utc::now().format("%Y-%m-%d").to_string();+    let added_by = std::env::var("ADOM_USER").ok()+        .or_else(|| std::env::var("USER").ok())+        .unwrap_or_else(|| "unknown".into());+    let entry = serde_json::json!({+        "written": written,+        "narration": narration,+        "engines_confirmed": ["edge-tts"],+        "reason": reason,+        "added_by": added_by,+        "added_date": today,+    });++    let home = std::env::var("HOME").context("no $HOME")?;+    let gallia_candidates = [+        std::env::var("ADOM_GALLIA").ok(),+        Some(format!("{home}/project/gallia")),+        Some("/home/adom/project/gallia".into()),+    ];+    let path = gallia_candidates.into_iter().flatten()+        .map(|s| PathBuf::from(s).join("skills/tts-pronunciation/pronunciations.json"))+        .find(|p| p.exists());++    if let Some(path) = path {+        let src = std::fs::read_to_string(&path)?;+        let mut doc: serde_json::Value = serde_json::from_str(&src)?;+        let obj = doc.as_object_mut().ok_or_else(|| anyhow!("not an object"))?;+        obj.insert("updated".to_string(), serde_json::Value::String(today));+        let entries = obj.get_mut("entries")+            .and_then(|v| v.as_array_mut())+            .ok_or_else(|| anyhow!("no entries array"))?;+        let w_lc = written.to_lowercase();+        entries.retain(|e| e.get("written").and_then(|s| s.as_str())+            .map(|s| s.to_lowercase()) != Some(w_lc.clone()));+        entries.push(entry.clone());+        let n = entries.len();+        std::fs::write(&path, serde_json::to_string_pretty(&doc)? + "\n")?;+        println!("OK: {} entries in {}", n, path.display());+        eprintln!("Hint: commit + push {} so service-tts picks it up on next redeploy", path.display());+        return Ok(());+    }++    // Fallback: write to a local proposal queue.+    let queue = PathBuf::from(&home).join(".adom/tts/proposed-pronunciations.json");+    if let Some(parent) = queue.parent() { std::fs::create_dir_all(parent)?; }+    let mut list: Vec<serde_json::Value> = std::fs::read_to_string(&queue).ok()+        .and_then(|s| serde_json::from_str(&s).ok())+        .unwrap_or_default();+    list.push(entry.clone());+    std::fs::write(&queue, serde_json::to_string_pretty(&list)? + "\n")?;+    println!("OK: queued in {} ({} pending)", queue.display(), list.len());+    eprintln!("Hint: no gallia checkout found. JSON entry:");+    println!("{}", serde_json::to_string_pretty(&entry)?);+    Ok(())+}++fn cmd_serve(port: u16, open_tab: bool, dev: bool) -> Result<()> {+    server::run(server::ServeConfig {+        port,+        service_url: base_url(),+        open_tab,+        dev,+    })+}++fn cmd_push(file: &std::path::Path, text: &str, voice: &str, rate: &str) -> Result<()> {+    let label = if text.is_empty() {+        file.file_stem().and_then(|s| s.to_str()).unwrap_or("clip").to_string()+    } else {+        text.to_string()+    };+    let clip = history::push_file(file, &label, voice, rate)?;+    println!("OK: pushed {} ({} bytes) → id={}", file.display(), clip.size_bytes, clip.id);+    Ok(())+}++fn cmd_config(show: bool) -> Result<()> {+    let from_env = std::env::var("ADOM_TTS_API").ok();+    println!("url       : {}", base_url());+    println!("source    : {}",+        if from_env.is_some() { "env $ADOM_TTS_API (overriding)" } else { "compile-time baked-in URL" });+    println!("prod bake : {PROD_URL}");+    if show {+        let cfg = config::load();+        let surface = config::resolve_surface(None, &cfg.play);+        let path = config::config_path()+            .map(|p| p.display().to_string())+            .unwrap_or_else(|| "(no $HOME)".into());+        println!();+        println!("[play]");+        println!("config file : {}", path);+        println!("surface     : {}  (effective, after env override)", surface.as_str());+        println!("pup_session : {}", cfg.play.pup_session);+        println!("pup_profile : {}", cfg.play.pup_profile);+    }+    Ok(())+}++fn cmd_get(path: &str) -> Result<()> {+    let url = format!("{}{}", base_url(), path);+    let resp = http_client().get(&url).send().unwrap_or_else(|e| {+        eprintln!("adom-tts: unreachable ({e})");+        std::process::exit(2);+    });+    if !resp.status().is_success() {+        eprintln!("adom-tts: HTTP {}", resp.status());+        std::process::exit(4);+    }+    print!("{}", resp.text().context("read body")?);+    println!();+    Ok(())+}++#[allow(clippy::too_many_arguments)]+fn cmd_say(+    text: &str,+    out: &PathBuf,+    voice: &str,+    rate: &str,+    apply: bool,+    register_history: bool,+    play_after: bool,+    surface: Option<Surface>,+    wait: bool,+) -> Result<()> {+    let text = if text == "-" {+        let mut s = String::new();+        std::io::Read::read_to_string(&mut std::io::stdin(), &mut s)?;+        s+    } else {+        text.to_string()+    };+    if text.trim().is_empty() {+        return Err(anyhow!("empty text — pass a string or '-' for stdin"));+    }+    let body = serde_json::json!({+        "text": text,+        "voice": voice,+        "rate": rate,+        "apply_pronunciations": apply,+    });+    let url = format!("{}/tts/say", base_url());+    let resp = http_client().post(&url).json(&body).send().unwrap_or_else(|e| {+        eprintln!("adom-tts: unreachable ({e})");+        std::process::exit(2);+    });+    let status = resp.status();+    // Grab cache/hash headers before consuming the body.+    let cache = resp.headers().get("x-tts-cache")+        .and_then(|v| v.to_str().ok()).unwrap_or("").to_string();+    let hash = resp.headers().get("x-tts-hash")+        .and_then(|v| v.to_str().ok()).unwrap_or("").to_string();+    if !status.is_success() {+        eprintln!("adom-tts: HTTP {status}");+        std::process::exit(4);+    }+    let bytes = resp.bytes().context("read body")?;+    std::fs::write(out, &bytes)?;+    println!("→ {} ({} bytes, cache={}, hash={})", out.display(), bytes.len(), cache, &hash[..hash.len().min(12)]);+    if register_history {+        let clip = history::save(&bytes, &text, voice, rate, "cli", Some(hash.clone()))?;+        eprintln!("✓ registered in playground history: {}", clip.id);+    }+    if play_after {+        let target = out.to_string_lossy().to_string();+        play::run(&target, surface, wait)?;+    } else if std::env::var("ADOM_TTS_NO_HINTS").is_err() {+        // Self-teach: most callers want to play the mp3 they just made.+        // Tell them how before they go write a custom autoplay HTML.+        eprintln!("# hint: pass --play to auto-play this clip in a Hydrogen tab or pup window");+        eprintln!("# hint: or run `adom-tts play {}` separately (same effect)", out.display());+        eprintln!("# hint: don't author a custom autoplay HTML or create a port mapping —");+        eprintln!("#       adom-tts ships the auto-player. silence: ADOM_TTS_NO_HINTS=1");+    }+    Ok(())+}++const BUILD_SKILL: &str = include_str!("../BUILD-SKILL.md");++fn cmd_install() -> Result<()> {+    let home = std::env::var("HOME").map(PathBuf::from)+        .map_err(|_| anyhow!("no $HOME"))?;+    let skill_dir = home.join(".claude/skills/adom-tts");+    std::fs::create_dir_all(&skill_dir)?;++    // Deploy the sibling build skill too (adom-app-model §5).+    let build_dir = home.join(".claude/skills/adom-tts-build");+    std::fs::create_dir_all(&build_dir)?;+    std::fs::write(build_dir.join("SKILL.md"), BUILD_SKILL)?;+    eprintln!("✓ build skill → {}", build_dir.join("SKILL.md").display());++    // Fetch SKILL.md from the wiki page. Mirrors adom-cli-design rule 7.b+    // (no include_str! of prose). Falls back to a brief placeholder if the+    // wiki is unreachable + nothing already exists.+    let wiki = "https://wiki-ufypy5dpx93o.adom.cloud/api/v1/pages";+    let dst = skill_dir.join("SKILL.md");+    match http_client().get(wiki).send() {+        Ok(r) if r.status().is_success() => {+            if let Ok(body) = r.json::<serde_json::Value>() {+                let pages = body.get("pages").and_then(|p| p.as_array())+                    .or_else(|| body.as_array())+                    .cloned().unwrap_or_default();+                let page = pages.iter()+                    .find(|p| p.get("slug").and_then(|s| s.as_str()) == Some("adom-tts")+                           && p.get("type").and_then(|t| t.as_str()) == Some("app"));+                if let Some(p) = page {+                    if let Some(src) = p.get("skill_source").and_then(|s| s.as_str()).filter(|s| !s.is_empty()) {+                        std::fs::write(&dst, src)?;+                        eprintln!("✓ skill fetched from wiki → {}", dst.display());+                        return write_completions(&home);+                    }+                }+            }+        }+        _ => {}+    }+    if !dst.exists() {+        std::fs::write(&dst, "# adom-tts\n\nSee https://wiki-ufypy5dpx93o.adom.cloud/apps/adom-tts for the full skill.\n")?;+    }+    eprintln!("! wiki unreachable — kept {} (may be stale)", dst.display());+    write_completions(&home)+}++fn write_completions(home: &std::path::Path) -> Result<()> {+    let comp_dir = home.join(".bash_completion.d");+    std::fs::create_dir_all(&comp_dir)?;+    std::fs::write(comp_dir.join("adom-tts"),+        "_adom_tts() {\n\+         \tlocal cur=\"${COMP_WORDS[COMP_CWORD]}\"\n\+         \tlocal prev=\"${COMP_WORDS[COMP_CWORD-1]}\"\n\+         \tcase \"$prev\" in\n\+         \t\t--surface) COMPREPLY=($(compgen -W \"hydrogen pup\" -- \"$cur\")); return ;;\n\+         \tesac\n\+         \tif [[ \"$cur\" == --* ]]; then\n\+         \t\tCOMPREPLY=($(compgen -W \"--play --surface --wait --out --voice --rate --history --no-pronunciations --show\" -- \"$cur\"))\n\+         \t\treturn\n\+         \tfi\n\+         \tCOMPREPLY=($(compgen -W \"health version config say voices pronunciations install serve push play pron\" -- \"$cur\"))\n\+         }\n\+         complete -F _adom_tts adom-tts\n")?;+    eprintln!("✓ completions at {}", comp_dir.join("adom-tts").display());+    Ok(())+}
src/player.htmladded+136
@@ -0,0 +1,136 @@+<!doctype html>+<html lang="en">+<head>+<meta charset="utf-8">+<meta name="viewport" content="width=device-width,initial-scale=1">+<title>{{TITLE}} — adom-tts player</title>+<style>+  :root {+    --bg: #0b0b0b;+    --fg: #e8e8e8;+    --muted: #888;+    --accent: #4ea3ff;+    --pill-bg: #1a1a1a;+    --pill-border: #2a2a2a;+  }+  html, body { background: var(--bg); color: var(--fg); margin: 0; padding: 0; height: 100%; }+  body {+    font-family: -apple-system, system-ui, "Segoe UI", Roboto, sans-serif;+    display: flex; align-items: center; justify-content: center;+  }+  .card {+    width: min(640px, 90vw);+    padding: 28px 32px;+    border: 1px solid var(--pill-border);+    border-radius: 12px;+    background: #111;+    box-shadow: 0 8px 32px rgba(0,0,0,0.4);+  }+  .title {+    font-size: 18px; font-weight: 600;+    margin: 0 0 6px;+    word-break: break-all;+  }+  .meta {+    font-size: 13px; color: var(--muted);+    margin: 0 0 18px;+  }+  audio {+    width: 100%;+    margin-bottom: 14px;+    filter: invert(1) hue-rotate(180deg);+  }+  .row {+    display: flex; align-items: center; gap: 10px;+    flex-wrap: wrap;+  }+  button {+    background: var(--accent); color: #fff; border: 0;+    padding: 8px 14px; border-radius: 6px;+    font-size: 14px; font-weight: 500;+    cursor: pointer;+  }+  button:hover { filter: brightness(1.1); }+  button:active { filter: brightness(0.9); }+  .pill {+    display: inline-block;+    padding: 4px 10px;+    background: var(--pill-bg);+    border: 1px solid var(--pill-border);+    border-radius: 999px;+    font-size: 12px;+    color: var(--muted);+  }+  .pill.playing { color: var(--accent); border-color: var(--accent); }+  .pill.paused  { color: #d4a017; border-color: #d4a017; }+  .pill.ended   { color: #888; }+  .pill.error   { color: #e0524d; border-color: #e0524d; }+  .footer {+    margin-top: 18px;+    font-size: 11px;+    color: var(--muted);+    text-align: center;+    opacity: 0.7;+  }+</style>+</head>+<body>+  <div class="card">+    <p class="title">{{TITLE}}</p>+    <p class="meta" id="meta">{{META_LINE}}</p>+    <audio id="a" controls autoplay preload="auto" src="{{AUDIO_URL}}"></audio>+    <div class="row">+      <button id="replay">Replay</button>+      <span id="status" class="pill">loading</span>+    </div>+    <p class="footer">adom-tts auto-player · close this tab when done</p>+  </div>+<script>+  const MODE = "{{MODE}}"; // "hydrogen" or "pup"+  const a = document.getElementById('a');+  const status = document.getElementById('status');+  const meta = document.getElementById('meta');++  function setStatus(label, cls) {+    status.textContent = label;+    status.className = 'pill ' + cls;+  }++  a.addEventListener('loadedmetadata', () => {+    if (a.duration && isFinite(a.duration)) {+      const mins = Math.floor(a.duration / 60);+      const secs = String(Math.floor(a.duration % 60)).padStart(2, '0');+      const dur = mins + ':' + secs;+      meta.textContent = (meta.textContent ? meta.textContent + ' · ' : '') + dur;+    }+  });+  a.addEventListener('playing', () => setStatus('playing', 'playing'));+  a.addEventListener('pause',   () => { if (!a.ended) setStatus('paused', 'paused'); });+  a.addEventListener('ended',   () => {+    setStatus('ended', 'ended');+    if (MODE === 'hydrogen') {+      // Tell the one-shot server to shut down early.+      fetch('/done').catch(() => {});+    }+  });+  a.addEventListener('error',   () => setStatus('audio error', 'error'));++  document.getElementById('replay').addEventListener('click', () => {+    a.currentTime = 0;+    a.play().catch(() => {});+  });++  // Belt-and-braces autoplay defeat: the browser may block the initial+  // autoplay attribute. Fire .play() once after a short delay; if it+  // throws (e.g. user-gesture-required), the user can hit Replay or the+  // <audio controls> play button.+  setTimeout(() => {+    if (a.paused) {+      a.play().catch(() => {+        setStatus('click play', 'paused');+      });+    }+  }, 250);+</script>+</body>+</html>
src/server.rsadded+626
@@ -0,0 +1,626 @@+//! Tiny HTTP server for the TTS playground — Hydrogen webview backend.+//!+//! Every UI action has a curl-equivalent on this server (app-creator §7).+//! Synthesis hits the shared service-tts container so pronunciation+//! overrides + source-hash cache apply uniformly.++use crate::history;+use anyhow::Result;+use base64::{engine::general_purpose::STANDARD as B64, Engine};+use serde::Deserialize;+use std::collections::VecDeque;+use std::io::Cursor;+use std::process::Command;+use std::sync::atomic::{AtomicBool, Ordering};+use std::sync::{Arc, Mutex};+use std::thread;+use tiny_http::{Header, Method, Request, Response, Server, StatusCode};++const UI_HTML: &str = include_str!("ui.html");+pub(crate) const FAVICON_SVG: &str = include_str!("../docs/icon.svg");+const CONSOLE_CAP: usize = 500;+const TAB_NAME: &str = "TTS Playground";+pub(crate) const PANEL_TYPE: &str = "adom/a1b2c3d4-0031-4000-a000-000000000031";++pub struct ServeConfig {+    pub port: u16,+    pub service_url: String,+    pub open_tab: bool,+    /// Reserved for the /eval hot-patch channel (app-creator §7b).+    #[allow(dead_code)]+    pub dev: bool,+}++struct Shared {+    console: Mutex<VecDeque<serde_json::Value>>,+    last_synth_at: Mutex<Option<String>>,+    voices_cache: Mutex<Option<String>>,+    shutdown: AtomicBool,+}++pub fn run(cfg: ServeConfig) -> Result<()> {+    let addr = format!("0.0.0.0:{}", cfg.port);+    let server = Server::http(&addr)+        .map_err(|e| anyhow::anyhow!("bind {addr}: {e}"))?;+    let server = Arc::new(server);+    let shared = Arc::new(Shared {+        console: Mutex::new(VecDeque::with_capacity(CONSOLE_CAP)),+        last_synth_at: Mutex::new(None),+        voices_cache: Mutex::new(None),+        shutdown: AtomicBool::new(false),+    });++    let url = webview_url(cfg.port);+    println!("OK: adom-tts serve listening on {addr}");+    println!("OK: webview url: {url}");++    if cfg.open_tab {+        match open_webview_tab(&url) {+            Ok(()) => println!("OK: opened Hydrogen webview tab \"{TAB_NAME}\""),+            Err(e) => eprintln!("Hint: could not open webview tab ({e}) — visit {url} manually"),+        }+    }++    for request in server.incoming_requests() {+        if shared.shutdown.load(Ordering::SeqCst) {+            break;+        }+        let shared = shared.clone();+        let server = server.clone();+        let cfg_service_url = cfg.service_url.clone();+        thread::spawn(move || {+            handle(request, &shared, &cfg_service_url, &server);+        });+    }++    if cfg.open_tab {+        let _ = close_webview_tab();+    }+    println!("OK: shutdown complete");+    Ok(())+}++fn handle(request: Request, shared: &Shared, service_url: &str, server: &Server) {+    let method = request.method().clone();+    let raw = request.url().to_string();+    let url = raw.split('?').next().unwrap_or("").to_string();++    let result: std::io::Result<()> = match (&method, url.as_str()) {+        (Method::Get, "/" | "/index.html") => respond_html(request, UI_HTML),+        (Method::Get, "/favicon.svg") => respond(request, 200, "image/svg+xml", FAVICON_SVG.as_bytes().to_vec()),+        (Method::Get, "/favicon.ico") => respond(request, 200, "image/svg+xml", FAVICON_SVG.as_bytes().to_vec()),+        (Method::Get, "/state") => handle_state(request, shared, service_url),+        (Method::Get, "/voices") => handle_voices(request, shared, service_url),+        (Method::Get, "/health") => handle_health(request, service_url),+        (Method::Get, "/pronunciations") => handle_pronunciations(request, service_url),+        (Method::Post, "/pronunciation/propose") => handle_pron_propose(request),+        (Method::Get, "/pronunciation/proposals") => handle_pron_proposals(request),+        (Method::Post, "/synth") => handle_synth(request, shared, service_url),+        (Method::Get, "/history") => handle_history_list(request),+        (Method::Post, "/console") => handle_console_append(request, shared),+        (Method::Get, "/console") => handle_console_tail(request, shared),+        (Method::Delete, "/console") => handle_console_clear(request, shared),+        (Method::Post | Method::Get, "/shutdown") => {+            let _ = respond_json(request, 200, &serde_json::json!({"ok": true}));+            shared.shutdown.store(true, Ordering::SeqCst);+            server.unblock();+            return;+        }+        (Method::Get, path) if path.starts_with("/history/") => {+            let id = path.trim_start_matches("/history/").trim_end_matches(".mp3");+            handle_history_audio(request, id)+        }+        (Method::Delete, path) if path.starts_with("/history/") => {+            let id = path.trim_start_matches("/history/");+            handle_history_delete(request, id)+        }+        _ => respond(request, 404, "application/json",+            b"{\"ok\":false,\"error\":\"not found\"}".to_vec()),+    };+    if let Err(e) = result {+        eprintln!("adom-tts serve: request error: {e}");+    }+}++fn handle_state(req: Request, shared: &Shared, service_url: &str) -> std::io::Result<()> {+    let count = history::list().map(|v| v.len()).unwrap_or(0);+    let last = shared.last_synth_at.lock().unwrap().clone();+    respond_json(req, 200, &serde_json::json!({+        "ok": true,+        "version": env!("CARGO_PKG_VERSION"),+        "service_url": service_url,+        "history_count": count,+        "last_synth_at": last,+    }))+}++fn handle_health(req: Request, service_url: &str) -> std::io::Result<()> {+    match reqwest::blocking::Client::new()+        .get(format!("{service_url}/health"))+        .timeout(std::time::Duration::from_secs(5))+        .send()+    {+        Ok(r) => {+            let status = r.status().as_u16();+            let body = r.text().unwrap_or_default();+            respond(req, status, "application/json", body.into_bytes())+        }+        Err(e) => respond_json(req, 502, &serde_json::json!({+            "ok": false, "error": format!("service-tts unreachable: {e}")+        })),+    }+}++fn handle_voices(req: Request, shared: &Shared, service_url: &str) -> std::io::Result<()> {+    {+        let cache = shared.voices_cache.lock().unwrap();+        if let Some(body) = cache.as_ref() {+            return respond(req, 200, "application/json", body.clone().into_bytes());+        }+    }+    match reqwest::blocking::Client::new()+        .get(format!("{service_url}/tts/voices"))+        .timeout(std::time::Duration::from_secs(15))+        .send()+    {+        Ok(r) if r.status().is_success() => {+            let body = r.text().unwrap_or_default();+            *shared.voices_cache.lock().unwrap() = Some(body.clone());+            respond(req, 200, "application/json", body.into_bytes())+        }+        Ok(r) => respond_json(req, r.status().as_u16(), &serde_json::json!({+            "ok": false, "error": format!("service-tts HTTP {}", r.status())+        })),+        Err(e) => respond_json(req, 502, &serde_json::json!({+            "ok": false, "error": format!("service-tts unreachable: {e}")+        })),+    }+}++#[derive(Deserialize)]+struct SynthReq {+    text: String,+    #[serde(default = "default_voice")]+    voice: String,+    #[serde(default = "default_rate")]+    rate: String,+    #[serde(default = "default_apply")]+    apply_pronunciations: bool,+}+fn default_voice() -> String { "en-US-AndrewNeural".into() }+fn default_rate() -> String { "+0%".into() }+fn default_apply() -> bool { true }++fn handle_synth(mut req: Request, shared: &Shared, service_url: &str) -> std::io::Result<()> {+    let mut body = String::new();+    std::io::Read::read_to_string(req.as_reader(), &mut body).ok();+    let parsed: SynthReq = match serde_json::from_str(&body) {+        Ok(v) => v,+        Err(e) => return respond_json(req, 400, &serde_json::json!({+            "ok": false, "error": format!("bad json: {e}")+        })),+    };+    if parsed.text.trim().is_empty() {+        return respond_json(req, 400, &serde_json::json!({+            "ok": false, "error": "text is empty"+        }));+    }+    let resp = match reqwest::blocking::Client::new()+        .post(format!("{service_url}/tts/say"))+        .json(&serde_json::json!({+            "text": parsed.text,+            "voice": parsed.voice,+            "rate": parsed.rate,+            "apply_pronunciations": parsed.apply_pronunciations,+        }))+        .timeout(std::time::Duration::from_secs(60))+        .send()+    {+        Ok(r) => r,+        Err(e) => return respond_json(req, 502, &serde_json::json!({+            "ok": false, "error": format!("service-tts unreachable: {e}")+        })),+    };+    if !resp.status().is_success() {+        let status = resp.status().as_u16();+        return respond_json(req, status, &serde_json::json!({+            "ok": false, "error": format!("service-tts HTTP {status}")+        }));+    }+    let hash = resp.headers().get("x-tts-hash")+        .and_then(|v| v.to_str().ok()).map(str::to_string);+    let bytes = match resp.bytes() {+        Ok(b) => b.to_vec(),+        Err(e) => return respond_json(req, 502, &serde_json::json!({+            "ok": false, "error": format!("read body: {e}")+        })),+    };+    let clip = match history::save(&bytes, &parsed.text, &parsed.voice, &parsed.rate, "ui", hash) {+        Ok(c) => c,+        Err(e) => return respond_json(req, 500, &serde_json::json!({+            "ok": false, "error": format!("save clip: {e}")+        })),+    };+    *shared.last_synth_at.lock().unwrap() = Some(clip.created_at.clone());+    respond_json(req, 200, &serde_json::json!({"ok": true, "clip": clip}))+}++fn handle_pronunciations(req: Request, service_url: &str) -> std::io::Result<()> {+    match reqwest::blocking::Client::new()+        .get(format!("{service_url}/tts/pronunciations"))+        .timeout(std::time::Duration::from_secs(10))+        .send()+    {+        Ok(r) => {+            let status = r.status().as_u16();+            let body = r.text().unwrap_or_default();+            respond(req, status, "application/json", body.into_bytes())+        }+        Err(e) => respond_json(req, 502, &serde_json::json!({+            "ok": false, "error": format!("service-tts unreachable: {e}")+        })),+    }+}++#[derive(Deserialize)]+struct PronProposal {+    written: String,+    narration: String,+    #[serde(default)]+    reason: String,+}++fn gallia_pronunciations_path() -> Option<std::path::PathBuf> {+    use std::path::PathBuf;+    let candidates: Vec<PathBuf> = [+        std::env::var("ADOM_GALLIA").ok(),+        Some(format!("{}/project/gallia", std::env::var("HOME").unwrap_or_default())),+        Some("/home/adom/project/gallia".into()),+    ].into_iter().flatten()+        .map(|s| PathBuf::from(s).join("skills/tts-pronunciation/pronunciations.json"))+        .collect();+    candidates.into_iter().find(|p| p.exists())+}++fn handle_pron_propose(mut req: Request) -> std::io::Result<()> {+    let mut body = String::new();+    std::io::Read::read_to_string(req.as_reader(), &mut body).ok();+    let p: PronProposal = match serde_json::from_str(&body) {+        Ok(v) => v,+        Err(e) => return respond_json(req, 400, &serde_json::json!({+            "ok": false, "error": format!("bad json: {e}")+        })),+    };+    if p.written.trim().is_empty() || p.narration.trim().is_empty() {+        return respond_json(req, 400, &serde_json::json!({+            "ok": false, "error": "written and narration are required"+        }));+    }+    let today = chrono::Utc::now().format("%Y-%m-%d").to_string();+    let added_by = std::env::var("ADOM_USER")+        .or_else(|_| std::env::var("USER"))+        .unwrap_or_else(|_| "unknown".into());+    let reason = if p.reason.trim().is_empty() {+        "Added via TTS Playground.".to_string()+    } else { p.reason.trim().to_string() };+    let entry = serde_json::json!({+        "written": p.written.trim(),+        "narration": p.narration.trim(),+        "engines_confirmed": ["edge-tts"],+        "reason": reason,+        "added_by": added_by,+        "added_date": today,+    });++    // Try to write directly into the gallia skill file.+    if let Some(path) = gallia_pronunciations_path() {+        match append_to_gallia(&path, &entry) {+            Ok(n) => {+                return respond_json(req, 200, &serde_json::json!({+                    "ok": true,+                    "target": "gallia",+                    "path": path.to_string_lossy(),+                    "total_entries": n,+                    "entry": entry,+                    "next_step": format!(+                        "commit + push {} so the service-tts container picks it up on next redeploy",+                        path.display()+                    ),+                }));+            }+            Err(e) => {+                eprintln!("adom-tts: gallia append failed ({e}) — falling back to proposal queue");+            }+        }+    }++    // Fallback: append to local proposals queue.+    let queue = proposals_path();+    if let Some(parent) = queue.parent() { let _ = std::fs::create_dir_all(parent); }+    let mut list: Vec<serde_json::Value> = std::fs::read_to_string(&queue).ok()+        .and_then(|s| serde_json::from_str(&s).ok())+        .unwrap_or_default();+    list.push(entry.clone());+    if let Err(e) = std::fs::write(&queue, serde_json::to_vec_pretty(&list).unwrap_or_default()) {+        return respond_json(req, 500, &serde_json::json!({+            "ok": false, "error": format!("write queue: {e}")+        }));+    }+    respond_json(req, 200, &serde_json::json!({+        "ok": true,+        "target": "queue",+        "path": queue.to_string_lossy(),+        "total_entries": list.len(),+        "entry": entry,+        "next_step": format!(+            "gallia checkout not found — entry saved to {}. Paste this JSON into gallia/skills/tts-pronunciation/pronunciations.json > entries when you're ready.",+            queue.display()+        ),+    }))+}++fn proposals_path() -> std::path::PathBuf {+    let home = std::env::var("HOME").unwrap_or_default();+    std::path::PathBuf::from(home).join(".adom/tts/proposed-pronunciations.json")+}++fn handle_pron_proposals(req: Request) -> std::io::Result<()> {+    let queue = proposals_path();+    let list: Vec<serde_json::Value> = std::fs::read_to_string(&queue).ok()+        .and_then(|s| serde_json::from_str(&s).ok())+        .unwrap_or_default();+    respond_json(req, 200, &serde_json::json!({+        "ok": true,+        "path": queue.to_string_lossy(),+        "proposals": list,+    }))+}++/// Append an entry to the gallia pronunciations.json `entries` array while+/// preserving the file's formatting. Returns the new entry count.+fn append_to_gallia(path: &std::path::Path, entry: &serde_json::Value) -> anyhow::Result<usize> {+    let src = std::fs::read_to_string(path)?;+    let mut doc: serde_json::Value = serde_json::from_str(&src)?;+    let today = chrono::Utc::now().format("%Y-%m-%d").to_string();+    if let Some(obj) = doc.as_object_mut() {+        obj.insert("updated".to_string(), serde_json::Value::String(today));+        let entries = obj.get_mut("entries")+            .and_then(|v| v.as_array_mut())+            .ok_or_else(|| anyhow::anyhow!("no entries array"))?;+        // Dedupe by `written` (case-insensitive).+        let written_lc = entry.get("written").and_then(|s| s.as_str())+            .map(|s| s.to_lowercase()).unwrap_or_default();+        entries.retain(|e| {+            e.get("written").and_then(|s| s.as_str())+                .map(|s| s.to_lowercase()) != Some(written_lc.clone())+        });+        entries.push(entry.clone());+        let n = entries.len();+        std::fs::write(path, serde_json::to_string_pretty(&doc)? + "\n")?;+        Ok(n)+    } else {+        anyhow::bail!("pronunciations.json is not a JSON object");+    }+}++fn handle_history_list(req: Request) -> std::io::Result<()> {+    match history::list() {+        Ok(clips) => respond_json(req, 200, &serde_json::json!({"ok": true, "clips": clips})),+        Err(e) => respond_json(req, 500, &serde_json::json!({+            "ok": false, "error": format!("{e}")+        })),+    }+}++fn handle_history_audio(req: Request, id: &str) -> std::io::Result<()> {+    let path = match history::audio_path(id) {+        Ok(p) => p,+        Err(_) => return respond(req, 404, "application/json",+            b"{\"ok\":false,\"error\":\"not found\"}".to_vec()),+    };+    let bytes = std::fs::read(&path)?;+    respond(req, 200, "audio/mpeg", bytes)+}++fn handle_history_delete(req: Request, id: &str) -> std::io::Result<()> {+    match history::delete(id) {+        Ok(()) => respond_json(req, 200, &serde_json::json!({"ok": true, "id": id})),+        Err(e) => respond_json(req, 500, &serde_json::json!({+            "ok": false, "error": format!("{e}")+        })),+    }+}++fn handle_console_append(mut req: Request, shared: &Shared) -> std::io::Result<()> {+    let mut body = String::new();+    std::io::Read::read_to_string(req.as_reader(), &mut body).ok();+    let entry: serde_json::Value = serde_json::from_str(&body)+        .unwrap_or_else(|_| serde_json::json!({"level":"log","msg":body}));+    let mut buf = shared.console.lock().unwrap();+    if buf.len() >= CONSOLE_CAP { buf.pop_front(); }+    buf.push_back(entry);+    respond_json(req, 200, &serde_json::json!({"ok": true}))+}++fn handle_console_tail(req: Request, shared: &Shared) -> std::io::Result<()> {+    let buf = shared.console.lock().unwrap();+    let all: Vec<_> = buf.iter().cloned().collect();+    respond_json(req, 200, &serde_json::json!({"ok": true, "entries": all}))+}++fn handle_console_clear(req: Request, shared: &Shared) -> std::io::Result<()> {+    shared.console.lock().unwrap().clear();+    respond_json(req, 200, &serde_json::json!({"ok": true}))+}++fn respond(req: Request, status: u16, content_type: &str, body: Vec<u8>) -> std::io::Result<()> {+    let ct = Header::from_bytes(&b"Content-Type"[..], content_type.as_bytes())+        .expect("valid header");+    let len = body.len();+    let cursor = Cursor::new(body);+    let resp = Response::new(StatusCode(status), vec![ct], cursor, Some(len), None);+    req.respond(resp)+}++fn respond_html(req: Request, body: &str) -> std::io::Result<()> {+    respond(req, 200, "text/html; charset=utf-8", body.as_bytes().to_vec())+}++fn respond_json(req: Request, status: u16, value: &serde_json::Value) -> std::io::Result<()> {+    let bytes = serde_json::to_vec(value).unwrap_or_else(|_| b"{}".to_vec());+    respond(req, status, "application/json", bytes)+}++pub(crate) fn webview_url(port: u16) -> String {+    match std::env::var("VSCODE_PROXY_URI") {+        Ok(template) => template.replace("{{port}}", &port.to_string()),+        Err(_) => format!("http://127.0.0.1:{port}/"),+    }+}++fn open_webview_tab(url: &str) -> Result<()> {+    open_webview_tab_named(url, TAB_NAME)+}++pub(crate) fn open_webview_tab_named(url: &str, display_name: &str) -> Result<()> {+    let workspace = Command::new("adom-cli")+        .args(["hydrogen", "workspace", "get"])+        .output()?;+    if !workspace.status.success() {+        anyhow::bail!("adom-cli hydrogen workspace get failed");+    }+    let stdout = String::from_utf8_lossy(&workspace.stdout);+    let icon_data = format!("data:image/svg+xml;base64,{}", B64.encode(FAVICON_SVG.as_bytes()));+    let initial = serde_json::json!({"url": url}).to_string();++    if let Some(leaf_id) = pick_non_vscode_leaf(&stdout) {+        let out = Command::new("adom-cli")+            .args([+                "hydrogen", "workspace", "add-tab",+                "--panel-id", &leaf_id,+                "--panel-type", PANEL_TYPE,+                "--display-name", display_name,+                "--display-icon", &icon_data,+                "--initial-state", &initial,+            ])+            .output()?;+        if !out.status.success() {+            anyhow::bail!("add-tab failed: {}", String::from_utf8_lossy(&out.stderr));+        }+        return Ok(());+    }++    // No non-VS-Code panel exists — split the VS Code pane to make one.+    // Per `feedback_never_cover_vscode.md` we never put a tab on top of+    // VS Code; splitting creates a sibling pane instead.+    let vscode_id = pick_vscode_leaf(&stdout)+        .ok_or_else(|| anyhow::anyhow!("no panel found in workspace"))?;+    let split = Command::new("adom-cli")+        .args([+            "hydrogen", "workspace", "split",+            "--panel-id", &vscode_id,+            "--direction", "horizontal",+            "--position", "after",+            "--ratio", "0.5",+            "--panel-type", PANEL_TYPE,+            "--display-name", display_name,+            "--display-icon", &icon_data,+            "--initial-state", &initial,+        ])+        .output()?;+    if !split.status.success() {+        anyhow::bail!("split failed: {}", String::from_utf8_lossy(&split.stderr));+    }+    Ok(())+}++/// Inverse of `pick_non_vscode_leaf` — returns the first panel id whose+/// tabs include the VS Code panelType. Used as a split anchor when no+/// other host panel exists yet.+fn pick_vscode_leaf(workspace_json: &str) -> Option<String> {+    const VSCODE_SUFFIX: &str = "eeee-4000-a000-00000000000e";+    let v: serde_json::Value = serde_json::from_str(workspace_json).ok()?;++    fn collect(node: &serde_json::Value, out: &mut Vec<serde_json::Value>) {+        if let Some(obj) = node.as_object() {+            if obj.get("type").and_then(|s| s.as_str()) == Some("leaf")+                && obj.contains_key("id")+                && obj.get("tabs").map(|t| t.is_array()).unwrap_or(false)+            {+                out.push(node.clone());+            }+            for (_, v) in obj { collect(v, out); }+        } else if let Some(arr) = node.as_array() {+            for v in arr { collect(v, out); }+        }+    }+    let mut panels = Vec::new();+    collect(&v, &mut panels);++    panels.iter()+        .find(|p| p.get("tabs").and_then(|t| t.as_array()).map(|tabs| {+            tabs.iter().any(|t| t.get("panelType")+                .and_then(|s| s.as_str())+                .map(|s| s.ends_with(VSCODE_SUFFIX))+                .unwrap_or(false))+        }).unwrap_or(false))+        .and_then(|p| p.get("id").and_then(|s| s.as_str()).map(str::to_string))+}++fn pick_non_vscode_leaf(workspace_json: &str) -> Option<String> {+    // Workspace shape: tree of split containers (with `first`/`second`) and+    // panels (with `tabs`). Panels are the leaf nodes that hold tabs — a tab+    // itself carries the `panelType`, not the panel. We want a panel id+    // whose tabs do NOT include the VS Code panelType (never cover VS Code).+    let v: serde_json::Value = serde_json::from_str(workspace_json).ok()?;+    let focused_panel = v.get("focusedPanelId").and_then(|s| s.as_str());+    // VS Code tab panelType ends in "...eeee-4000-a000-00000000000e".+    const VSCODE_SUFFIX: &str = "eeee-4000-a000-00000000000e";++    fn collect_panels(node: &serde_json::Value, out: &mut Vec<serde_json::Value>) {+        if let Some(obj) = node.as_object() {+            if obj.get("type").and_then(|s| s.as_str()) == Some("leaf")+                && obj.contains_key("id")+                && obj.get("tabs").map(|t| t.is_array()).unwrap_or(false)+            {+                out.push(node.clone());+            }+            for (_, v) in obj { collect_panels(v, out); }+        } else if let Some(arr) = node.as_array() {+            for v in arr { collect_panels(v, out); }+        }+    }+    let mut panels = Vec::new();+    collect_panels(&v, &mut panels);++    let covers_vscode = |p: &serde_json::Value| -> bool {+        p.get("tabs").and_then(|t| t.as_array()).map(|tabs| {+            tabs.iter().any(|t| t.get("panelType")+                .and_then(|s| s.as_str())+                .map(|s| s.ends_with(VSCODE_SUFFIX))+                .unwrap_or(false))+        }).unwrap_or(false)+    };++    let ok_panels: Vec<_> = panels.into_iter().filter(|p| !covers_vscode(p)).collect();+    if ok_panels.is_empty() { return None; }++    // Prefer the focused panel if it's not a VS Code host.+    if let Some(fid) = focused_panel {+        if let Some(m) = ok_panels.iter()+            .find(|p| p.get("id").and_then(|s| s.as_str()) == Some(fid))+        {+            return m.get("id").and_then(|s| s.as_str()).map(str::to_string);+        }+    }+    ok_panels.first()+        .and_then(|p| p.get("id").and_then(|s| s.as_str()).map(str::to_string))+}++fn close_webview_tab() -> Result<()> {+    let _ = Command::new("adom-cli")+        .args(["hydrogen", "workspace", "remove-tab", "--name", TAB_NAME])+        .output();+    Ok(())+}+
src/ui.htmladded+1390
@@ -0,0 +1,1390 @@+<!doctype html>+<html lang="en">+<head>+  <meta charset="utf-8">+  <meta name="viewport" content="width=device-width,initial-scale=1">+  <title>TTS Playground · Adom</title>+  <link rel="icon" type="image/svg+xml" href="favicon.svg">+  <style>+    @import url("https://adom.inc/fonts/fonts.css");++    :root {+      --bg: #0d1117;+      --surface: #161b22;+      --surface2: #1c2129;+      --elevated: #1c2128;+      --border: #30363d;+      --border-muted: #21262d;+      --text: #e6edf3;+      --text-dim: #8b949e;+      --text2: #8b949e;       /* canonical alias of --text-dim */+      --text3: #484f58;       /* tertiary / separator */+      --accent: #00b8b0;+      --green: #3fb950;+      --red: #f85149;+      --yellow: #d29922;+      --blue: #58a6ff;+    }++    * { box-sizing: border-box; }++    html, body {+      margin: 0;+      padding: 0;+      height: 100%;+      background: var(--bg);+      color: var(--text);+      font-family: "Satoshi", "Familjen Grotesk", sans-serif;+      font-size: 14px;+      line-height: 1.5;+      -webkit-font-smoothing: antialiased;+    }++    h1, h2, h3, .headline {+      font-family: "Familjen Grotesk", "Satoshi", sans-serif;+      font-weight: 600;+      letter-spacing: -0.01em;+      margin: 0;+    }++    code, .mono { font-family: "JetBrains Mono", ui-monospace, monospace; }++    .app {+      display: grid;+      grid-template-rows: auto 1fr;+      height: 100vh;+      min-height: 0;+    }++    /* Canonical Adom app-header — see gallia/skills/adom-app-header. */+    header {+      height: 44px;+      padding: 0 16px;+      display: flex;+      align-items: center;+      gap: 12px;+      border-bottom: 1px solid var(--border-muted);+      background: var(--surface);+      position: relative;+      font-family: 'Familjen Grotesk', sans-serif;+      flex-shrink: 0;+    }+    header .brand {+      display: flex; align-items: center; gap: 8px;+      color: var(--accent);+      font-size: 13px; font-weight: 600;+      letter-spacing: 0.01em;+      cursor: default;+      flex-shrink: 0;+      outline: none;+    }+    header .brand:hover .app-name { text-decoration: underline; text-decoration-color: var(--accent); text-underline-offset: 3px; }+    header .brand svg.logo { width: 16px; height: 16px; flex-shrink: 0; color: var(--accent); }+    header .brand svg.logo rect { fill: currentColor; }+    header .brand .app-name { color: var(--accent); }+    header .brand .app-subtitle { color: var(--text2); font-weight: 500; }+    header .brand .sep { color: var(--text3); font-weight: 400; }+    header .vsep { width: 1px; height: 18px; background: var(--border); flex-shrink: 0; }+    header .subject {+      display: flex; align-items: center; gap: 8px;+      min-width: 0; flex: 1 1 auto;+    }+    header .subject-name {+      color: var(--text);+      font-family: 'Familjen Grotesk', sans-serif;+      font-size: 14px; font-weight: 600;+      white-space: nowrap; overflow: hidden; text-overflow: ellipsis;+      min-width: 0;+    }+    header .subject-meta {+      display: flex; align-items: center; gap: 6px;+      color: var(--text2);+      font-family: 'JetBrains Mono', monospace;+      font-size: 11px;+      white-space: nowrap;+      flex-shrink: 0;+    }+    header .subject-meta .sep { color: var(--text3); }+    @media (max-width: 720px) {+      header .subject-meta { display: none; }+    }+    /* Service-status pill in the subject zone — reuses the canonical state-pill shape. */+    header .pill {+      display: inline-flex; align-items: center; gap: 6px;+      padding: 2px 8px; border-radius: 10px;+      background: var(--elevated); border: 1px solid var(--border);+      font-family: 'Familjen Grotesk', sans-serif;+      font-size: 10px; font-weight: 500; letter-spacing: 0.3px;+      color: var(--text2);+      flex-shrink: 0;+    }+    header .pill .dot {+      width: 6px; height: 6px; border-radius: 50%;+      background: var(--text3);+    }+    header .pill.ok .dot { background: var(--green); }+    header .pill.ok { color: var(--green); border-color: var(--green); }+    header .pill.err .dot { background: var(--red); }+    header .pill.err { color: var(--red); border-color: var(--red); }++    /* Interactive hover-popover */+    header .hover-popover {+      position: absolute; top: calc(100% + 6px); left: 16px;+      background: var(--elevated); border: 1px solid var(--border);+      border-radius: 8px; box-shadow: 0 8px 24px rgba(0,0,0,0.5);+      padding: 12px 14px; min-width: 300px; max-width: 420px;+      font-family: 'Satoshi', sans-serif; font-size: 12px; color: var(--text);+      z-index: 99999; opacity: 0; transform: translateY(-2px);+      pointer-events: none;+      transition: opacity 120ms ease, transform 120ms ease;+    }+    header .hover-popover.show { opacity: 1; transform: none; pointer-events: auto; }+    header .hover-popover .popover-title { font-family: 'Familjen Grotesk', sans-serif; font-size: 14px; font-weight: 700; color: var(--text); margin-bottom: 8px; }+    header .hover-popover .popover-row { display: flex; gap: 12px; padding: 3px 0; align-items: baseline; }+    header .hover-popover .popover-row .k { color: var(--text3); font-family: 'JetBrains Mono', monospace; font-size: 10px; text-transform: lowercase; width: 64px; flex-shrink: 0; }+    header .hover-popover .popover-row .v { color: var(--text); font-family: 'Satoshi', sans-serif; font-size: 12px; flex: 1; word-break: break-word; }+    header .hover-popover .popover-actions { margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--border-muted); display: flex; gap: 12px; }+    header .hover-popover .popover-link { color: var(--accent); text-decoration: none; font-family: 'Familjen Grotesk', sans-serif; font-size: 12px; font-weight: 600; }+    header .hover-popover .popover-link:hover { text-decoration: underline; }++    /* ───── Body ───── */+    main {+      display: grid;+      grid-template-columns: minmax(0, 1fr) minmax(260px, 340px);+      grid-template-rows: auto minmax(0, 1fr);+      min-height: 0;+      padding: 16px;+      gap: 16px;+      overflow: hidden;+    }+    main > .compose { grid-column: 1 / 2; grid-row: 1 / 2; }+    main > .history { grid-column: 1 / 2; grid-row: 2 / 3; }+    main > .pron    { grid-column: 2 / 3; grid-row: 1 / 3; }+    @media (max-width: 980px) {+      main {+        grid-template-columns: minmax(0, 1fr);+        grid-template-rows: auto minmax(180px, 1fr) auto;+        overflow-y: auto;+      }+      main > .compose { grid-column: 1; grid-row: 1; }+      main > .history { grid-column: 1; grid-row: 2; }+      main > .pron    { grid-column: 1; grid-row: 3; }+      .pron-list { max-height: 180px; }+    }++    .compose {+      background: var(--surface);+      border: 1px solid var(--border);+      border-radius: 12px;+      padding: 16px;+      display: grid;+      gap: 12px;+    }++    textarea {+      width: 100%;+      min-height: 88px;+      padding: 12px;+      background: var(--bg);+      color: var(--text);+      border: 1px solid var(--border);+      border-radius: 8px;+      font-family: inherit;+      font-size: 14px;+      resize: vertical;+    }+    textarea:focus { outline: 2px solid var(--accent); outline-offset: -1px; border-color: transparent; }++    .controls {+      display: grid;+      grid-template-columns: 1fr 1fr auto;+      gap: 12px;+      align-items: end;+    }+    .field { display: grid; gap: 4px; }+    .field label {+      font-size: 11px;+      color: var(--text-dim);+      text-transform: none;+      letter-spacing: 0;+    }++    select, input[type=range] {+      width: 100%;+      padding: 8px 12px;+      background: var(--bg);+      color: var(--text);+      border: 1px solid var(--border);+      border-radius: 8px;+      font-family: inherit;+      font-size: 14px;+    }+    select:focus { outline: 2px solid var(--accent); outline-offset: -1px; border-color: transparent; }+    input[type=range] { padding: 0; accent-color: var(--accent); }++    .rate-row { display: grid; grid-template-columns: 1fr auto; gap: 8px; align-items: center; }+    .rate-value { font-family: "JetBrains Mono", monospace; font-size: 12px; color: var(--text-dim); min-width: 42px; text-align: right; }++    button {+      display: inline-flex;+      align-items: center;+      justify-content: center;+      gap: 6px;+      padding: 10px 18px;+      background: var(--accent);+      color: #00221f;+      border: none;+      border-radius: 8px;+      font-family: inherit;+      font-size: 14px;+      font-weight: 600;+      cursor: pointer;+      transition: filter 120ms, transform 60ms, background 120ms;+    }+    button:hover:not(:disabled) { filter: brightness(1.08); }+    button:active:not(:disabled) { transform: translateY(1px); }+    button:disabled { opacity: 0.5; cursor: not-allowed; }++    button.ghost {+      background: var(--surface2);+      color: var(--text);+      border: 1px solid var(--border);+      padding: 6px 10px;+      font-weight: 500;+    }+    button.ghost:hover:not(:disabled) { background: var(--border); filter: none; }++    button.icon {+      width: 32px; height: 32px;+      padding: 0;+      background: var(--surface2);+      color: var(--text);+      border: 1px solid var(--border);+    }+    button.icon:hover:not(:disabled) { background: var(--border); filter: none; }+    button.icon svg { width: 16px; height: 16px; fill: currentColor; }++    button.danger.icon:hover:not(:disabled) {+      background: rgba(248, 81, 73, 0.15);+      color: var(--red);+      border-color: var(--red);+    }++    /* ───── History ───── */+    .history {+      display: grid;+      grid-template-rows: auto minmax(0, 1fr);+      gap: 8px;+      min-height: 0;+    }+    .history-head {+      display: flex;+      align-items: center;+      gap: 8px;+      padding: 0 4px;+    }+    .history-head h2 { font-size: 13px; color: var(--text-dim); font-weight: 500; }+    .history-head .count {+      font-family: "JetBrains Mono", monospace;+      font-size: 11px;+      color: var(--text-dim);+    }+    .history-head .spacer { flex: 1; }++    .history-list {+      overflow-y: auto;+      display: grid;+      gap: 8px;+      padding-right: 4px;+      min-height: 0;+    }+    .history-list::-webkit-scrollbar { width: 8px; }+    .history-list::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }++    .clip {+      display: grid;+      grid-template-columns: auto 1fr auto;+      gap: 12px;+      align-items: center;+      padding: 12px;+      background: var(--surface);+      border: 1px solid var(--border);+      border-radius: 8px;+      transition: border-color 120ms;+    }+    .clip:hover { border-color: var(--border); }+    .clip.playing { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }++    .clip .play {+      width: 40px; height: 40px;+      border-radius: 50%;+      background: var(--accent);+      color: #00221f;+      display: flex;+      align-items: center;+      justify-content: center;+      flex-shrink: 0;+    }+    .clip .play svg { width: 18px; height: 18px; fill: currentColor; }+    .clip.playing .play { background: var(--surface2); color: var(--accent); border: 1px solid var(--accent); }++    .clip .meta {+      display: grid;+      gap: 4px;+      min-width: 0;+    }+    .clip .text {+      color: var(--text);+      font-size: 14px;+      overflow: hidden;+      text-overflow: ellipsis;+      display: -webkit-box;+      -webkit-line-clamp: 2;+      -webkit-box-orient: vertical;+    }+    .clip .sub {+      display: flex;+      gap: 6px;+      flex-wrap: wrap;+      align-items: center;+      font-size: 11px;+      color: var(--text-dim);+    }+    .clip .badge {+      padding: 2px 6px;+      border-radius: 4px;+      background: var(--surface2);+      border: 1px solid var(--border);+      font-family: "JetBrains Mono", monospace;+      font-size: 10px;+    }+    .clip .badge.source-ui { color: var(--blue); border-color: var(--blue); }+    .clip .badge.source-cli { color: var(--yellow); border-color: var(--yellow); }+    .clip .badge.source-external { color: var(--text-dim); }++    .clip .actions {+      display: flex;+      gap: 6px;+      flex-shrink: 0;+    }++    .empty {+      padding: 48px 16px;+      text-align: center;+      color: var(--text-dim);+      border: 1px dashed var(--border);+      border-radius: 12px;+    }+    .empty h3 {+      font-size: 15px;+      font-weight: 500;+      color: var(--text);+      margin-bottom: 4px;+    }+    .empty p { font-size: 13px; }++    /* ───── Toast ───── */+    .toast {+      position: fixed;+      bottom: 16px;+      right: 16px;+      padding: 10px 14px;+      background: var(--surface);+      border: 1px solid var(--border);+      border-radius: 8px;+      color: var(--text);+      font-size: 13px;+      box-shadow: 0 8px 24px rgba(0,0,0,0.4);+      max-width: 360px;+      opacity: 0;+      transform: translateY(10px);+      transition: opacity 200ms, transform 200ms;+      pointer-events: none;+      z-index: 100;+    }+    .toast.show { opacity: 1; transform: translateY(0); }+    .toast.err { border-color: var(--red); color: var(--red); }+    .toast.ok { border-color: var(--green); }++    /* ───── Tooltip (§1d — one body-appended fixed div, cursor-anchored, z:99999) ───── */+    #global-tooltip {+      position: fixed;+      top: 0; left: 0;+      z-index: 99999;+      pointer-events: none;+      padding: 7px 10px;+      background: #000;+      color: var(--text);+      border: 1px solid var(--border);+      border-radius: 6px;+      font-family: "Satoshi", sans-serif;+      font-size: 12px;+      font-weight: 400;+      line-height: 1.45;+      text-transform: none;+      letter-spacing: 0;+      white-space: pre-line;+      max-width: min(320px, calc(100vw - 40px));+      width: max-content;+      box-shadow: 0 8px 24px rgba(0,0,0,0.5);+      transform: translate(var(--tip-x, 0), var(--tip-y, 0));+      opacity: 0;+      display: none;+      transition: opacity 180ms ease;+    }+    #global-tooltip.visible { display: block; }+    #global-tooltip.shown { opacity: 1; }++    @media (max-width: 640px) {+      .controls { grid-template-columns: 1fr; }+      .clip { grid-template-columns: auto 1fr; }+      .clip .actions { grid-column: 1 / -1; justify-content: flex-end; }+    }++    /* ───── Pronunciation hint banner above compose ───── */+    .pron-hint {+      display: none;+      padding: 8px 12px;+      background: rgba(0, 184, 176, 0.08);+      border: 1px solid rgba(0, 184, 176, 0.35);+      border-radius: 8px;+      color: var(--text);+      font-size: 12.5px;+      line-height: 1.5;+    }+    .pron-hint.show { display: block; }+    .pron-hint strong { color: var(--accent); font-weight: 600; }+    .pron-hint .mono { color: var(--text-dim); }++    /* ───── Pronunciation panel ───── */+    .pron {+      display: grid;+      grid-template-rows: auto auto auto minmax(0, 1fr);+      gap: 12px;+      min-height: 0;+      background: var(--surface);+      border: 1px solid var(--border);+      border-radius: 12px;+      padding: 16px;+    }+    .pron-head {+      display: flex;+      align-items: center;+      gap: 8px;+    }+    .pron-head h2 { font-size: 14px; font-weight: 600; }+    .pron-head .count {+      font-family: "JetBrains Mono", monospace;+      font-size: 11px;+      color: var(--text-dim);+      margin-left: 4px;+    }+    .pron-head .spacer { flex: 1; }+    .pron-explain {+      font-size: 12px;+      color: var(--text-dim);+      line-height: 1.5;+    }+    .pron-add {+      display: grid;+      grid-template-columns: 1fr 1fr;+      gap: 8px;+      padding: 12px;+      background: var(--surface2);+      border: 1px solid var(--border);+      border-radius: 8px;+    }+    .pron-add .full { grid-column: 1 / -1; }+    .pron-add input, .pron-add textarea {+      width: 100%;+      padding: 8px 10px;+      background: var(--bg);+      color: var(--text);+      border: 1px solid var(--border);+      border-radius: 6px;+      font-family: inherit;+      font-size: 13px;+    }+    .pron-add input:focus, .pron-add textarea:focus {+      outline: 2px solid var(--accent);+      outline-offset: -1px;+      border-color: transparent;+    }+    .pron-add label {+      font-size: 11px;+      color: var(--text-dim);+      margin-bottom: 3px;+      display: block;+    }+    .pron-add textarea { min-height: 50px; resize: vertical; font-size: 12.5px; }+    .pron-actions {+      display: flex;+      gap: 6px;+      grid-column: 1 / -1;+      justify-content: flex-end;+    }+    .pron-actions button { padding: 6px 12px; font-size: 12.5px; }++    .pron-search {+      padding: 8px 10px;+      background: var(--bg);+      color: var(--text);+      border: 1px solid var(--border);+      border-radius: 6px;+      font-family: inherit;+      font-size: 12.5px;+    }+    .pron-search:focus { outline: 2px solid var(--accent); outline-offset: -1px; border-color: transparent; }++    .pron-list {+      overflow-y: auto;+      display: grid;+      gap: 4px;+      padding-right: 2px;+      min-height: 120px;+    }+    .pron-list::-webkit-scrollbar { width: 6px; }+    .pron-list::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }+    .pron-row {+      display: grid;+      grid-template-columns: 1fr auto 1fr;+      align-items: center;+      gap: 6px;+      padding: 6px 10px;+      background: var(--surface2);+      border: 1px solid var(--border);+      border-radius: 6px;+      font-size: 12px;+    }+    .pron-row .from { color: var(--text); font-weight: 500; font-family: "JetBrains Mono", monospace; font-size: 11.5px; }+    .pron-row .arrow { color: var(--text-dim); font-size: 11px; }+    .pron-row .to { color: var(--accent); font-family: "JetBrains Mono", monospace; font-size: 11.5px; }+  </style>+</head>+<body>+  <div class="app">+    <!-- Canonical Adom app-header — see gallia/skills/adom-app-header/SKILL.md. -->+    <header>+      <div class="brand" data-tooltip-target="app-info" tabindex="0">+        <svg class="logo" viewBox="0 0 64 64" aria-hidden="true">+          <rect x="8"  y="28" width="6" height="8"  rx="2"/>+          <rect x="18" y="22" width="6" height="20" rx="2"/>+          <rect x="28" y="14" width="6" height="36" rx="2"/>+          <rect x="38" y="22" width="6" height="20" rx="2"/>+          <rect x="48" y="28" width="6" height="8"  rx="2"/>+        </svg>+        <span class="app-name">adom-tts</span>+        <span class="sep">·</span>+        <span class="app-subtitle">TTS playground</span>+      </div>++      <div class="vsep" aria-hidden="true"></div>++      <div class="subject">+        <span class="subject-name">Speech synthesis &amp; pronunciations</span>+        <span id="service-pill" class="pill" data-tooltip="Status of the shared service-tts container. Green = reachable. Red = unreachable (your clips will fail to synthesize).">+          <span class="dot"></span>+          <span id="service-pill-text">connecting…</span>+        </span>+        <span class="subject-meta" id="topMeta"></span>+      </div>++      <div class="hover-popover" id="app-info" role="dialog" aria-label="App information">+        <div class="popover-title">adom-tts</div>+        <div class="popover-row"><span class="k">about</span>+          <span class="v">Shared edge-tts wrapper with pronunciation overrides + source-hash cache. Every Adom tool that speaks routes through here so an Adom-term fix (KiCad, adom-tsci, etc.) applies everywhere.</span></div>+        <div class="popover-row"><span class="k">version</span><span class="v" id="popVersion">v0.1.0</span></div>+        <div class="popover-row"><span class="k">engine</span><span class="v">service-tts · edge-tts (Andrew Multilingual)</span></div>+        <div class="popover-row"><span class="k">author</span><span class="v">Adom Inc · adom.inc</span></div>+        <div class="popover-actions">+          <a class="popover-link" href="https://wiki-ufypy5dpx93o.adom.cloud/apps/adom-tts" target="_blank" rel="noopener">Open wiki ↗</a>+          <a class="popover-link" href="https://github.com/adom-inc/adom-tts" target="_blank" rel="noopener">GitHub ↗</a>+        </div>+      </div>+    </header>++    <script>+    // Interactive hover-popover. SHOW_DELAY = 500ms (human-ui-patterns §1b).+    (function attachHeaderPopovers(){+      const SHOW_DELAY = 500, HIDE_GRACE = 220;+      const wire = () => {+        document.querySelectorAll('header [data-tooltip-target]').forEach(trigger => {+          if (trigger.__popoverWired) return;+          trigger.__popoverWired = true;+          const target = document.getElementById(trigger.dataset.tooltipTarget);+          if (!target) return;+          let showTimer = null, hideTimer = null;+          const show = () => {+            clearTimeout(hideTimer);+            if (target.classList.contains('show')) return;+            clearTimeout(showTimer);+            showTimer = setTimeout(() => target.classList.add('show'), SHOW_DELAY);+          };+          const queueHide = () => {+            clearTimeout(showTimer);+            clearTimeout(hideTimer);+            hideTimer = setTimeout(() => target.classList.remove('show'), HIDE_GRACE);+          };+          trigger.addEventListener('mouseenter', show);+          trigger.addEventListener('mouseleave', queueHide);+          trigger.addEventListener('focus', show);+          trigger.addEventListener('blur', queueHide);+          target.addEventListener('mouseenter', show);+          target.addEventListener('mouseleave', queueHide);+          trigger.addEventListener('click', e => {+            clearTimeout(showTimer);+            if (target.classList.contains('show')) target.classList.remove('show');+            else target.classList.add('show');+            e.stopPropagation();+          });+        });+      };+      if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', wire);+      else wire();+    })();++    // Mirror clip count + pronunciation count into the canonical subject-meta.+    function updateAdomTtsMeta() {+      const meta = document.getElementById('topMeta');+      if (!meta) return;+      const clips = document.querySelectorAll('.clip').length;+      const prons = (window.pronEntries || []).length;+      const parts = [];+      if (prons) parts.push(`${prons} pronunciations`);+      if (clips) parts.push(`${clips} clip${clips === 1 ? '' : 's'}`);+      meta.innerHTML = parts.length+        ? '<span class="sep">·</span>' + parts.map(p => `<span>${p}</span>`).join('<span class="sep">·</span>')+        : '';+    }+    window.addEventListener('load', updateAdomTtsMeta);+    setInterval(updateAdomTtsMeta, 2000);+    </script>++    <main>+      <section class="compose">+        <div id="pron-hint" class="pron-hint"></div>+        <textarea id="text"+                  placeholder="Type what you want to hear — e.g. &quot;hi everyone, this is the adom hydrogen editor and its amazing that it can build so much stuff for me&quot;"></textarea>+        <div class="controls">+          <div class="field">+            <label for="voice">Voice</label>+            <select id="voice" data-tooltip="Which edge-tts voice synthesizes the clip.&#10;&#10;Default is en-US-AndrewNeural — the house voice for every Adom demo. Change only if you want variety."></select>+          </div>+          <div class="field">+            <label for="rate">Rate</label>+            <div class="rate-row">+              <input type="range" id="rate" min="-50" max="50" step="5" value="0"+                     data-tooltip="Speaking rate, percent offset from normal.&#10;&#10;0% is normal. Positive = faster, negative = slower. Adom demos usually stay within ±10%; bigger offsets can make the voice sound unnatural.">+              <span id="rate-value" class="rate-value">+0%</span>+            </div>+          </div>+          <button id="speak"+                  data-tooltip="Synthesize the text above using the selected voice and rate.&#10;&#10;The clip is rendered by service-tts (with pronunciation overrides for Adom terms), saved to ~/.adom/tts/history/, and appears in the list below.&#10;&#10;Shortcut: press Enter in the text box. Shift+Enter inserts a newline.">+            <span id="speak-label">Speak</span>+          </button>+        </div>+      </section>++      <section class="pron" id="pron">+        <div class="pron-head">+          <h2>Pronunciations</h2>+          <span class="count"><span id="pron-count">–</span></span>+          <div class="spacer"></div>+          <button class="ghost" id="pron-preview"+                  data-tooltip-pos="bottom"+                  data-tooltip="Synthesize &quot;written&quot; and &quot;narration&quot; back-to-back so you can hear whether the phonetic spelling actually helps.&#10;&#10;Both clips land in History — the raw one is tagged source=external, the applied one source=ui, so you can compare.">+            Preview+          </button>+        </div>+        <div class="pron-explain">+          The shared service-tts container rewrites text before synthesis using a global pronunciation table. Adding an entry here updates the gallia tts-pronunciation skill — every Adom tool that uses TTS benefits.+        </div>+        <div class="pron-add">+          <div>+            <label for="pron-written">Written</label>+            <input id="pron-written" placeholder="adom-tsci"+                   data-tooltip-pos="bottom"+                   data-tooltip="The term as it appears in code, UIs, captions, or chat.&#10;&#10;Matched case-insensitively by the service. Keep this the natural spelling — only the &quot;Narration&quot; field gets phoneticized.">+          </div>+          <div>+            <label for="pron-narration">Narration</label>+            <input id="pron-narration" placeholder="adom t s c i"+                   data-tooltip-pos="bottom"+                   data-tooltip="The phonetic spelling fed to the TTS engine.&#10;&#10;For acronyms, separate each letter with a space (letter-by-letter). For mispronounced words, spell them phonetically (e.g. &quot;KiCad&quot; → &quot;kai cad&quot;).">+          </div>+          <div class="full">+            <label for="pron-reason">Reason (optional)</label>+            <textarea id="pron-reason" placeholder="TTS reads 'tsci' as 'sci' — letter-by-letter forces the intended four-letter reading."+                      data-tooltip-pos="bottom"+                      data-tooltip="Why this entry exists — documented in the gallia skill file for future readers.&#10;&#10;Skip this and a default note is filled in (&quot;Added via TTS Playground&quot;)."></textarea>+          </div>+          <div class="pron-actions">+            <button class="ghost" id="pron-clear"+                    data-tooltip-pos="bottom"+                    data-tooltip="Empty the Written / Narration / Reason fields.&#10;&#10;Does NOT affect the table below or any entry that's already been added.">Clear</button>+            <button id="pron-propose"+                    data-tooltip-pos="bottom"+                    data-tooltip="Add this pronunciation to the gallia tts-pronunciation skill.&#10;&#10;Writes directly to gallia/skills/tts-pronunciation/pronunciations.json on your container — you still need to commit + push so the service-tts container picks it up on next redeploy (watchdog auto-pulls every 2 min).&#10;&#10;If gallia isn't checked out locally, the entry is queued at ~/.adom/tts/proposed-pronunciations.json and the JSON snippet is shown for manual paste.">Propose</button>+          </div>+        </div>+        <input type="search" id="pron-search" class="pron-search" placeholder="Search pronunciations…"+               data-tooltip-pos="bottom"+               data-tooltip="Filter the table below by either column.&#10;&#10;Match is case-insensitive and substring-based: &quot;pcb&quot; finds PCB, JLCPCB, and anything with that letter run.">+        <div class="pron-list" id="pron-list"></div>+      </section>++      <section class="history">+        <div class="history-head">+          <h2>History</h2>+          <span class="count"><span id="history-count">0</span> clips</span>+          <div class="spacer"></div>+          <button class="ghost" id="refresh"+                  data-tooltip="Re-scan ~/.adom/tts/history/ and redraw the list.&#10;&#10;The list auto-refreshes every 2 seconds anyway; use this if you just dropped a clip in from the CLI and don't want to wait.">+            Refresh+          </button>+        </div>+        <div class="history-list" id="history-list">+          <div class="empty">+            <h3>No clips yet</h3>+            <p>Type something above and hit Speak — or run <span class="mono">adom-tts say "hello" --out hi.mp3 --history</span> in a terminal.</p>+          </div>+        </div>+      </section>+    </main>+  </div>++  <div id="toast" class="toast" role="status" aria-live="polite"></div>+  <div id="global-tooltip"></div>++  <audio id="audio-player" preload="none"></audio>++  <script>+    // ───── Global tooltip (§1d — body-appended fixed div, cursor-anchored, 500ms delay) ─────+    (function () {+      const tip = document.getElementById("global-tooltip");+      let cursorX = 0, cursorY = 0;+      let currentTrigger = null;+      let revealTimer = null;+      const suppressedAncestors = [];+      const OFFSET = 14;+      const DELAY = 500;++      document.addEventListener("mousemove", (e) => {+        cursorX = e.clientX;+        cursorY = e.clientY;+        if (currentTrigger && tip.classList.contains("shown")) {+          place(currentTrigger);+        }+      }, true);++      function pickAnchor(triggerRect, tipW, tipH) {+        const vw = window.innerWidth, vh = window.innerHeight;+        const cands = [+          { x: cursorX + OFFSET,           y: cursorY + OFFSET },+          { x: cursorX - tipW - OFFSET,    y: cursorY + OFFSET },+          { x: cursorX + OFFSET,           y: cursorY - tipH - OFFSET },+          { x: cursorX - tipW - OFFSET,    y: cursorY - tipH - OFFSET },+        ];+        function overlapsTrigger(c) {+          return !(c.x + tipW < triggerRect.left || c.x > triggerRect.right+                || c.y + tipH < triggerRect.top  || c.y > triggerRect.bottom);+        }+        function offscreen(c) {+          return (c.x < 4 || c.y < 4 || c.x + tipW > vw - 4 || c.y + tipH > vh - 4);+        }+        function coversCursor(c) {+          // Tip must not sit under the cursor itself.+          return (cursorX >= c.x && cursorX <= c.x + tipW+               && cursorY >= c.y && cursorY <= c.y + tipH);+        }+        let best = cands[0], bestScore = Infinity;+        for (const c of cands) {+          let s = 0;+          if (offscreen(c)) s += 1000;+          if (overlapsTrigger(c)) s += 100;+          if (coversCursor(c)) s += 50;+          if (s < bestScore) { bestScore = s; best = c; }+        }+        // Clamp inside viewport as a last step.+        best.x = Math.max(4, Math.min(best.x, vw - tipW - 4));+        best.y = Math.max(4, Math.min(best.y, vh - tipH - 4));+        return best;+      }++      function place(trigger) {+        const r = trigger.getBoundingClientRect();+        const tipW = tip.offsetWidth, tipH = tip.offsetHeight;+        const { x, y } = pickAnchor(r, tipW, tipH);+        tip.style.setProperty("--tip-x", x + "px");+        tip.style.setProperty("--tip-y", y + "px");+      }++      function show(trigger) {+        tip.textContent = trigger.getAttribute("data-tooltip") || "";+        tip.classList.add("visible");+        place(trigger);+        // reveal after DELAY so mouse fly-bys don't fire anything+        clearTimeout(revealTimer);+        revealTimer = setTimeout(() => {+          if (currentTrigger === trigger) {+            place(trigger);+            requestAnimationFrame(() => tip.classList.add("shown"));+          }+        }, DELAY);+      }++      function hide() {+        clearTimeout(revealTimer);+        tip.classList.remove("shown");+        tip.classList.remove("visible");+        currentTrigger = null;+        while (suppressedAncestors.length) {+          suppressedAncestors.pop().removeAttribute("data-tooltip-suppress");+        }+      }++      document.addEventListener("mouseover", (e) => {+        const t = e.target.closest && e.target.closest("[data-tooltip]:not([data-tooltip-suppress])");+        if (!t) { if (currentTrigger) hide(); return; }+        if (t === currentTrigger) return;+        // §1f — innermost wins, suppress ancestor triggers while active.+        while (suppressedAncestors.length) {+          suppressedAncestors.pop().removeAttribute("data-tooltip-suppress");+        }+        let n = t.parentElement;+        while (n && n !== document.body) {+          if (n.hasAttribute && n.hasAttribute("data-tooltip")) {+            n.setAttribute("data-tooltip-suppress", "");+            suppressedAncestors.push(n);+          }+          n = n.parentElement;+        }+        currentTrigger = t;+        show(t);+      }, true);++      document.addEventListener("mouseout", (e) => {+        if (!currentTrigger) return;+        // hide when we leave the current trigger entirely (relatedTarget not inside it)+        const to = e.relatedTarget;+        if (!to || !currentTrigger.contains(to)) hide();+      }, true);++      // hide on scroll or visibility change (tooltip would dangle)+      window.addEventListener("scroll", hide, true);+      document.addEventListener("visibilitychange", hide);+    })();++    // ───── Console forwarding to server (/console) ─────+    (function () {+      const CAP = 200;+      const buf = [];+      let flushing = false;+      async function flush() {+        if (flushing || !buf.length) return;+        flushing = true;+        const batch = buf.splice(0, buf.length);+        for (const e of batch) {+          try {+            await fetch("console", {+              method: "POST",+              headers: { "Content-Type": "application/json" },+              body: JSON.stringify(e),+            });+          } catch (_) { /* drop — not fatal */ }+        }+        flushing = false;+      }+      ["log", "warn", "error", "info"].forEach((lvl) => {+        const orig = console[lvl].bind(console);+        console[lvl] = (...args) => {+          orig(...args);+          const msg = args.map((a) => {+            try { return typeof a === "string" ? a : JSON.stringify(a); }+            catch (_) { return String(a); }+          }).join(" ");+          if (buf.length >= CAP) buf.shift();+          buf.push({ level: lvl, msg, t: new Date().toISOString() });+          setTimeout(flush, 0);+        };+      });+      window.addEventListener("error", (e) => console.error("window.onerror:", e.message, e.filename + ":" + e.lineno));+      window.addEventListener("unhandledrejection", (e) => console.error("unhandledrejection:", e.reason));+    })();++    // ───── State & DOM ─────+    const $ = (id) => document.getElementById(id);+    const textEl = $("text");+    const voiceEl = $("voice");+    const rateEl = $("rate");+    const rateValEl = $("rate-value");+    const speakBtn = $("speak");+    const speakLabel = $("speak-label");+    const listEl = $("history-list");+    const countEl = $("history-count");+    const pillEl = $("service-pill");+    const pillTextEl = $("service-pill-text");+    const toastEl = $("toast");+    const audio = $("audio-player");++    let playingId = null;+    audio.addEventListener("ended", () => {+      playingId = null;+      render();+    });++    function toast(msg, kind = "") {+      toastEl.textContent = msg;+      toastEl.className = "toast show" + (kind ? " " + kind : "");+      clearTimeout(toast._t);+      toast._t = setTimeout(() => { toastEl.classList.remove("show"); }, 3500);+    }++    function fmtTime(iso) {+      try {+        const d = new Date(iso);+        const now = new Date();+        const diff = (now - d) / 1000;+        if (diff < 60) return Math.round(diff) + "s ago";+        if (diff < 3600) return Math.round(diff / 60) + "m ago";+        if (diff < 86400) return Math.round(diff / 3600) + "h ago";+        return d.toLocaleString();+      } catch (_) { return iso; }+    }++    function fmtBytes(n) {+      if (n < 1024) return n + " B";+      if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " KB";+      return (n / 1024 / 1024).toFixed(2) + " MB";+    }++    rateEl.addEventListener("input", () => {+      const v = parseInt(rateEl.value, 10);+      rateValEl.textContent = (v >= 0 ? "+" : "") + v + "%";+    });++    // Enter → speak (like Claude's chat box).+    // Shift+Enter → newline. Alt+Enter also inserts a newline for muscle-memory.+    textEl.addEventListener("keydown", (e) => {+      if (e.key !== "Enter" || e.isComposing) return;+      if (e.shiftKey || e.altKey) return;  // let the newline through+      e.preventDefault();+      speak();+    });++    // ───── Load voices ─────+    async function loadVoices() {+      try {+        const r = await fetch("voices");+        const j = await r.json();+        // service-tts returns {short_name, gender, locale?}. Fall back to+        // edge-tts raw keys (ShortName, Gender, Locale) if a different+        // backend ever gets swapped in.+        const all = j.voices || j || [];+        const norm = (v) => ({+          short_name: v.short_name || v.ShortName || "",+          gender: v.gender || v.Gender || "",+          locale: v.locale || v.Locale || (v.short_name || v.ShortName || "").slice(0, 5),+        });+        const voices = all.map(norm).filter((v) => v.locale.startsWith("en-"));+        voices.sort((a, b) => a.short_name.localeCompare(b.short_name));+        voiceEl.innerHTML = "";+        for (const v of voices) {+          const opt = document.createElement("option");+          opt.value = v.short_name;+          const name = v.short_name.replace(/^en-[A-Z]{2}-/, "").replace(/Neural$/, "");+          const gender = v.gender ? " · " + v.gender : "";+          opt.textContent = `${name} (${v.short_name})${gender}`;+          if (v.short_name === "en-US-AndrewNeural") opt.selected = true;+          voiceEl.appendChild(opt);+        }+        if (!voiceEl.value) voiceEl.value = "en-US-AndrewNeural";+        console.log("voices loaded:", voices.length);+      } catch (e) {+        console.error("voice load failed:", e);+        // Fall back to Andrew as the only option.+        voiceEl.innerHTML = '<option value="en-US-AndrewNeural" selected>Andrew (en-US-AndrewNeural)</option>';+      }+    }++    // ───── /state poller ─────+    async function pollState() {+      try {+        const r = await fetch("state");+        const j = await r.json();+        pillEl.className = "pill ok";+        pillTextEl.textContent = "service-tts ok";+      } catch (_) {+        pillEl.className = "pill err";+        pillTextEl.textContent = "service-tts unreachable";+      }+    }++    // ───── /history list ─────+    let lastListFingerprint = "";+    let clips = [];++    async function refreshHistory() {+      try {+        const r = await fetch("history");+        const j = await r.json();+        if (!j.ok) return;+        const fp = (j.clips || []).map((c) => c.id + "/" + c.size_bytes).join("|");+        if (fp === lastListFingerprint) return;+        lastListFingerprint = fp;+        clips = j.clips || [];+        render();+      } catch (e) { /* transient */ }+    }++    function render() {+      countEl.textContent = clips.length;+      if (!clips.length) {+        listEl.innerHTML = `+          <div class="empty">+            <h3>No clips yet</h3>+            <p>Type something above and hit Speak — or run <span class="mono">adom-tts say "hello" --out hi.mp3 --history</span> in a terminal.</p>+          </div>`;+        return;+      }+      listEl.innerHTML = "";+      for (const c of clips) {+        const row = document.createElement("div");+        row.className = "clip" + (c.id === playingId ? " playing" : "");+        const playing = c.id === playingId;+        row.innerHTML = `+          <button class="play" data-play="${c.id}"+                  data-tooltip="${playing ? "Pause playback of this clip." : "Play this clip in the browser.\nAudio is streamed from ~/.adom/tts/history/" + c.id + ".mp3."}">+            <svg viewBox="0 0 24 24">+              ${playing+                ? '<rect x="6" y="5" width="4" height="14" rx="1"/><rect x="14" y="5" width="4" height="14" rx="1"/>'+                : '<path d="M8 5v14l11-7z"/>'}+            </svg>+          </button>+          <div class="meta">+            <div class="text">${escapeHtml(c.text)}</div>+            <div class="sub">+              <span class="badge">${escapeHtml(shortVoice(c.voice))}</span>+              <span class="badge">${escapeHtml(c.rate)}</span>+              <span class="badge source-${escapeHtml(c.source)}">${escapeHtml(c.source)}</span>+              <span>${fmtTime(c.created_at)}</span>+              <span>·</span>+              <span>${fmtBytes(c.size_bytes)}</span>+            </div>+          </div>+          <div class="actions">+            <a class="ghost" style="text-decoration:none"+               href="history/${encodeURIComponent(c.id)}.mp3"+               download="${encodeURIComponent(c.id)}.mp3"+               data-tooltip="Save the .mp3 to your computer's Downloads folder.&#10;&#10;The file is also on disk at ~/.adom/tts/history/${c.id}.mp3 — that's the same path video-post and other tools read from.">+              <button class="icon" data-tooltip-pos="bottom" data-tooltip="Download the .mp3 to your computer.&#10;&#10;File is also on disk at ~/.adom/tts/history/${c.id}.mp3 — that's where other Adom tools pick it up from.">+                <svg viewBox="0 0 24 24"><path d="M12 3v10.6l3.3-3.3 1.4 1.4L12 16.4 7.3 11.7l1.4-1.4L12 13.6V3h0zM5 19h14v2H5z"/></svg>+              </button>+            </a>+            <button class="icon" data-copy="${c.id}"+                    data-tooltip="Copy the playground URL for this clip to your clipboard.&#10;&#10;Paste into a chat or browser tab to play it directly — the URL serves the audio with audio/mpeg content-type, so any player can stream it.">+              <svg viewBox="0 0 24 24"><path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/></svg>+            </button>+            <button class="icon danger" data-delete="${c.id}"+                    data-tooltip="Delete this clip permanently.&#10;&#10;Removes both the .mp3 and the .json sidecar from ~/.adom/tts/history/. No undo. Any tool that had already read the file keeps its copy — only new reads fail.">+              <svg viewBox="0 0 24 24"><path d="M9 3v1H4v2h1l1 14c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2l1-14h1V4h-5V3H9zm2 5h2v10h-2V8zm-4 0h2v10H7V8zm8 0h2v10h-2V8z"/></svg>+            </button>+          </div>+        `;+        listEl.appendChild(row);+      }++      listEl.querySelectorAll("[data-play]").forEach((b) => {+        b.addEventListener("click", () => play(b.dataset.play));+      });+      listEl.querySelectorAll("[data-copy]").forEach((b) => {+        b.addEventListener("click", () => copyUrl(b.dataset.copy));+      });+      listEl.querySelectorAll("[data-delete]").forEach((b) => {+        b.addEventListener("click", () => delClip(b.dataset.delete));+      });+    }++    function shortVoice(v) {+      return (v || "").replace(/^en-[A-Z]{2}-/, "").replace(/Neural$/, "") || v;+    }++    function escapeHtml(s) {+      return String(s || "")+        .replace(/&/g, "&amp;")+        .replace(/</g, "&lt;")+        .replace(/>/g, "&gt;")+        .replace(/"/g, "&quot;");+    }++    function play(id) {+      if (playingId === id) {+        audio.pause();+        playingId = null;+        render();+        return;+      }+      audio.src = "history/" + encodeURIComponent(id) + ".mp3";+      audio.play().catch((e) => {+        console.error("play failed:", e);+        toast("couldn't play clip: " + e.message, "err");+      });+      playingId = id;+      render();+    }++    async function copyUrl(id) {+      const url = new URL("history/" + encodeURIComponent(id) + ".mp3", location.href).toString();+      try {+        await navigator.clipboard.writeText(url);+        toast("copied " + url, "ok");+      } catch (e) {+        toast("copy failed — " + url, "err");+      }+    }++    async function delClip(id) {+      if (!confirm("Delete this clip?")) return;+      try {+        const r = await fetch("history/" + encodeURIComponent(id), { method: "DELETE" });+        const j = await r.json();+        if (!j.ok) throw new Error(j.error || "delete failed");+        if (playingId === id) { audio.pause(); playingId = null; }+        toast("clip deleted", "ok");+        await refreshHistory();+      } catch (e) {+        toast("delete failed: " + e.message, "err");+      }+    }++    // ───── Speak ─────+    async function speak() {+      const text = textEl.value.trim();+      if (!text) { toast("type something first", "err"); textEl.focus(); return; }+      speakBtn.disabled = true;+      speakLabel.textContent = "Synthesizing…";+      try {+        const r = await fetch("synth", {+          method: "POST",+          headers: { "Content-Type": "application/json" },+          body: JSON.stringify({+            text,+            voice: voiceEl.value || "en-US-AndrewNeural",+            rate: (rateEl.value >= 0 ? "+" : "") + rateEl.value + "%",+          }),+        });+        const j = await r.json();+        if (!r.ok || !j.ok) throw new Error(j.error || ("HTTP " + r.status));+        toast("clip saved", "ok");+        await refreshHistory();+        if (j.clip && j.clip.id) play(j.clip.id);+      } catch (e) {+        console.error("synth failed:", e);+        toast("synth failed: " + e.message, "err");+      } finally {+        speakBtn.disabled = false;+        speakLabel.textContent = "Speak";+      }+    }++    speakBtn.addEventListener("click", speak);+    $("refresh").addEventListener("click", refreshHistory);++    // ───── Pronunciations ─────+    let pronEntries = [];+    const pronHint = $("pron-hint");+    const pronListEl = $("pron-list");+    const pronCountEl = $("pron-count");+    const pronSearchEl = $("pron-search");+    const pronWrittenEl = $("pron-written");+    const pronNarrationEl = $("pron-narration");+    const pronReasonEl = $("pron-reason");++    async function loadPronunciations() {+      try {+        const r = await fetch("pronunciations");+        const j = await r.json();+        pronEntries = j.overrides || j.entries || [];+        pronCountEl.textContent = pronEntries.length;+        renderPronList();+        updatePronHint();+      } catch (e) { console.error("pronunciations load failed:", e); }+    }++    function renderPronList() {+      const q = (pronSearchEl.value || "").toLowerCase();+      const shown = pronEntries.filter((e) =>+        !q || (e.from || e.written || "").toLowerCase().includes(q)+           || (e.to || e.narration || "").toLowerCase().includes(q));+      pronListEl.innerHTML = "";+      if (!shown.length) {+        pronListEl.innerHTML = '<div style="color:var(--text-dim);font-size:12px;padding:8px;text-align:center;">no matches</div>';+        return;+      }+      for (const e of shown) {+        const from = e.from || e.written || "";+        const to = e.to || e.narration || "";+        const row = document.createElement("div");+        row.className = "pron-row";+        row.innerHTML = `+          <span class="from">${escapeHtml(from)}</span>+          <span class="arrow">→</span>+          <span class="to">${escapeHtml(to)}</span>+        `;+        row.addEventListener("click", () => {+          pronWrittenEl.value = from;+          pronNarrationEl.value = to;+          pronWrittenEl.focus();+        });+        row.style.cursor = "pointer";+        row.setAttribute("data-tooltip", `Click to copy this entry into the add form, e.g. for editing.\nCurrent: "${from}" → "${to}"`);+        row.setAttribute("data-tooltip-pos", "bottom");+        pronListEl.appendChild(row);+      }+    }++    pronSearchEl.addEventListener("input", renderPronList);++    function updatePronHint() {+      const text = textEl.value;+      if (!text || !pronEntries.length) { pronHint.className = "pron-hint"; return; }+      const lc = text.toLowerCase();+      const hits = pronEntries.filter((e) => {+        const from = (e.from || e.written || "").toLowerCase();+        return from && lc.includes(from);+      }).slice(0, 3);+      if (!hits.length) { pronHint.className = "pron-hint"; return; }+      pronHint.className = "pron-hint show";+      pronHint.innerHTML = hits.map((e) => {+        const from = e.from || e.written || "";+        const to = e.to || e.narration || "";+        return `<strong>${escapeHtml(from)}</strong> will be voiced as <span class="mono">${escapeHtml(to)}</span>`;+      }).join(" · ") + ' <span class="mono" style="opacity:0.7">· global override</span>';+    }++    textEl.addEventListener("input", updatePronHint);++    // Preview: synth the "written" raw and the "narration" raw back-to-back.+    async function pronPreview() {+      const written = pronWrittenEl.value.trim();+      const narration = pronNarrationEl.value.trim();+      if (!written || !narration) {+        toast("fill Written and Narration first", "err");+        return;+      }+      const btn = $("pron-preview");+      btn.disabled = true; btn.textContent = "Rendering…";+      try {+        // Raw: no pronunciation override, plain written form — hear what the TTS does.+        const r1 = await fetch("synth", {+          method: "POST",+          headers: { "Content-Type": "application/json" },+          body: JSON.stringify({+            text: written,+            voice: voiceEl.value || "en-US-AndrewNeural",+            rate: "+0%",+            apply_pronunciations: false,+          }),+        });+        const j1 = await r1.json();+        if (!j1.ok) throw new Error(j1.error || "raw synth failed");++        // Applied: feed the narration form directly (no-op pronunciation apply).+        const r2 = await fetch("synth", {+          method: "POST",+          headers: { "Content-Type": "application/json" },+          body: JSON.stringify({+            text: narration,+            voice: voiceEl.value || "en-US-AndrewNeural",+            rate: "+0%",+            apply_pronunciations: false,+          }),+        });+        const j2 = await r2.json();+        if (!j2.ok) throw new Error(j2.error || "narration synth failed");++        toast(`preview: "${written}" then "${narration}"`, "ok");+        await refreshHistory();+        play(j1.clip.id);+        setTimeout(() => {+          if (!audio.paused) {+            audio.addEventListener("ended", function onend() {+              audio.removeEventListener("ended", onend);+              play(j2.clip.id);+            }, { once: true });+          } else {+            play(j2.clip.id);+          }+        }, 100);+      } catch (e) {+        toast("preview failed: " + e.message, "err");+      } finally {+        btn.disabled = false; btn.textContent = "Preview";+      }+    }++    async function pronPropose() {+      const written = pronWrittenEl.value.trim();+      const narration = pronNarrationEl.value.trim();+      const reason = pronReasonEl.value.trim();+      if (!written || !narration) {+        toast("fill Written and Narration first", "err");+        return;+      }+      const btn = $("pron-propose");+      btn.disabled = true;+      try {+        const r = await fetch("pronunciation/propose", {+          method: "POST",+          headers: { "Content-Type": "application/json" },+          body: JSON.stringify({ written, narration, reason }),+        });+        const j = await r.json();+        if (!j.ok) throw new Error(j.error || "propose failed");+        const target = j.target === "gallia"+          ? `added to gallia skill (${j.total_entries} entries)`+          : `queued locally (${j.total_entries} pending)`;+        toast(target, "ok");+        pronWrittenEl.value = "";+        pronNarrationEl.value = "";+        pronReasonEl.value = "";+        await loadPronunciations();+      } catch (e) {+        toast("propose failed: " + e.message, "err");+      } finally {+        btn.disabled = false;+      }+    }++    $("pron-preview").addEventListener("click", pronPreview);+    $("pron-propose").addEventListener("click", pronPropose);+    $("pron-clear").addEventListener("click", () => {+      pronWrittenEl.value = "";+      pronNarrationEl.value = "";+      pronReasonEl.value = "";+      pronWrittenEl.focus();+    });++    // ───── Boot ─────+    loadVoices();+    loadPronunciations();+    pollState();+    refreshHistory();+    setInterval(refreshHistory, 2000);+    setInterval(pollState, 10000);++    // Support direct links like ?scroll=pron — the outer <main> is the+    // scroll container in narrow layout so native anchor scrolling doesn't+    // always reach the target; scroll explicitly.+    (function () {+      const params = new URLSearchParams(location.search);+      const target = params.get("scroll") || location.hash.replace("#", "");+      if (!target) return;+      requestAnimationFrame(() => {+        const el = document.getElementById(target);+        if (el) el.scrollIntoView({ behavior: "auto", block: "start" });+      });+    })();+  </script>+</body>+</html>