From 4d93ef5a0e9bb073a483616e135a3161f5ac2598 Mon Sep 17 00:00:00 2001 From: jenkins Date: Mon, 24 Aug 2026 19:22:20 -0300 Subject: [PATCH] hermes(voice): workspace nav home, character orb, conversation rename, voice-lang fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final conversation-mode polish from mobile testing: - The Workspace toggle now sits with the chat/Telegram nav at every width: nav.rail on desktop, the top app titlebar on mobile. The floating pill that pushed the mobile composer's control row (and the conversation-mode button) off screen is gone - a fallback exists only for headless DOMs and is pinned to a top corner, never over the composer. - The conversation orb watermark is now the Hermes character avatar (static/hermes-agent-192.png) instead of the caduceus staff. - User-facing 'hands-free' copy renamed to 'Conversation mode'. - Wrong-voice fix: strongReplyLanguage flagged Spanish on a single accented char, so an English reply naming European cities (Zürich, Málaga) overrode the correct English STT detection and was spoken by the Spanish voice. Detection now requires density (Cyrillic >=4 at >=50%, or inverted punctuation / >=2 accents corroborated by Spanish stopwords); plain English always speaks English, forced language wins, accent-free Spanish still routes via trusted STT. 294 voice tests. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf --- dockerfiles/hermes-webui-atlas-voice.css | 25 +++--- dockerfiles/hermes-webui-atlas-voice.js | 89 ++++++++++++++----- dockerfiles/hermes-webui-hux/bootstrap.css | 53 ++++++++--- dockerfiles/hermes-webui-hux/bootstrap.js | 77 +++++++++++----- .../tests/data/atlas_voice_language_probe.js | 30 +++++++ .../test_hermes_voice_capture_continuity.py | 32 ++++--- .../test_hermes_voice_language_routing.py | 32 +++++++ .../test_hermes_webui_hux_integration_node.js | 42 ++++++++- 8 files changed, 300 insertions(+), 80 deletions(-) diff --git a/dockerfiles/hermes-webui-atlas-voice.css b/dockerfiles/hermes-webui-atlas-voice.css index 98d4aff7..d3e10d8e 100644 --- a/dockerfiles/hermes-webui-atlas-voice.css +++ b/dockerfiles/hermes-webui-atlas-voice.css @@ -433,24 +433,23 @@ wave animations always read over it. Monochrome via currentColor at low opacity so it stays legible against every state tint without competing with the accent colour. Scales with the orb because it is inset-positioned. */ +/* FIX 2: the Hermes CHARACTER avatar (static/hermes-agent-192.png — the same + mark the top app bar shows next to the conversation title), painted as a + centred, low-opacity watermark that scales with the orb. It is a raster mark + already served under /static, so it is referenced by URL rather than inlined. + It carries no animation of its own (reduced-motion safe) and stays subtle + enough to read across every state tint without masking the energy layers. */ .voice-conversation-orb-mark { position: absolute; - inset: 24%; - display: flex; - align-items: center; - justify-content: center; + inset: 22%; pointer-events: none; - color: rgba(233, 244, 255, 0.9); + background-image: url(hermes-agent-192.png); + background-position: center; + background-repeat: no-repeat; + background-size: contain; opacity: 0.16; - mix-blend-mode: screen; - transition: opacity 200ms ease-out; -} - -.voice-conversation-orb-mark svg { - width: 100%; - height: 100%; - display: block; filter: drop-shadow(0 0 6px rgba(var(--voice-accent), 0.3)); + transition: opacity 200ms ease-out; } /* Speaking/thinking lift the watermark a touch so it feels alive with the turn, diff --git a/dockerfiles/hermes-webui-atlas-voice.js b/dockerfiles/hermes-webui-atlas-voice.js index 27933e58..470d437f 100644 --- a/dockerfiles/hermes-webui-atlas-voice.js +++ b/dockerfiles/hermes-webui-atlas-voice.js @@ -67,12 +67,11 @@ }catch(_){return '';} })(); const reducedMotion=window.matchMedia?window.matchMedia('(prefers-reduced-motion: reduce)'):{matches:false}; - // FIX 2: the app's own Hermes caduceus mark (static/favicon.svg), embedded - // as a centred watermark inside the conversation orb. Rendered monochrome via - // currentColor at low opacity so it reads across every state tint - // (idle/listening/transcribing/thinking/speaking) without ever obscuring the - // energy animation, and it carries no animation of its own (reduced-motion safe). - const HERMES_MARK_SVG=''; + // FIX 2: the conversation orb's centred watermark is the Hermes CHARACTER + // avatar (static/hermes-agent-192.png) — the very mark the top app bar shows + // next to the conversation title — painted by .voice-conversation-orb-mark in + // atlas-voice.css as a low-opacity, non-animating (reduced-motion safe) layer + // that scales with the orb and reads across every state tint. const ERROR_VISIBLE_MS=3200; const STREAMING_CAPABILITY_URL='/api/voice/streaming/capability'; const TTS_STREAM_URL='/api/tts/stream'; @@ -188,26 +187,47 @@ return language; } - const SPANISH_ORTHOGRAPHY=/[áéíóúñü¡¿]/i; + // Inverted punctuation is exclusive to Spanish and decisive on its own. + const SPANISH_UNIQUE=/[¡¿]/; + // Accented vowels and ñ also occur in English loanwords and European place + // names (Zürich, Málaga, Genève, jalapeño), so they signal Spanish only + // alongside real Spanish stopword density — never on their own. + const SPANISH_ACCENTS=/[áéíóúüñ]/gi; const SPANISH_STOPWORDS=/\b(?:el|la|los|las|un|una|es|está|qué|para|por|con|pero|como|más|sí|gracias|hola|puedo|también|muy|este|esta|todo|bien)\b/g; + const CYRILLIC_CHARS=/[Ѐ-ӿ]/g; + const WORD_LETTERS=/[A-Za-zÀ-ÿЀ-ӿ]/g; function strongReplyLanguage(text){ - // Script-level certainty only: Cyrillic text is Russian; Spanish - // orthography (accents, ñ, inverted punctuation) is Spanish. Plain-ASCII - // text yields no signal, so an English reply never flips a trusted - // STT-detected voice. + // Decisive script/orthography evidence in the REPLY text only. This is + // ranked ABOVE the trusted STT detection, so it must never fire on + // incidental foreign glyphs: a lone accented European place name, or a + // single stray Cyrillic letter inside otherwise-English prose, keeps the + // English (Amy) voice. Russian needs Cyrillic-letter density; Spanish needs + // inverted punctuation, or accent/ñ density corroborated by Spanish + // stopword density. (Root cause of the "English reply about Europe spoken in + // a foreign voice" bug: the old rule returned 'es' for a single accented + // char and 'ru' for a single Cyrillic char, overriding a correct 'en' STT.) const sample=String(text||'').slice(0,400); - if(/[Ѐ-ӿ]/.test(sample)) return 'ru'; - if(SPANISH_ORTHOGRAPHY.test(sample)) return 'es'; + const letters=(sample.match(WORD_LETTERS)||[]).length; + const cyrillic=(sample.match(CYRILLIC_CHARS)||[]).length; + if(cyrillic>=4&&letters>0&&cyrillic/letters>=0.5) return 'ru'; + if(SPANISH_UNIQUE.test(sample)) return 'es'; + const accents=(sample.match(SPANISH_ACCENTS)||[]).length; + if(accents>=2){ + const lower=sample.toLowerCase(); + const words=lower.split(/\s+/).filter(Boolean); + const stops=(lower.match(SPANISH_STOPWORDS)||[]).length; + if(words.length>=4&&stops>=2&&stops/words.length>=0.12) return 'es'; + } return ''; } function detectReplyLanguage(text){ // Lightweight reply-language heuristic for turns without a trusted STT - // detection: script evidence first, then Spanish stopword density (an - // accent-free Spanish sentence still routes to the Spanish voice). - // Returns '' for English/unknown, which the private TTS service resolves - // to its own English default voice. + // detection: decisive script/orthography evidence first, then accent-free + // Spanish stopword density (an accent-free Spanish sentence still routes to + // the Spanish voice). Returns '' for English/unknown, which the private TTS + // service resolves to its own English default voice. const strong=strongReplyLanguage(text); if(strong) return strong; const sample=String(text||'').slice(0,400).toLowerCase(); @@ -219,6 +239,23 @@ return ''; } + function resolveReplyLanguage(text,sttLanguage,forced){ + // The single source of truth for which Piper voice reads a reply. A user + // force wins outright; otherwise decisive reply-text evidence corrects a + // wrong or missing STT detection; otherwise the turn's trusted STT-detected + // language is spoken; otherwise the accent-free stopword heuristic. Plain + // English prose — including a reply full of European proper nouns — carries + // no decisive evidence and matches no STT hint of its own, so it can never be + // flipped off the English voice by a previous turn's sticky value: only a + // real force or real reply-text evidence moves it. + if(forced) return forced; + const strong=strongReplyLanguage(text); + if(strong) return strong; + const stt=normalizeSttLanguage(sttLanguage); + if(stt) return stt; + return detectReplyLanguage(text)||''; + } + function toast(message){ if(typeof window.showToast==='function') window.showToast(message,3000); } @@ -594,9 +631,10 @@ orb.appendChild(conversationNode('span','voice-conversation-orb-halo')); orb.appendChild(conversationNode('span','voice-conversation-orb-core')); orb.appendChild(conversationNode('span','voice-conversation-orb-ring')); - // FIX 2: Hermes mark watermark, centred in the orb beneath the energy layers. + // FIX 2: the Hermes character-avatar watermark, centred in the orb beneath + // the energy layers. It is a presentation-only span; atlas-voice.css paints + // static/hermes-agent-192.png (the top-app-bar character mark) into it. const orbMark=conversationNode('span','voice-conversation-orb-mark',{'aria-hidden':'true'}); - orbMark.innerHTML=HERMES_MARK_SVG; orb.appendChild(orbMark); const stateEl=conversationNode('div','voice-conversation-state',{'aria-hidden':'true'}); const captions=conversationNode('div','voice-conversation-captions',{'aria-live':'polite'}); @@ -966,7 +1004,7 @@ outputDevices=[]; modeBtn.classList.remove('active'); setState('idle'); - if(showMessage) toast('Hands-free voice mode off'); + if(showMessage) toast('Conversation mode off'); } function restartSoon(token,delay){ @@ -2778,7 +2816,7 @@ // the next streaming STT turn recognizes the switch promptly. // A user-forced language (FIX 3) wins over every auto signal: the reply is // spoken by the chosen voice regardless of script evidence or detection. - const resolved=forcedLanguage||strongReplyLanguage(text)||turn.sttLanguage||detectReplyLanguage(text); + const resolved=resolveReplyLanguage(text,turn.sttLanguage,forcedLanguage); if(resolved) turn.language=resolved; if(!forcedLanguage&&!turn.sttLanguage&&resolved) sessionLanguage=resolved; // One audio timeline, one voice: a new logical message after an interim @@ -2887,7 +2925,7 @@ outputDevices=[]; finalizeAttempts=0; modeBtn.classList.add('active'); - toast('Hands-free private voice mode on'); + toast('Conversation mode on'); openConversationOverlay(); if(typeof window.stopTTS==='function') window.stopTTS(); if(typeof S!=='undefined'&&S.busy){setState('thinking');return;} @@ -2971,6 +3009,13 @@ adaptiveChunks:adaptiveChunks, sentenceEnd:sentenceEnd, cleanForSpeech:cleanForSpeech, + // Pure, behaviour-neutral reply-language routing helpers so a probe can + // regression-lock FIX 4: plain English (European proper nouns included) + // never leaves the English voice, Cyrillic → ru, clear Spanish → es, and a + // user force always wins. + strongReplyLanguage:strongReplyLanguage, + detectReplyLanguage:detectReplyLanguage, + resolveReplyLanguage:resolveReplyLanguage, // Read-only, behaviour-neutral: lets a deterministic probe observe that the // interim fold never leaves an unspoken tail outstanding while the state // has fallen back to Thinking (the dropped-tail regression). diff --git a/dockerfiles/hermes-webui-hux/bootstrap.css b/dockerfiles/hermes-webui-hux/bootstrap.css index 0c247cf4..f2cb0454 100644 --- a/dockerfiles/hermes-webui-hux/bootstrap.css +++ b/dockerfiles/hermes-webui-hux/bootstrap.css @@ -1,8 +1,12 @@ +/* Floating fallback: only rendered when NO nav home exists (headless/degenerate + DOMs). Pinned to the TOP-right corner — never the bottom — so it can never + overlap, push, or reflow the message composer's bottom control row. In the + real app both nav homes exist, so this never appears. */ .hux-workspace-toggle { background: color-mix(in srgb, var(--bg-primary, #0a101b) 88%, #174653); border: 1px solid rgb(94 231 215 / 45%); border-radius: 999px; - bottom: max(5.8rem, calc(env(safe-area-inset-bottom) + 4.5rem)); + top: max(0.6rem, env(safe-area-inset-top)); box-shadow: 0 10px 32px rgb(0 0 0 / 32%); color: var(--text-primary, #e7eef6); cursor: pointer; @@ -13,6 +17,43 @@ z-index: 115; } +/* Mobile top-app-bar Workspace toggle: an icon-only button beside the app's own + new-chat/reload icons. Shown only below the rail breakpoint (where the rail — + and its own toggle — is hidden), so exactly one Workspace entry is ever + visible and nothing floats over the composer. */ +.hux-workspace-titlebar-toggle { + align-items: center; + background: none; + border: none; + border-radius: 8px; + color: var(--muted, #9fb0c0); + cursor: pointer; + display: none; + flex-shrink: 0; + height: 44px; + justify-content: center; + padding: 0; + width: 44px; + -webkit-app-region: no-drag; + -webkit-tap-highlight-color: transparent; +} + +.hux-workspace-titlebar-toggle:hover { + background: var(--hover-bg, rgb(94 231 215 / 12%)); + color: var(--text, #e7eef6); +} + +.hux-workspace-titlebar-toggle[aria-expanded="true"] { + background: var(--accent-bg, rgb(94 231 215 / 18%)); + color: var(--accent-text, var(--text, #e7eef6)); +} + +@media (max-width: 640px) { + .hux-workspace-titlebar-toggle { + display: inline-flex; + } +} + .hux-workspace-drawer { background: color-mix(in srgb, var(--bg-primary, #0a101b) 97%, #102f3a); border-left: 1px solid rgb(118 181 193 / 30%); @@ -72,6 +113,7 @@ .hux-workspace-toggle:focus-visible, .hux-workspace-toggle-rail:focus-visible, +.hux-workspace-titlebar-toggle:focus-visible, .hux-workspace-drawer button:focus-visible { outline: 2px solid #5ee7d7; outline-offset: 2px; @@ -84,15 +126,6 @@ color: var(--accent-text, var(--text, #e7eef6)); } -/* Where the rail is visible (upstream shows it from 641px up) the floating - fallback toggle disappears; below that the rail itself is hidden and the - floating button remains the only entry point, so no width loses access. */ -@media (min-width: 641px) { - .hux-workspace-toggle--railed { - display: none; - } -} - @media (max-width: 640px) { .hux-workspace-drawer { width: 100%; diff --git a/dockerfiles/hermes-webui-hux/bootstrap.js b/dockerfiles/hermes-webui-hux/bootstrap.js index 862a72b2..1563c300 100644 --- a/dockerfiles/hermes-webui-hux/bootstrap.js +++ b/dockerfiles/hermes-webui-hux/bootstrap.js @@ -233,13 +233,24 @@ } function railHost(doc) { - // The app's left icon rail (chat, tasks, telegram, …). Optional: bundles - // without it (and headless harnesses) keep the floating toggle. + // The app's left icon rail (chat, tasks, telegram, …), the desktop nav home + // (shown from ~641px up). Optional: bundles without it fall through to the + // top app bar, then to the floating toggle. if (typeof doc.querySelector !== 'function') return null; const rail = doc.querySelector('nav.rail'); return rail && typeof rail.insertBefore === 'function' ? rail : null; } + function titlebarHost(doc) { + // The top app bar (hamburger · avatar · title · new-chat · reload) — the + // mobile nav home, always visible below the rail breakpoint where the rail + // collapses. Mounting the Workspace toggle here keeps it beside the app's + // own icons instead of floating over the composer. + if (typeof doc.querySelector !== 'function') return null; + const bar = doc.querySelector('header.app-titlebar'); + return bar && typeof bar.insertBefore === 'function' ? bar : null; + } + function railIcon(doc) { try { if (typeof doc.createElementNS !== 'function') return null; @@ -258,27 +269,38 @@ } } + function navIconToggle(doc, cls) { + // An icon-only Workspace entry that sits WITH the app's own nav items and is + // styled by their rules. Shared by the desktop rail and the mobile top app + // bar so both look native beside their siblings. + const toggle = node(doc, 'button', {'class': cls, type: 'button', + 'aria-controls': 'huxWorkspaceDrawer', 'aria-expanded': 'false', + 'data-tooltip': 'Workspace', 'aria-label': 'Workspace'}); + const icon = railIcon(doc); + if (icon) toggle.appendChild(icon); + else toggle.textContent = '⧉'; + return toggle; + } + function createChrome(doc) { + // The Workspace toggle mounts WITH the other nav items at every width and + // never floats over the composer: the desktop icon rail (nav.rail) hands off + // to the mobile top app bar (header.app-titlebar), each showing exactly one + // toggle via bootstrap.css. A floating button appears ONLY when neither nav + // home exists (headless DOMs), pinned to a TOP corner clear of the composer. const rail = railHost(doc); - const open = node(doc, 'button', {'class': rail ? - 'hux-workspace-toggle hux-workspace-toggle--railed' : 'hux-workspace-toggle', - type: 'button', 'aria-controls': 'huxWorkspaceDrawer', 'aria-expanded': 'false'}, 'Workspace'); + const titlebar = titlebarHost(doc); let railToggle = null; if (rail) { - // Rail placement: an icon-only item beside the app's own tabs, styled - // by the upstream .rail-btn/.has-tooltip rules. The floating button - // stays in the DOM as the below-rail-breakpoint fallback and is hidden - // by bootstrap.css wherever the rail is visible, so nothing floats - // over the chat. Drawer behavior is unchanged. - railToggle = node(doc, 'button', {'class': 'rail-btn has-tooltip hux-workspace-toggle-rail', - type: 'button', 'aria-controls': 'huxWorkspaceDrawer', 'aria-expanded': 'false', - 'data-tooltip': 'Workspace', 'aria-label': 'Workspace'}); - const icon = railIcon(doc); - if (icon) railToggle.appendChild(icon); - else railToggle.textContent = '⧉'; + railToggle = navIconToggle(doc, 'rail-btn has-tooltip hux-workspace-toggle-rail'); const spacer = typeof rail.querySelector === 'function' ? rail.querySelector('.rail-spacer') : null; rail.insertBefore(railToggle, spacer || null); } + let titlebarToggle = null; + if (titlebar) { + titlebarToggle = navIconToggle(doc, 'hux-workspace-titlebar-toggle has-tooltip has-tooltip--bottom'); + titlebar.insertBefore(titlebarToggle, null); + } const drawer = node(doc, 'aside', {id: 'huxWorkspaceDrawer', 'class': 'hux-workspace-drawer', 'aria-labelledby': 'huxWorkspaceTitle'}); const header = node(doc, 'header', {'class': 'hux-workspace-drawer__header'}); @@ -287,22 +309,29 @@ const content = node(doc, 'div', {'class': 'hux-workspace-drawer__content'}); drawer.hidden = true; header.appendChild(title); header.appendChild(close); drawer.appendChild(header); drawer.appendChild(content); - doc.body.appendChild(open); doc.body.appendChild(drawer); + doc.body.appendChild(drawer); + let floating = null; + if (!rail && !titlebar) { + floating = node(doc, 'button', {'class': 'hux-workspace-toggle', type: 'button', + 'aria-controls': 'huxWorkspaceDrawer', 'aria-expanded': 'false'}, 'Workspace'); + doc.body.appendChild(floating); + } + const toggles = [railToggle, titlebarToggle, floating].filter(Boolean); + const open = floating || railToggle || titlebarToggle; function setOpen(value) { - drawer.hidden = !value; open.setAttribute('aria-expanded', value ? 'true' : 'false'); - if (railToggle) railToggle.setAttribute('aria-expanded', value ? 'true' : 'false'); + drawer.hidden = !value; + toggles.forEach((toggle) => toggle.setAttribute('aria-expanded', value ? 'true' : 'false')); if (value && typeof close.focus === 'function') close.focus(); } const onOpen = () => setOpen(true); const onClose = () => setOpen(false); const onKey = (event) => { if (event.key === 'Escape' && !drawer.hidden) setOpen(false); }; - open.addEventListener('click', onOpen); close.addEventListener('click', onClose); - if (railToggle) railToggle.addEventListener('click', onOpen); + toggles.forEach((toggle) => toggle.addEventListener('click', onOpen)); + close.addEventListener('click', onClose); doc.addEventListener('keydown', onKey); - return Object.freeze({content, drawer, open, railToggle, setOpen, destroy() { + return Object.freeze({content, drawer, open, railToggle, titlebarToggle, setOpen, destroy() { doc.removeEventListener('keydown', onKey); - if (open.parentNode) open.parentNode.removeChild(open); - if (railToggle && railToggle.parentNode) railToggle.parentNode.removeChild(railToggle); + toggles.forEach((toggle) => { if (toggle.parentNode) toggle.parentNode.removeChild(toggle); }); if (drawer.parentNode) drawer.parentNode.removeChild(drawer); }}); } diff --git a/testing/tests/data/atlas_voice_language_probe.js b/testing/tests/data/atlas_voice_language_probe.js index e71802a6..a8a9ef7f 100644 --- a/testing/tests/data/atlas_voice_language_probe.js +++ b/testing/tests/data/atlas_voice_language_probe.js @@ -535,6 +535,36 @@ scenarios.reply_script_evidence_corrects_wrong_detection = async () => { return { tts: harness.ttsRequests }; }; +// FIX 4: reply-language resolution driven directly through the shipped pure +// helpers. Plain English prose full of accented European place names must never +// be flipped off the English voice; Cyrillic → ru; clear Spanish → es; a user +// force always wins. (Root cause of the live bug: the old strongReplyLanguage +// returned 'es' for a single accented char and outranked a correct 'en' STT.) +scenarios.reply_language_resolution = async () => { + const harness = makeHarness(); + await harness.flush(); + const internals = harness.context.__atlasVoiceInternals; + const strong = (text) => internals.strongReplyLanguage(text); + const resolve = (text, stt, forced) => internals.resolveReplyLanguage(text, stt || '', forced || ''); + const europe = 'Here is something interesting about Europe: Zürich, München and Málaga ' + + 'are lovely, and the café in Genève is famous. Kraków is worth a visit too.'; + const cyrillic = 'Вот что интересно о Европе: Цюрих, Мюнхен и Малага прекрасны, ' + + 'а кафе в Женеве знаменито, и по всему городу очень вкусная еда.'; + const spanish = 'Aquí tienes algo interesante sobre Europa: Zúrich, Múnich y Málaga ' + + 'son preciosas, y la comida está muy buena en toda la ciudad.'; + return { + europeStrong: strong(europe), + europeDetectionless: resolve(europe, '', ''), + europeWithEnglishStt: resolve(europe, 'en', ''), + cyrillicStrong: strong(cyrillic), + cyrillicDetectionless: resolve(cyrillic, '', ''), + spanishStrong: strong(spanish), + spanishDetectionless: resolve(spanish, '', ''), + forcedOverridesEnglishText: resolve(europe, 'en', 'ru'), + forcedOverridesCyrillic: resolve(cyrillic, '', 'en'), + }; +}; + scenarios.canonical_pcm_fallback_builds_a_valid_wav = async () => { const harness = makeHarness(); await harness.flush(); diff --git a/testing/tests/test_hermes_voice_capture_continuity.py b/testing/tests/test_hermes_voice_capture_continuity.py index 3ad6d8a8..52f73310 100644 --- a/testing/tests/test_hermes_voice_capture_continuity.py +++ b/testing/tests/test_hermes_voice_capture_continuity.py @@ -433,9 +433,12 @@ def test_conversation_language_selector_source_and_style_contract(): assert "function selectConversationLanguage(code)" in source assert "aria-haspopup" in source and "'aria-expanded':'false'" in source assert "role:'menuitemradio'" in source - # Forced language overrides both STT (start hint) and the reply voice. + # Forced language overrides both STT (start hint) and the reply voice: it is + # the first argument-gated branch of the reply-language resolver. assert "language:forcedLanguage||sessionLanguage||'auto'" in source - assert "const resolved=forcedLanguage||strongReplyLanguage(text)" in source + assert "const resolved=resolveReplyLanguage(text,turn.sttLanguage,forcedLanguage)" in source + assert "function resolveReplyLanguage(text,sttLanguage,forced)" in source + assert "if(forced) return forced;" in source # Session-only: the forced language never touches localStorage. assert "localStorage" not in source.split("CONVERSATION_LANGUAGES", 1)[1].split("function openConversationOverlay", 1)[0] # Escape peels the menu before exiting; the language button joins the trap. @@ -448,21 +451,30 @@ def test_conversation_language_selector_source_and_style_contract(): def test_conversation_orb_hermes_mark_source_and_style_contract(): - """FIX 2: the Hermes caduceus mark is embedded in the orb as a static, - low-opacity watermark that respects reduced motion and never animates.""" + """FIX 2: the orb watermark is the Hermes CHARACTER avatar (the same + hermes-agent-192.png the top app bar shows), painted as a static, low-opacity + layer that scales with the orb, respects reduced motion and never animates — + NOT the caduceus/staff SVG it used to embed.""" source = VOICE_SCRIPT.read_text(encoding="utf-8") css = (ROOT / "dockerfiles" / "hermes-webui-atlas-voice.css").read_text(encoding="utf-8") - assert "const HERMES_MARK_SVG=" in source - assert 'fill-rule="evenodd"' in source # the real favicon caduceus path + # The span is still created, but the staff SVG is gone from both the source + # and the DOM — the character mark now comes from CSS, not inline markup. assert "'voice-conversation-orb-mark'" in source - assert "orbMark.innerHTML=HERMES_MARK_SVG" in source + assert "HERMES_MARK_SVG" not in source + assert 'fill-rule="evenodd"' not in source # the old caduceus path is removed + assert "orbMark.innerHTML" not in source + assert ".voice-conversation-orb-mark" in css - # Monochrome via currentColor at low opacity; carries no animation of its own. - assert "color: rgba(233, 244, 255, 0.9)" in css mark_rule = css.split(".voice-conversation-orb-mark {", 1)[1].split("}", 1)[0] - assert "animation" not in mark_rule + # The character avatar — the exact asset the top-bar avatar references — + # painted as a scaling, centred, low-opacity watermark with no animation. + assert "url(hermes-agent-192.png)" in mark_rule + assert "background-size: contain" in mark_rule assert "opacity: 0.16" in mark_rule + assert "animation" not in mark_rule + # The staff-era monochrome currentColor tint is gone. + assert "color: rgba(233, 244, 255, 0.9)" not in css def test_conversation_thinking_working_affordance_contract(): diff --git a/testing/tests/test_hermes_voice_language_routing.py b/testing/tests/test_hermes_voice_language_routing.py index d1e31004..1a07ce40 100644 --- a/testing/tests/test_hermes_voice_language_routing.py +++ b/testing/tests/test_hermes_voice_language_routing.py @@ -885,3 +885,35 @@ def test_reply_script_evidence_corrects_wrong_detection(voice_probe): assert requests, "voice mode never reached /api/tts" for request in requests: assert request["language"] == "es" + + +def test_english_reply_full_of_european_names_stays_on_the_english_voice(voice_probe): + """FIX 4: an English paragraph packed with accented European proper nouns + (Zürich, München, Málaga, café, Kraków) carries no decisive Spanish/Russian + evidence, so it never trips a foreign voice. A single accented place name used + to make strongReplyLanguage return 'es' and outrank a correct 'en' STT — the + root cause of the live "English reply about Europe spoken in a foreign voice" + bug.""" + r = voice_probe["reply_language_resolution"] + assert r["europeStrong"] == "" + assert r["europeDetectionless"] == "" # English default (Amy) — no language field + assert r["europeWithEnglishStt"] == "en" + + +def test_reply_language_resolution_routes_cyrillic_and_spanish(voice_probe): + """FIX 4: decisive script/orthography evidence still routes correctly — + a Cyrillic paragraph to the Russian voice, a clearly-Spanish paragraph to the + Spanish voice.""" + r = voice_probe["reply_language_resolution"] + assert r["cyrillicStrong"] == "ru" + assert r["cyrillicDetectionless"] == "ru" + assert r["spanishStrong"] == "es" + assert r["spanishDetectionless"] == "es" + + +def test_reply_language_resolution_forced_language_always_wins(voice_probe): + """FIX 4: an explicit conversation-mode force beats every auto signal — + including plain English text and decisive Cyrillic evidence.""" + r = voice_probe["reply_language_resolution"] + assert r["forcedOverridesEnglishText"] == "ru" + assert r["forcedOverridesCyrillic"] == "en" diff --git a/testing/tests/test_hermes_webui_hux_integration_node.js b/testing/tests/test_hermes_webui_hux_integration_node.js index a7067793..71cbbc96 100644 --- a/testing/tests/test_hermes_webui_hux_integration_node.js +++ b/testing/tests/test_hermes_webui_hux_integration_node.js @@ -420,7 +420,11 @@ test('chrome mounts an icon-only rail toggle beside the app tabs when a rail exi assert.equal(chrome.railToggle.textContent, '⧉'); assert.equal(rail.children[0], chrome.railToggle); assert.equal(rail.children[1], spacer); - assert.match(chrome.open.attributes.class, /--railed/); + // With a real nav home present there is NO floating pill: the rail toggle is + // the primary handle and the body carries only the drawer. + assert.equal(chrome.open, chrome.railToggle); + assert.equal(doc.body.children.length, 1); + assert.equal(doc.body.children[0], chrome.drawer); chrome.railToggle.trigger('click'); assert.equal(chrome.drawer.hidden, false); assert.equal(chrome.railToggle.getAttribute('aria-expanded'), 'true'); @@ -432,6 +436,42 @@ test('chrome mounts an icon-only rail toggle beside the app tabs when a rail exi assert.equal(doc.body.children.length, 0); }); +test('chrome mounts the Workspace toggle in the mobile top app bar, never floating over the composer', () => { + // Narrow-viewport contract: below the rail breakpoint the rail collapses and + // the top app bar (header.app-titlebar) is the nav home. The toggle mounts + // there beside the app's own icons; no fixed .hux-workspace-toggle pill is + // created, so it can never overlap, push, or reflow the message composer. + const doc = new FakeDocument(); + const titlebar = new FakeNode('header'); + titlebar.setAttribute('class', 'app-titlebar'); + titlebar.insertBefore = (child, before) => { + const index = titlebar.children.indexOf(before); + titlebar.children.splice(index < 0 ? titlebar.children.length : index, 0, child); + child.parentNode = titlebar; + }; + doc.querySelector = (selector) => (selector === 'header.app-titlebar' ? titlebar : null); + doc.createElementNS = (_ns, tag) => new FakeNode(tag); + const chrome = api.createChrome(doc); + assert.equal(chrome.railToggle, null); + assert.ok(chrome.titlebarToggle); + assert.equal(chrome.open, chrome.titlebarToggle); + assert.equal(chrome.titlebarToggle.getAttribute('aria-label'), 'Workspace'); + assert.match(chrome.titlebarToggle.attributes.class, /hux-workspace-titlebar-toggle/); + assert.equal(titlebar.children.includes(chrome.titlebarToggle), true); + // No floating fixed pill anywhere in the body — only the drawer. + assert.equal(doc.body.children.length, 1); + assert.equal(doc.body.children[0], chrome.drawer); + assert.equal(doc.body.children.some((child) => /hux-workspace-toggle(\s|$)/.test(child.attributes.class || '')), false); + chrome.titlebarToggle.trigger('click'); + assert.equal(chrome.drawer.hidden, false); + assert.equal(chrome.titlebarToggle.getAttribute('aria-expanded'), 'true'); + doc.trigger('keydown', {key: 'Escape'}); + assert.equal(chrome.drawer.hidden, true); + chrome.destroy(); + assert.equal(titlebar.children.includes(chrome.titlebarToggle), false); + assert.equal(doc.body.children.length, 0); +}); + test('rail toggle renders an svg icon where namespaces exist and falls back floating without a rail', () => { const doc = new FakeDocument(); const rail = new FakeNode('nav');