fix(ui): simplify Vocero controls and correct session feedback
This commit is contained in:
+9
-46
@@ -126,15 +126,6 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="app">
|
<div id="app">
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<!-- Hamburger (theme=white only): icon button 3 horizontal lines, sits left of brand cluster, top-left corner, square icon, dark text, no visible borders.
|
|
||||||
Mirrors the 3-line menu icon in the screenshot header. -->
|
|
||||||
<button id="menu-btn" class="icon-btn menu-btn" type="button" title="Menú" aria-label="Abrir menú" hidden data-theme-only="white">
|
|
||||||
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<line x1="3" y1="6" x2="21" y2="6"/>
|
|
||||||
<line x1="3" y1="12" x2="21" y2="12"/>
|
|
||||||
<line x1="3" y1="18" x2="21" y2="18"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<div class="brand">
|
<div class="brand">
|
||||||
<div class="ident">
|
<div class="ident">
|
||||||
<div class="ident-head">
|
<div class="ident-head">
|
||||||
@@ -147,7 +138,7 @@
|
|||||||
<!-- User avatar circle (theme=white only): grid item next to the brand, not
|
<!-- User avatar circle (theme=white only): grid item next to the brand, not
|
||||||
absolute-positioned → eliminates overlap with title/subtitle entirely.
|
absolute-positioned → eliminates overlap with title/subtitle entirely.
|
||||||
Same element id so main.js syncVoceroHeaderUser() still fills the text. -->
|
Same element id so main.js syncVoceroHeaderUser() still fills the text. -->
|
||||||
<div id="ident-user-name" class="ident-user-name" hidden data-theme-only="white" aria-hidden="true">AV</div>
|
<button id="ident-user-name" class="ident-user-name" type="button" hidden disabled data-theme-only="white" aria-label="Ver datos del usuario" aria-haspopup="dialog" aria-controls="user-profile-dialog"></button>
|
||||||
<!-- Left sidebar (reports) toggle: always visible, pinned left side of the
|
<!-- Left sidebar (reports) toggle: always visible, pinned left side of the
|
||||||
topbar control cluster. Clicking slides in the offcanvas sidebar on
|
topbar control cluster. Clicking slides in the offcanvas sidebar on
|
||||||
mobile; it's a no-op hover hint on desktop where sidebar is always
|
mobile; it's a no-op hover hint on desktop where sidebar is always
|
||||||
@@ -279,9 +270,7 @@
|
|||||||
<span class="w-bar wb-7" style="--bar-h: 0.3"></span>
|
<span class="w-bar wb-7" style="--bar-h: 0.3"></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Big circular mic button (theme=white). Click = start/stop voice session.
|
<!-- Starts a voice session; becomes an indicator while connected. -->
|
||||||
Forwards all its clicks to the real #main-circle so the entire WS
|
|
||||||
pipeline still works. -->
|
|
||||||
<button id="white-mic-circle"
|
<button id="white-mic-circle"
|
||||||
class="white-mic-circle state-idle"
|
class="white-mic-circle state-idle"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -295,7 +284,6 @@
|
|||||||
<line x1="12" y1="19" x2="12" y2="22"/>
|
<line x1="12" y1="19" x2="12" y2="22"/>
|
||||||
<line x1="8" y1="22" x2="16" y2="22"/>
|
<line x1="8" y1="22" x2="16" y2="22"/>
|
||||||
</svg>
|
</svg>
|
||||||
<svg class="wmc-icon wmc-stop" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>
|
|
||||||
<span class="wmc-icon wmc-spinner" aria-hidden="true"></span>
|
<span class="wmc-icon wmc-spinner" aria-hidden="true"></span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -311,12 +299,14 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Status caption below mic circle: listening, thinking, etc. -->
|
<!-- Status caption below mic circle: listening, thinking, etc. -->
|
||||||
<p id="white-mic-caption" class="white-mic-caption" role="status">Escuchando…</p>
|
<p id="white-mic-caption" class="white-mic-caption" role="status">Toca para empezar</p>
|
||||||
|
<p id="white-mic-subcaption" class="white-mic-subcaption" hidden></p>
|
||||||
|
|
||||||
<!-- Secondary stop pill below caption, with a dark stop-square icon. -->
|
<!-- Secondary stop pill below caption, with a dark stop-square icon. -->
|
||||||
<button id="white-stop-btn"
|
<button id="white-stop-btn"
|
||||||
class="white-stop-btn"
|
class="white-stop-btn"
|
||||||
type="button"
|
type="button"
|
||||||
|
hidden
|
||||||
aria-label="Detener"
|
aria-label="Detener"
|
||||||
title="Detener sesión de voz">
|
title="Detener sesión de voz">
|
||||||
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>
|
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>
|
||||||
@@ -370,16 +360,14 @@
|
|||||||
<!-- Text chat ALWAYS VISIBLE, fixed between the stage and footer.
|
<!-- Text chat ALWAYS VISIBLE, fixed between the stage and footer.
|
||||||
The panel is now a direct child of the flex-column #app, so it
|
The panel is now a direct child of the flex-column #app, so it
|
||||||
no longer needs a topbar toggle for a right-side overlay. -->
|
no longer needs a topbar toggle for a right-side overlay. -->
|
||||||
<div id="chat-panel" class="chat-panel chat-panel-inline" aria-label="Chat de texto">
|
<div id="chat-panel" class="chat-panel chat-panel-inline" role="region" aria-label="Transcripción de la conversación">
|
||||||
<div class="chat-panel-inner">
|
<div class="chat-panel-inner">
|
||||||
<header class="chat-panel-header">
|
<header class="chat-panel-header">
|
||||||
<h3>Conversación</h3>
|
<h3>Conversación</h3>
|
||||||
<div class="chat-header-actions" aria-label="Acciones de la conversación">
|
<div class="chat-header-actions" role="group" aria-label="Acciones de la conversación">
|
||||||
<!-- Online status pill (green dot + text) — theme=white only.
|
<span id="chat-status" class="chat-status-pill" hidden data-theme-only="white" data-status="idle" role="status" aria-live="polite">
|
||||||
Mirrors the screenshot chat header top-right corner. -->
|
|
||||||
<span class="chat-status-pill" hidden data-theme-only="white" role="status" aria-live="polite">
|
|
||||||
<span class="chat-status-dot" aria-hidden="true"></span>
|
<span class="chat-status-dot" aria-hidden="true"></span>
|
||||||
<span>En línea</span>
|
<span id="chat-status-label">Sin sesión</span>
|
||||||
</span>
|
</span>
|
||||||
<button id="chat-export-btn" class="chat-export-btn icon-btn"
|
<button id="chat-export-btn" class="chat-export-btn icon-btn"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -394,31 +382,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div id="chat-history" class="chat-history" role="log" aria-live="polite" aria-atomic="false"></div>
|
<div id="chat-history" class="chat-history" role="log" aria-live="polite" aria-atomic="false"></div>
|
||||||
<!-- Text input row — HIDDEN in dark theme per user request 2026-09-04.
|
|
||||||
In white theme VISIBLE with a new design (clip button + rounded input + blue circular send),
|
|
||||||
which mirrors the screenshot footer input bar.
|
|
||||||
Restore the dark-theme input by removing the `hidden` attr + style below. -->
|
|
||||||
<form id="chat-form" class="chat-form" autocomplete="off" spellcheck="false" hidden style="display:none!important">
|
|
||||||
<button id="chat-attach-btn" class="chat-attach-btn icon-btn" type="button" aria-label="Adjuntar archivo" title="Adjuntar">
|
|
||||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<input id="chat-input"
|
|
||||||
class="chat-input"
|
|
||||||
type="text"
|
|
||||||
name="message"
|
|
||||||
placeholder="Escribe un mensaje…"
|
|
||||||
aria-label="Enviar mensaje de texto a Vocero"
|
|
||||||
maxlength="500"
|
|
||||||
/>
|
|
||||||
<button id="chat-send" type="submit" class="chat-send-btn-round" aria-label="Enviar mensaje">
|
|
||||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<line x1="22" y1="2" x2="11" y2="13"/>
|
|
||||||
<polygon points="22 2 15 22 11 13 2 9 22 2"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+117
-214
@@ -628,13 +628,13 @@ function saveTools() {
|
|||||||
/** @type {Record<AppState, { caption: string; disabled: boolean }>} */
|
/** @type {Record<AppState, { caption: string; disabled: boolean }>} */
|
||||||
const STATE_VIEWS = {
|
const STATE_VIEWS = {
|
||||||
idle: { caption: "Toca o di \"Hola Vocero\"", disabled: false },
|
idle: { caption: "Toca o di \"Hola Vocero\"", disabled: false },
|
||||||
connecting: { caption: "Conectando", disabled: true },
|
connecting: { caption: "Conectando…", disabled: true },
|
||||||
queued: { caption: "Buscando un lugar para ti…", disabled: true },
|
queued: { caption: "Buscando un lugar para ti…", disabled: true },
|
||||||
"your-turn": { caption: "¡Es tu turno! 🎉", disabled: true },
|
"your-turn": { caption: "¡Es tu turno! 🎉", disabled: true },
|
||||||
listening: { caption: "", disabled: false },
|
listening: { caption: "Escuchando…", disabled: true },
|
||||||
"user-speaking": { caption: "", disabled: false },
|
"user-speaking": { caption: "Te escucho…", disabled: true },
|
||||||
processing: { caption: "", disabled: false },
|
processing: { caption: "Pensando…", disabled: true },
|
||||||
"ai-speaking": { caption: "", disabled: false },
|
"ai-speaking": { caption: "Vocero está hablando…", disabled: true },
|
||||||
error: { caption: "Toca para reintentar", disabled: false },
|
error: { caption: "Toca para reintentar", disabled: false },
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -666,6 +666,18 @@ const orbWrap = $(".orb-wrap");
|
|||||||
const micBtn = $("#mic-btn");
|
const micBtn = $("#mic-btn");
|
||||||
/** @type {HTMLButtonElement} */
|
/** @type {HTMLButtonElement} */
|
||||||
const stopBtn = $("#stop-btn");
|
const stopBtn = $("#stop-btn");
|
||||||
|
/** @type {HTMLButtonElement} */
|
||||||
|
const whiteMicBtn = $("#white-mic-circle");
|
||||||
|
/** @type {HTMLButtonElement} */
|
||||||
|
const whiteStopBtn = $("#white-stop-btn");
|
||||||
|
/** @type {HTMLParagraphElement} */
|
||||||
|
const whiteMicCaption = $("#white-mic-caption");
|
||||||
|
/** @type {HTMLParagraphElement} */
|
||||||
|
const whiteMicSubcaption = $("#white-mic-subcaption");
|
||||||
|
/** @type {HTMLElement} */
|
||||||
|
const chatStatus = $("#chat-status");
|
||||||
|
/** @type {HTMLElement} */
|
||||||
|
const chatStatusLabel = $("#chat-status-label");
|
||||||
/** @type {HTMLElement} */
|
/** @type {HTMLElement} */
|
||||||
const queueActions = $("#queue-actions");
|
const queueActions = $("#queue-actions");
|
||||||
/** @type {HTMLButtonElement} */
|
/** @type {HTMLButtonElement} */
|
||||||
@@ -735,7 +747,7 @@ const inputVoice = $("#voice");
|
|||||||
const inputInstructions = $("#instructions");
|
const inputInstructions = $("#instructions");
|
||||||
/** @type {HTMLElement} */
|
/** @type {HTMLElement} */
|
||||||
const primaryPromptMeta = $("#primary-prompt-meta");
|
const primaryPromptMeta = $("#primary-prompt-meta");
|
||||||
/** @type {HTMLElement} */
|
/** @type {HTMLButtonElement} */
|
||||||
const identUserName = $("#ident-user-name");
|
const identUserName = $("#ident-user-name");
|
||||||
/** @type {HTMLElement} */
|
/** @type {HTMLElement} */
|
||||||
// NOTE: #reports-hint now lives inside #reports-sidebar (left sidebar), not
|
// NOTE: #reports-hint now lives inside #reports-sidebar (left sidebar), not
|
||||||
@@ -766,14 +778,6 @@ const userProfileSubtitle = $("#user-profile-subtitle");
|
|||||||
/** @type {HTMLElement} */
|
/** @type {HTMLElement} */
|
||||||
const userProfileMetadata = $("#user-profile-metadata");
|
const userProfileMetadata = $("#user-profile-metadata");
|
||||||
|
|
||||||
/* ─── Inline chat form (always-visible panel at the bottom of #app) ──── */
|
|
||||||
/** @type {HTMLFormElement | null} */
|
|
||||||
const chatForm = /** @type {HTMLFormElement | null} */ ($("#chat-form"));
|
|
||||||
/** @type {HTMLInputElement | null} */
|
|
||||||
const chatInput = /** @type {HTMLInputElement | null} */ ($("#chat-input"));
|
|
||||||
/** @type {HTMLButtonElement | null} */
|
|
||||||
const chatSendBtn = /** @type {HTMLButtonElement | null} */ ($("#chat-send"));
|
|
||||||
|
|
||||||
/** @type {null | { count:number, wrappedLen:number, hints:string[], entries:Array<{sourceScheduleLabel?:string, date?:any}> }} */
|
/** @type {null | { count:number, wrappedLen:number, hints:string[], entries:Array<{sourceScheduleLabel?:string, date?:any}> }} */
|
||||||
let voceroReportsList = null;
|
let voceroReportsList = null;
|
||||||
let voceroReportsLoaded = false;
|
let voceroReportsLoaded = false;
|
||||||
@@ -1220,7 +1224,7 @@ function populateUserProfileDialog(profile) {
|
|||||||
* and optionally restore focus to the avatar when it closes (a11y).
|
* and optionally restore focus to the avatar when it closes (a11y).
|
||||||
*/
|
*/
|
||||||
function openUserProfileDialog() {
|
function openUserProfileDialog() {
|
||||||
if (!userProfileDialog) return;
|
if (!userProfileDialog || !extractVoceroUserName(voceroUserProfile)) return;
|
||||||
populateUserProfileDialog(/** @type {Record<string, unknown>} */ (voceroUserProfile));
|
populateUserProfileDialog(/** @type {Record<string, unknown>} */ (voceroUserProfile));
|
||||||
try {
|
try {
|
||||||
userProfileDialog.showModal();
|
userProfileDialog.showModal();
|
||||||
@@ -1234,17 +1238,18 @@ function openUserProfileDialog() {
|
|||||||
function syncVoceroHeaderUser() {
|
function syncVoceroHeaderUser() {
|
||||||
const name = extractVoceroUserName(voceroUserProfile);
|
const name = extractVoceroUserName(voceroUserProfile);
|
||||||
const initials = initialsForName(name);
|
const initials = initialsForName(name);
|
||||||
identUserName.hidden = !name;
|
identUserName.hidden = s2sTheme !== "white" || !name;
|
||||||
|
identUserName.disabled = !name;
|
||||||
if (name) {
|
if (name) {
|
||||||
// Show avatar with the 2-letter initials. Full name is reachable via hover
|
// Show avatar with the 2-letter initials. Full name is reachable via hover
|
||||||
// tooltip (title) and is used by screen readers via aria-label.
|
// tooltip (title) and is used by screen readers via aria-label.
|
||||||
identUserName.textContent = initials;
|
identUserName.textContent = initials;
|
||||||
identUserName.title = `Usuario: ${name}`;
|
identUserName.title = `Usuario: ${name}`;
|
||||||
identUserName.setAttribute("aria-label", `Usuario: ${name}`);
|
identUserName.setAttribute("aria-label", `Ver datos de ${name}`);
|
||||||
} else {
|
} else {
|
||||||
identUserName.textContent = "";
|
identUserName.textContent = "";
|
||||||
identUserName.removeAttribute("title");
|
identUserName.removeAttribute("title");
|
||||||
identUserName.removeAttribute("aria-label");
|
identUserName.setAttribute("aria-label", "Ver datos del usuario");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!reportsHint) return;
|
if (!reportsHint) return;
|
||||||
@@ -1820,40 +1825,6 @@ function pushToolsToSession() {
|
|||||||
// streaming state. The client's events are forwarded to its on* methods.
|
// streaming state. The client's events are forwarded to its on* methods.
|
||||||
const chat = new ChatView();
|
const chat = new ChatView();
|
||||||
|
|
||||||
// ── Inline chat form submit (always-visible panel) ─────────────────────────
|
|
||||||
// User can send free-text messages by typing + pressing Enter (form submit) or
|
|
||||||
// tapping the gradient paper-plane send button. The text flows into the
|
|
||||||
// existing realtime client sendUserText() so it behaves exactly the same as a
|
|
||||||
// voice-transcribed phrase (onUserTranscriptFinished → server → assistant
|
|
||||||
// response). ChatView already renders bubbles/history via its on* hooks so we
|
|
||||||
// just forward the text to the socket.
|
|
||||||
if (chatForm && chatInput) {
|
|
||||||
chatForm.addEventListener("submit", (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const raw = chatInput.value;
|
|
||||||
if (!raw) return;
|
|
||||||
const text = raw.trim();
|
|
||||||
if (!text) {
|
|
||||||
chatInput.value = "";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// 1. Send to Vocero realtime server (identical to voice phrase submission).
|
|
||||||
try {
|
|
||||||
if (client && typeof client.sendUserText === "function") {
|
|
||||||
client.sendUserText(text);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
// Never crash the UI if the realtime client rejected; swallow to console.
|
|
||||||
console.warn("chat-form: sendUserText threw", err);
|
|
||||||
}
|
|
||||||
// 2. Reset input. ChatView.onUserTranscriptStarted / onUserTranscriptFinished
|
|
||||||
// (already wired to client events) will render the ephemeral bubble +
|
|
||||||
// persistent history row + scroll to bottom automatically.
|
|
||||||
chatInput.value = "";
|
|
||||||
chatInput.focus();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Account / limiter ─────────────────────────────────────────────────────
|
// ── Account / limiter ─────────────────────────────────────────────────────
|
||||||
// Login chip + daily-limit modal (inert unless the deploy is in LB mode). The
|
// Login chip + daily-limit modal (inert unless the deploy is in LB mode). The
|
||||||
// server meters conversation time; the client just heartbeats a live session
|
// server meters conversation time; the client just heartbeats a live session
|
||||||
@@ -1919,7 +1890,12 @@ function _kickIdleTimer(reason = "activity") {
|
|||||||
// user can tell what happened instead of wondering why the orb reset.
|
// user can tell what happened instead of wondering why the orb reset.
|
||||||
circleSubcaption.textContent = "Sesión cerrada tras 30 segundos de silencio. Toca el círculo o repite «Hola Vocero» para empezar de nuevo.";
|
circleSubcaption.textContent = "Sesión cerrada tras 30 segundos de silencio. Toca el círculo o repite «Hola Vocero» para empezar de nuevo.";
|
||||||
circleSubcaption.hidden = false;
|
circleSubcaption.hidden = false;
|
||||||
window.setTimeout(() => { circleSubcaption.hidden = true; }, 8000);
|
whiteMicSubcaption.textContent = circleSubcaption.textContent;
|
||||||
|
whiteMicSubcaption.hidden = false;
|
||||||
|
window.setTimeout(() => {
|
||||||
|
circleSubcaption.hidden = true;
|
||||||
|
whiteMicSubcaption.hidden = true;
|
||||||
|
}, 8000);
|
||||||
})();
|
})();
|
||||||
}, IDLE_TIMEOUT_MS);
|
}, IDLE_TIMEOUT_MS);
|
||||||
if (DEBUG) {
|
if (DEBUG) {
|
||||||
@@ -1938,14 +1914,41 @@ let client = null;
|
|||||||
/** @type {MediaStream | null} */
|
/** @type {MediaStream | null} */
|
||||||
let micStream = null;
|
let micStream = null;
|
||||||
let micMuted = false;
|
let micMuted = false;
|
||||||
|
/** @type {Promise<void> | null} */
|
||||||
|
let teardownPromise = null;
|
||||||
|
|
||||||
|
function syncVoiceControls() {
|
||||||
|
const view = STATE_VIEWS[currentState];
|
||||||
|
const live = LIVE_STATES.has(currentState);
|
||||||
|
const disabled = view.disabled || !!teardownPromise;
|
||||||
|
const label = currentState === "error" ? "Reintentar conversación de voz"
|
||||||
|
: currentState === "idle" ? "Iniciar conversación de voz" : view.caption;
|
||||||
|
circleBtn.disabled = disabled;
|
||||||
|
circleBtn.setAttribute("aria-label", label);
|
||||||
|
whiteMicBtn.disabled = disabled;
|
||||||
|
whiteMicBtn.setAttribute("aria-label", label);
|
||||||
|
whiteMicBtn.className = `white-mic-circle ${STATE_CLASS[currentState]}`;
|
||||||
|
whiteStopBtn.hidden = !live || s2sTheme !== "white";
|
||||||
|
whiteStopBtn.disabled = !live || !!teardownPromise;
|
||||||
|
|
||||||
|
const waiting = currentState === "queued" || currentState === "your-turn";
|
||||||
|
chatStatus.dataset.status = live ? "online" : currentState === "error" ? "error"
|
||||||
|
: currentState === "connecting" || waiting ? "waiting" : "idle";
|
||||||
|
chatStatusLabel.textContent = live ? "En línea"
|
||||||
|
: currentState === "connecting" ? "Conectando…"
|
||||||
|
: currentState === "queued" ? "En cola"
|
||||||
|
: currentState === "your-turn" ? "Tu turno"
|
||||||
|
: currentState === "error" ? "Sin conexión" : "Sin sesión";
|
||||||
|
}
|
||||||
|
|
||||||
/** @param {AppState} next */
|
/** @param {AppState} next */
|
||||||
function setState(next) {
|
function setState(next) {
|
||||||
|
const previousState = currentState;
|
||||||
currentState = next;
|
currentState = next;
|
||||||
const view = STATE_VIEWS[next];
|
const view = STATE_VIEWS[next];
|
||||||
circleBtn.disabled = view.disabled;
|
|
||||||
circleBtn.className = `circle ${STATE_CLASS[next]}`;
|
circleBtn.className = `circle ${STATE_CLASS[next]}`;
|
||||||
if (next !== "error") setCaption(view.caption);
|
syncVoiceControls();
|
||||||
|
if (next !== "error" || previousState !== "error") setCaption(view.caption);
|
||||||
|
|
||||||
const live = LIVE_STATES.has(next);
|
const live = LIVE_STATES.has(next);
|
||||||
orbWrap.classList.toggle("live", live);
|
orbWrap.classList.toggle("live", live);
|
||||||
@@ -1973,6 +1976,8 @@ function setState(next) {
|
|||||||
} else {
|
} else {
|
||||||
circleSubcaption.hidden = true;
|
circleSubcaption.hidden = true;
|
||||||
}
|
}
|
||||||
|
whiteMicSubcaption.textContent = circleSubcaption.textContent;
|
||||||
|
whiteMicSubcaption.hidden = circleSubcaption.hidden;
|
||||||
|
|
||||||
// Wake word: armed only when idle/error (no live mic contention with the
|
// Wake word: armed only when idle/error (no live mic contention with the
|
||||||
// realtime session), and disarmed the moment we move into any other state.
|
// realtime session), and disarmed the moment we move into any other state.
|
||||||
@@ -2022,6 +2027,9 @@ function setCaption(text, kind = "") {
|
|||||||
if (!trimmed) parts.push("empty");
|
if (!trimmed) parts.push("empty");
|
||||||
if (addIdleCta) parts.push("idle-cta");
|
if (addIdleCta) parts.push("idle-cta");
|
||||||
circleCaption.className = parts.join(" ");
|
circleCaption.className = parts.join(" ");
|
||||||
|
whiteMicCaption.textContent = trimmed;
|
||||||
|
whiteMicCaption.classList.toggle("muted", kind === "muted");
|
||||||
|
whiteMicCaption.classList.toggle("error", kind === "error");
|
||||||
}
|
}
|
||||||
|
|
||||||
function openSettings() {
|
function openSettings() {
|
||||||
@@ -2177,22 +2185,9 @@ aboutModal.addEventListener("click", (e) => {
|
|||||||
if (e.target === aboutModal) aboutModal.close();
|
if (e.target === aboutModal) aboutModal.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
/* ─── User Profile dialog handlers (click avatar circle) ───────────── */
|
/* ─── User profile: native button and dialog keyboard behavior ─────── */
|
||||||
// The avatar circle in the brand header: when clicked, opens a native
|
|
||||||
// <dialog> with the user's profile info. Keyboard accessible: Enter / Space
|
|
||||||
// also open the dialog. a11y note: we attach role="button" + tabindex via JS
|
|
||||||
// at load time (so even if markup changes the behavior is preserved).
|
|
||||||
if (identUserName) {
|
if (identUserName) {
|
||||||
identUserName.setAttribute("role", "button");
|
|
||||||
identUserName.setAttribute("tabindex", "0");
|
|
||||||
identUserName.style.cursor = "pointer";
|
|
||||||
identUserName.addEventListener("click", (e) => { e.preventDefault(); openUserProfileDialog(); });
|
identUserName.addEventListener("click", (e) => { e.preventDefault(); openUserProfileDialog(); });
|
||||||
identUserName.addEventListener("keydown", (e) => {
|
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
|
||||||
e.preventDefault();
|
|
||||||
openUserProfileDialog();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
// Close via the explicit X button (top-right of the card).
|
// Close via the explicit X button (top-right of the card).
|
||||||
if (userProfileClose) {
|
if (userProfileClose) {
|
||||||
@@ -2200,6 +2195,9 @@ if (userProfileClose) {
|
|||||||
}
|
}
|
||||||
// Click in the backdrop = click whose target is the <dialog> itself.
|
// Click in the backdrop = click whose target is the <dialog> itself.
|
||||||
if (userProfileDialog) {
|
if (userProfileDialog) {
|
||||||
|
userProfileDialog.addEventListener("close", () => {
|
||||||
|
if (!identUserName.hidden) identUserName.focus();
|
||||||
|
});
|
||||||
userProfileDialog.addEventListener("click", (e) => {
|
userProfileDialog.addEventListener("click", (e) => {
|
||||||
if (e.target === userProfileDialog) userProfileDialog.close();
|
if (e.target === userProfileDialog) userProfileDialog.close();
|
||||||
});
|
});
|
||||||
@@ -2724,48 +2722,15 @@ async function fetchConfig() {
|
|||||||
void account.refresh();
|
void account.refresh();
|
||||||
syncToolsUi();
|
syncToolsUi();
|
||||||
syncConnectionUi();
|
syncConnectionUi();
|
||||||
// Apply theme runtime: show/hide theme-only elements, wire up white-mic
|
// Apply the theme and refresh state-dependent controls after config resolves.
|
||||||
// button forwarders, unhide chat-form in white theme, etc. Must run after
|
|
||||||
// `s2sTheme` is set and the data-theme attr has been applied to html.
|
|
||||||
_applyThemeRuntime();
|
_applyThemeRuntime();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Spanish (Castilian) label map for the "Escuchando..." status caption below
|
|
||||||
* the white-theme blue mic circle. Mirrors every client state.
|
|
||||||
*/
|
|
||||||
const WHITE_STATUS_LABELS = Object.freeze({
|
|
||||||
idle: "Toca para empezar",
|
|
||||||
connecting: "Conectando…",
|
|
||||||
queued: "En cola…",
|
|
||||||
"your-turn":"Escuchando…",
|
|
||||||
processing: "Pensando…",
|
|
||||||
"ai-speaking":"Hablando…",
|
|
||||||
error: "Algo salió mal",
|
|
||||||
stopped: "Detenido",
|
|
||||||
});
|
|
||||||
|
|
||||||
let _themeRuntimeInitialized = false;
|
let _themeRuntimeInitialized = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply runtime wiring for the current theme:
|
* Apply theme visibility, then restore the avatar and session visibility.
|
||||||
* 1. Flip [hidden] on every [data-theme-only="white" / "dark"] element in
|
* Register theme-specific actions once; setState/setCaption update both views.
|
||||||
* the document so the correct parallel UI is shown.
|
|
||||||
* 2. Remove the hardcoded `hidden + display:none !important` on #chat-form
|
|
||||||
* in white theme (we want the text input + send button visible, just
|
|
||||||
* like the screenshot input bar).
|
|
||||||
* 3. Click forwarders: #white-mic-circle → real #main-circle,
|
|
||||||
* #white-stop-btn → real #stop-btn.
|
|
||||||
* 4. MutationObserver on #main-circle `.className` to mirror every
|
|
||||||
* `state-*` class live to #white-mic-circle so it swaps its visuals
|
|
||||||
* (mic icon / stop icon / spinner) exactly in sync with the orb.
|
|
||||||
* 5. Wrap `setCaption()` so the Spanish label `#white-mic-caption` gets
|
|
||||||
* updated at the same time as the default orb caption.
|
|
||||||
* 6. Quick action button listeners: populate chat-input / or send via
|
|
||||||
* client.sendUserText() when a session is live.
|
|
||||||
* Called by fetchConfig() AFTER s2sTheme and <html data-theme> are set.
|
|
||||||
* Re-runs safely on every config fetch (guard `_themeRuntimeInitialized` for
|
|
||||||
* the one-shot observer / listeners).
|
|
||||||
*/
|
*/
|
||||||
function _applyThemeRuntime() {
|
function _applyThemeRuntime() {
|
||||||
const isWhite = s2sTheme === "white";
|
const isWhite = s2sTheme === "white";
|
||||||
@@ -2793,109 +2758,29 @@ function _applyThemeRuntime() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 2. Chat form input: in WHITE theme, we need the text input visible to
|
syncVoceroHeaderUser();
|
||||||
// match the screenshot. The HTML ships with `hidden` + inline
|
syncVoiceControls();
|
||||||
// `display:none !important` because the original dark theme hid it
|
|
||||||
// (per user request 2026-09-04). Clear both here, restore when leaving.
|
|
||||||
const chatForm = document.getElementById("chat-form");
|
|
||||||
if (chatForm) {
|
|
||||||
if (isWhite) {
|
|
||||||
chatForm.removeAttribute("hidden");
|
|
||||||
chatForm.style.removeProperty("display");
|
|
||||||
} else if (!chatForm.hasAttribute("hidden")) {
|
|
||||||
// Reapply original dark-theme defaults (preserve existing user edit).
|
|
||||||
chatForm.setAttribute("hidden", "");
|
|
||||||
chatForm.style.setProperty("display", "none", "important");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// One-shot wiring (never re-add listeners/observer on subsequent calls).
|
// One-shot wiring (never re-add listeners on subsequent calls).
|
||||||
if (_themeRuntimeInitialized) return;
|
if (_themeRuntimeInitialized) return;
|
||||||
_themeRuntimeInitialized = true;
|
_themeRuntimeInitialized = true;
|
||||||
|
|
||||||
// 3a. Click forwarder: white mic circle → the real start/stop orb handler.
|
// Start through the same handler as the dark-theme orb.
|
||||||
const whiteMic = document.getElementById("white-mic-circle");
|
const whiteMic = document.getElementById("white-mic-circle");
|
||||||
const realCircle = document.getElementById("main-circle");
|
const realCircle = document.getElementById("main-circle");
|
||||||
if (whiteMic && realCircle) {
|
if (whiteMic && realCircle) {
|
||||||
whiteMic.addEventListener("click", (ev) => {
|
whiteMic.addEventListener("click", (ev) => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
realCircle.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
if (!whiteMic.disabled) realCircle.click();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3b. Click forwarder: white "Detener" pill → hidden real stop side-btn.
|
whiteStopBtn.addEventListener("click", async () => {
|
||||||
const whiteStop = document.getElementById("white-stop-btn");
|
if (LIVE_STATES.has(currentState)) await teardown();
|
||||||
const realStop = document.getElementById("stop-btn");
|
});
|
||||||
if (whiteStop && realStop) {
|
|
||||||
whiteStop.addEventListener("click", (ev) => {
|
|
||||||
ev.preventDefault();
|
|
||||||
ev.stopPropagation();
|
|
||||||
realStop.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Live sync of #main-circle className → #white-mic-circle className
|
// Retained quick actions are only available to an active realtime session.
|
||||||
// (states: .state-idle, .state-connecting, .state-your-turn, .state-processing,
|
|
||||||
// .state-ai-speaking, .state-error, .state-queued). White-mic styles
|
|
||||||
// use these same state-* classes (CSS prefixes with .white-mic-circle.state-*)
|
|
||||||
// so we simply shadow every class on the orb button to the new button.
|
|
||||||
if (whiteMic && realCircle && typeof MutationObserver !== "undefined") {
|
|
||||||
const syncStateClasses = () => {
|
|
||||||
// Keep the base class + data-theme-only marker but clear/re-add the rest.
|
|
||||||
const tokens = Array.from(realCircle.classList);
|
|
||||||
for (const cl of Array.from(whiteMic.classList)) {
|
|
||||||
if (cl.startsWith("state-")) whiteMic.classList.remove(cl);
|
|
||||||
}
|
|
||||||
for (const cl of tokens) {
|
|
||||||
if (cl.startsWith("state-")) whiteMic.classList.add(cl);
|
|
||||||
}
|
|
||||||
// Also show/hide the white stop pill in sync with the real stop-btn
|
|
||||||
// (which is shown when connected).
|
|
||||||
if (whiteStop) {
|
|
||||||
const visible = !realStop.hasAttribute("aria-hidden") || realStop.getAttribute("aria-hidden") === "false";
|
|
||||||
if (visible) {
|
|
||||||
whiteStop.style.removeProperty("display");
|
|
||||||
whiteStop.removeAttribute("hidden");
|
|
||||||
} else {
|
|
||||||
whiteStop.setAttribute("hidden", "");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
syncStateClasses();
|
|
||||||
const obs = new MutationObserver(() => syncStateClasses());
|
|
||||||
obs.observe(realCircle, { attributes: true, attributeFilter: ["class", "aria-hidden"] });
|
|
||||||
if (realStop) obs.observe(realStop, { attributes: true, attributeFilter: ["aria-hidden", "class"] });
|
|
||||||
|
|
||||||
// 5. Set the initial Spanish caption for theme=white + wrap setCaption()
|
|
||||||
// so any future setCaption() call also updates the Spanish label.
|
|
||||||
const capWhite = document.getElementById("white-mic-caption");
|
|
||||||
if (capWhite) {
|
|
||||||
capWhite.textContent = WHITE_STATUS_LABELS[currentState] || WHITE_STATUS_LABELS.idle;
|
|
||||||
const origSetCaption = setCaption;
|
|
||||||
// Re-assign the global (window / module scope var holds function ref) so
|
|
||||||
// each caller of setCaption(text, kind) now also drives the Spanish label.
|
|
||||||
// We intentionally DON'T replace the signature — old code keeps working.
|
|
||||||
(function (prev) {
|
|
||||||
window.setCaption = function (text, kind) {
|
|
||||||
prev(text, kind);
|
|
||||||
// When in white theme we prefer the Spanish state-based label from
|
|
||||||
// WHITE_STATUS_LABELS whenever possible, falling back to raw text.
|
|
||||||
if (s2sTheme === "white" && capWhite) {
|
|
||||||
const fromState = WHITE_STATUS_LABELS[currentState];
|
|
||||||
capWhite.textContent = fromState || text || WHITE_STATUS_LABELS.idle;
|
|
||||||
const muted = kind === "muted";
|
|
||||||
capWhite.classList.toggle("muted", !!muted);
|
|
||||||
capWhite.classList.toggle("error", kind === "error");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
})(origSetCaption);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 6. Quick action buttons for summaries, data lookup and response drafts
|
|
||||||
// populate the text input or — if a realtime
|
|
||||||
// session is live — sends it as user text straight away.
|
|
||||||
document.querySelectorAll(".quick-action-btn").forEach(btn => {
|
document.querySelectorAll(".quick-action-btn").forEach(btn => {
|
||||||
btn.addEventListener("click", () => {
|
btn.addEventListener("click", () => {
|
||||||
const action = btn.getAttribute("data-action") || "";
|
const action = btn.getAttribute("data-action") || "";
|
||||||
@@ -2907,16 +2792,7 @@ function _applyThemeRuntime() {
|
|||||||
const text = prompts[action] || "";
|
const text = prompts[action] || "";
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
if (client && typeof client.sendUserText === "function") {
|
if (client && typeof client.sendUserText === "function") {
|
||||||
// Live session: send directly as if the user typed + pressed enter.
|
|
||||||
client.sendUserText(text);
|
client.sendUserText(text);
|
||||||
} else {
|
|
||||||
// No session: prefill the text input so they can send manually later.
|
|
||||||
const inp = document.getElementById("chat-input");
|
|
||||||
if (inp) {
|
|
||||||
inp.value = text;
|
|
||||||
inp.dispatchEvent(new Event("input", { bubbles: true }));
|
|
||||||
inp.focus({ preventScroll: false });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -3301,6 +3177,7 @@ async function doStart(audioContext = null) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
c.addEventListener("status", (e) => {
|
c.addEventListener("status", (e) => {
|
||||||
|
if (client !== c) return;
|
||||||
const detail = /** @type {CustomEvent<{ status: string }>} */ (e).detail;
|
const detail = /** @type {CustomEvent<{ status: string }>} */ (e).detail;
|
||||||
_kickIdleTimer(`client-status:${detail.status}`);
|
_kickIdleTimer(`client-status:${detail.status}`);
|
||||||
onClientStatus(detail.status);
|
onClientStatus(detail.status);
|
||||||
@@ -3340,15 +3217,19 @@ async function doStart(audioContext = null) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
c.addEventListener("error", (e) => {
|
c.addEventListener("error", (e) => {
|
||||||
|
if (client !== c) return;
|
||||||
const detail = /** @type {CustomEvent<{ error: unknown }>} */ (e).detail;
|
const detail = /** @type {CustomEvent<{ error: unknown }>} */ (e).detail;
|
||||||
onFatalError(detail.error);
|
onFatalError(detail.error);
|
||||||
});
|
});
|
||||||
c.addEventListener("server-error", (e) => {
|
c.addEventListener("server-error", (e) => {
|
||||||
// Non-fatal: the backend reported an error mid-session. Log it, keep the
|
// Non-fatal: the backend reported an error mid-session. Log it, keep the
|
||||||
// socket and the conversation alive (the model can recover on its own).
|
// socket and the conversation alive while showing an actionable message.
|
||||||
const detail = /** @type {CustomEvent<{ error: unknown }>} */ (e).detail;
|
const detail = /** @type {CustomEvent<{ error: unknown }>} */ (e).detail;
|
||||||
const msg = detail.error instanceof Error ? detail.error.message : String(detail.error);
|
const msg = detail.error instanceof Error ? detail.error.message : String(detail.error);
|
||||||
console.warn("[main] server error (non-fatal):", msg);
|
console.warn("[main] server error (non-fatal):", msg);
|
||||||
|
if (client === c) {
|
||||||
|
setCaption("Vocero no pudo completar la respuesta. Intenta hablar de nuevo.", "error");
|
||||||
|
}
|
||||||
});
|
});
|
||||||
c.addEventListener("session", (e) => {
|
c.addEventListener("session", (e) => {
|
||||||
const info = /** @type {CustomEvent<{ info: import("./ws/s2s-ws-client.js").WsSessionInfo }>} */ (e).detail.info;
|
const info = /** @type {CustomEvent<{ info: import("./ws/s2s-ws-client.js").WsSessionInfo }>} */ (e).detail.info;
|
||||||
@@ -3512,7 +3393,9 @@ function onClientStatus(status) {
|
|||||||
setState("ai-speaking");
|
setState("ai-speaking");
|
||||||
break;
|
break;
|
||||||
case "closed":
|
case "closed":
|
||||||
// teardown() will move us to idle
|
// An intentional close clears client before closing the socket. A remote
|
||||||
|
// close still needs to release the mic and clear the online UI.
|
||||||
|
if (client) void teardown();
|
||||||
break;
|
break;
|
||||||
case "error":
|
case "error":
|
||||||
setState("error");
|
setState("error");
|
||||||
@@ -3520,7 +3403,21 @@ function onClientStatus(status) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function teardown() {
|
/** @param {AppState} [finalState] @returns {Promise<void>} */
|
||||||
|
function teardown(finalState = "idle") {
|
||||||
|
if (teardownPromise) return teardownPromise;
|
||||||
|
const closingClient = client;
|
||||||
|
client = null;
|
||||||
|
teardownPromise = finishTeardown(closingClient, finalState).finally(() => {
|
||||||
|
teardownPromise = null;
|
||||||
|
syncVoiceControls();
|
||||||
|
});
|
||||||
|
syncVoiceControls();
|
||||||
|
return teardownPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {S2sWsRealtimeClient | null} closingClient @param {AppState} finalState */
|
||||||
|
async function finishTeardown(closingClient, finalState) {
|
||||||
stopHeartbeat();
|
stopHeartbeat();
|
||||||
stopJoinCountdown();
|
stopJoinCountdown();
|
||||||
endTrackedSession();
|
endTrackedSession();
|
||||||
@@ -3531,13 +3428,12 @@ async function teardown() {
|
|||||||
// user left queue, limit exceeded) the pending wake-word confirmation would
|
// user left queue, limit exceeded) the pending wake-word confirmation would
|
||||||
// be re-injected on the next session. Clear it so we never double-fire.
|
// be re-injected on the next session. Clear it so we never double-fire.
|
||||||
pendingWakeWordConfirmationText = "";
|
pendingWakeWordConfirmationText = "";
|
||||||
if (client) {
|
if (closingClient) {
|
||||||
try {
|
try {
|
||||||
await client.close();
|
await closingClient.close();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn("[main] error closing client:", err);
|
console.warn("[main] error closing client:", err);
|
||||||
}
|
}
|
||||||
client = null;
|
|
||||||
}
|
}
|
||||||
if (micStream) {
|
if (micStream) {
|
||||||
for (const track of micStream.getTracks()) track.stop();
|
for (const track of micStream.getTracks()) track.stop();
|
||||||
@@ -3545,7 +3441,7 @@ async function teardown() {
|
|||||||
}
|
}
|
||||||
micMuted = false;
|
micMuted = false;
|
||||||
micBtn.classList.remove("muted");
|
micBtn.classList.remove("muted");
|
||||||
setState("idle");
|
setState(finalState);
|
||||||
// Refresh the chip's remaining-today after the budget moved.
|
// Refresh the chip's remaining-today after the budget moved.
|
||||||
if (limiterOn) void account.refresh();
|
if (limiterOn) void account.refresh();
|
||||||
}
|
}
|
||||||
@@ -3553,13 +3449,20 @@ async function teardown() {
|
|||||||
/** @param {unknown} err */
|
/** @param {unknown} err */
|
||||||
function onFatalError(err) {
|
function onFatalError(err) {
|
||||||
console.error("[main] fatal:", err);
|
console.error("[main] fatal:", err);
|
||||||
|
const wasLive = LIVE_STATES.has(currentState);
|
||||||
setState("error");
|
setState("error");
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
setCaption(truncateError(message), "error");
|
const caption = message.includes("micrófono")
|
||||||
void teardown().catch(() => {
|
? `${truncateError(message)} Toca para reintentar.`
|
||||||
|
: wasLive
|
||||||
|
? "Se interrumpió la conexión con Vocero. Revisa tu conexión y toca para reintentar."
|
||||||
|
: "No se pudo conectar con Vocero. Revisa tu conexión y toca para reintentar.";
|
||||||
|
setCaption(caption, "error");
|
||||||
|
const showError = () => {
|
||||||
setState("error");
|
setState("error");
|
||||||
setCaption(truncateError(message), "error");
|
setCaption(caption, "error");
|
||||||
});
|
};
|
||||||
|
void teardown("error").then(showError, showError);
|
||||||
}
|
}
|
||||||
|
|
||||||
setState("idle");
|
setState("idle");
|
||||||
|
|||||||
+48
-216
@@ -505,7 +505,7 @@ a:hover {
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
text-shadow: 0 1px 2px rgba(0,0,0,0.35);
|
text-shadow: 0 1px 2px rgba(0,0,0,0.35);
|
||||||
user-select: none;
|
user-select: none;
|
||||||
cursor: default;
|
cursor: pointer;
|
||||||
transition:
|
transition:
|
||||||
transform .22s cubic-bezier(.2,.7,.2,1),
|
transform .22s cubic-bezier(.2,.7,.2,1),
|
||||||
box-shadow .3s ease,
|
box-shadow .3s ease,
|
||||||
@@ -541,6 +541,11 @@ a:hover {
|
|||||||
0 6px 22px rgba(0,0,0,0.28),
|
0 6px 22px rgba(0,0,0,0.28),
|
||||||
0 0 34px -6px color-mix(in srgb, var(--brand) 60%, transparent);
|
0 0 34px -6px color-mix(in srgb, var(--brand) 60%, transparent);
|
||||||
}
|
}
|
||||||
|
#ident-user-name[hidden],
|
||||||
|
#white-stop-btn[hidden],
|
||||||
|
#white-mic-subcaption[hidden] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
@keyframes pulseDot {
|
@keyframes pulseDot {
|
||||||
0%, 100% { opacity: 1; transform: scale(1); }
|
0%, 100% { opacity: 1; transform: scale(1); }
|
||||||
50% { opacity: .78; transform: scale(.9); }
|
50% { opacity: .78; transform: scale(.9); }
|
||||||
@@ -2459,88 +2464,6 @@ body.cam-on .footer {
|
|||||||
background-clip: content-box;
|
background-clip: content-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Chat input row (bottom of chat panel, above the button) ──────── */
|
|
||||||
/* NOTE: currently HIDDEN in index.html. Kept seamless here so if/when we
|
|
||||||
re-enable it the form melts visually into the page bg, no separator frame. */
|
|
||||||
.chat-form {
|
|
||||||
display: flex;
|
|
||||||
align-items: stretch;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 8px 2px 0;
|
|
||||||
border-top: 0 solid transparent;
|
|
||||||
background: transparent;
|
|
||||||
flex: none;
|
|
||||||
}
|
|
||||||
.chat-input {
|
|
||||||
flex: 1 1 auto;
|
|
||||||
min-width: 0;
|
|
||||||
height: 40px;
|
|
||||||
padding: 0 14px;
|
|
||||||
border-radius: 12px;
|
|
||||||
background: color-mix(in srgb, var(--bg) 70%, rgba(255,255,255,0.02));
|
|
||||||
border: 1px solid color-mix(in srgb, var(--brand) 18%, var(--border));
|
|
||||||
color: var(--text);
|
|
||||||
font-size: 14px;
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
line-height: 1;
|
|
||||||
transition: border-color .2s ease, box-shadow .2s ease, background .2s ease;
|
|
||||||
outline: none;
|
|
||||||
box-shadow: 0 0 0 0 transparent;
|
|
||||||
}
|
|
||||||
.chat-input::placeholder {
|
|
||||||
color: color-mix(in srgb, var(--text-dim) 72%, transparent);
|
|
||||||
letter-spacing: 0.01em;
|
|
||||||
}
|
|
||||||
.chat-input:hover {
|
|
||||||
border-color: color-mix(in srgb, var(--brand-2) 44%, var(--border));
|
|
||||||
}
|
|
||||||
.chat-input:focus {
|
|
||||||
border-color: color-mix(in srgb, var(--brand-3) 60%, transparent);
|
|
||||||
background: color-mix(in srgb, var(--bg) 82%, rgba(255,255,255,0.04));
|
|
||||||
box-shadow:
|
|
||||||
0 0 0 3px color-mix(in srgb, var(--brand-3) 22%, transparent),
|
|
||||||
0 0 26px -8px color-mix(in srgb, var(--brand-3) 70%, transparent);
|
|
||||||
}
|
|
||||||
.chat-send-btn {
|
|
||||||
flex: none;
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
border-radius: 12px;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 0;
|
|
||||||
color: #fff;
|
|
||||||
background:
|
|
||||||
radial-gradient(120% 120% at 30% 18%, rgba(255,255,255,0.18), transparent 58%),
|
|
||||||
linear-gradient(135deg,
|
|
||||||
color-mix(in srgb, var(--brand) 82%, transparent),
|
|
||||||
color-mix(in srgb, var(--brand-2) 70%, transparent) 55%,
|
|
||||||
color-mix(in srgb, var(--brand-3) 70%, transparent));
|
|
||||||
border: 1px solid color-mix(in srgb, var(--brand-2) 50%, var(--border));
|
|
||||||
box-shadow:
|
|
||||||
0 1px 0 rgba(255,255,255,0.1) inset,
|
|
||||||
0 6px 18px rgba(0,0,0,0.30),
|
|
||||||
0 0 24px -8px color-mix(in srgb, var(--brand-2) 70%, transparent);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: transform .22s ease, box-shadow .3s ease, filter .3s ease;
|
|
||||||
}
|
|
||||||
.chat-send-btn:hover {
|
|
||||||
transform: translateY(-1px);
|
|
||||||
filter: brightness(1.05);
|
|
||||||
box-shadow:
|
|
||||||
0 1px 0 rgba(255,255,255,0.12) inset,
|
|
||||||
0 10px 22px rgba(0,0,0,0.34),
|
|
||||||
0 0 34px -6px color-mix(in srgb, var(--brand-2) 80%, transparent);
|
|
||||||
}
|
|
||||||
.chat-send-btn:active { transform: translateY(0) scale(0.98); }
|
|
||||||
.chat-send-btn:focus-visible {
|
|
||||||
outline: none;
|
|
||||||
box-shadow:
|
|
||||||
0 0 0 3px color-mix(in srgb, var(--brand-3) 40%, transparent),
|
|
||||||
0 6px 18px rgba(0,0,0,0.30);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Reports sidebar (LEFT) ───────────────────────────────────────── */
|
/* ─── Reports sidebar (LEFT) ───────────────────────────────────────── */
|
||||||
/* Behaviour:
|
/* Behaviour:
|
||||||
- desktop (>= 1024px): FIXED to the left edge, 320px, always visible.
|
- desktop (>= 1024px): FIXED to the left edge, 320px, always visible.
|
||||||
@@ -4396,29 +4319,14 @@ html[data-theme="white"] .talk-unlocker-spinner {
|
|||||||
html[data-theme="white"] .talk-unlocker-title { color: #0f172a; }
|
html[data-theme="white"] .talk-unlocker-title { color: #0f172a; }
|
||||||
html[data-theme="white"] .talk-unlocker-sub { color: rgba(15,23,42,0.65); }
|
html[data-theme="white"] .talk-unlocker-sub { color: rgba(15,23,42,0.65); }
|
||||||
|
|
||||||
/* ── Topbar — flat, no card, center brand, hamburger left, avatar right ── */
|
/* ── Topbar — flat, no card, centered brand and avatar right ── */
|
||||||
html[data-theme="white"] .topbar {
|
html[data-theme="white"] .topbar {
|
||||||
padding: 18px 18px 6px 18px;
|
padding: 18px 18px 6px 18px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
html[data-theme="white"] .menu-btn {
|
|
||||||
color: #0f172a;
|
|
||||||
width: 44px;
|
|
||||||
height: 44px;
|
|
||||||
padding: 0;
|
|
||||||
border-radius: 12px;
|
|
||||||
display: inline-flex !important;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
opacity: 0.9;
|
|
||||||
}
|
|
||||||
html[data-theme="white"] .menu-btn:hover {
|
|
||||||
background: rgba(15, 23, 42, 0.05);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
/* ── Topbar layout (theme=white): 3-column grid ──────────────────────────
|
/* ── Topbar layout (theme=white): 3-column grid ──────────────────────────
|
||||||
Col1 = hamburger 52 px · Col2 = brand centered · Col3 = user avatar 52 px
|
Balanced side gutters keep the brand centered with an optional avatar.
|
||||||
Using a real grid eliminates absolute-position overlap bugs between the
|
Using a real grid eliminates absolute-position overlap bugs between the
|
||||||
avatar circle and the 2-line brand title/subtitle that plagued the previous
|
avatar circle and the 2-line brand title/subtitle that plagued the previous
|
||||||
implementation. Every item lives inside its own grid cell → no collision. */
|
implementation. Every item lives inside its own grid cell → no collision. */
|
||||||
@@ -4437,13 +4345,6 @@ html[data-theme="white"] .topbar {
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
}
|
}
|
||||||
html[data-theme="white"] .menu-btn {
|
|
||||||
grid-column: 1 / 2;
|
|
||||||
grid-row: 1 / 2;
|
|
||||||
justify-self: start;
|
|
||||||
align-self: center;
|
|
||||||
margin: 0 0 0 2px;
|
|
||||||
}
|
|
||||||
/* Brand card → strip glass, becomes a transparent centered title block */
|
/* Brand card → strip glass, becomes a transparent centered title block */
|
||||||
html[data-theme="white"] .brand {
|
html[data-theme="white"] .brand {
|
||||||
grid-column: 2 / 3;
|
grid-column: 2 / 3;
|
||||||
@@ -4502,7 +4403,7 @@ html[data-theme="white"] .ident-user-name {
|
|||||||
width: 42px;
|
width: 42px;
|
||||||
height: 42px;
|
height: 42px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: linear-gradient(135deg, #3b82f6 0%, #60a5fa 100%);
|
background: linear-gradient(135deg, #1d4ed8 0%, #2563eb 100%);
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -4511,6 +4412,10 @@ html[data-theme="white"] .ident-user-name {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
border: 2px solid rgba(59, 130, 246, 0.25);
|
border: 2px solid rgba(59, 130, 246, 0.25);
|
||||||
box-shadow: 0 3px 10px rgba(37, 99, 235, 0.22);
|
box-shadow: 0 3px 10px rgba(37, 99, 235, 0.22);
|
||||||
|
text-shadow: none;
|
||||||
|
}
|
||||||
|
html[data-theme="white"] .ident-user-name::before {
|
||||||
|
content: none;
|
||||||
}
|
}
|
||||||
html[data-theme="white"] .ident-title {
|
html[data-theme="white"] .ident-title {
|
||||||
font-size: clamp(24px, 6vw, 32px);
|
font-size: clamp(24px, 6vw, 32px);
|
||||||
@@ -4682,7 +4587,10 @@ html[data-theme="white"] .white-mic-circle .wmc-icon {
|
|||||||
rules below are written with high specificity and !important on the
|
rules below are written with high specificity and !important on the
|
||||||
display property so they cannot be overridden by accidental resets. */
|
display property so they cannot be overridden by accidental resets. */
|
||||||
html[data-theme="white"] .white-mic-circle.state-idle > svg.wmc-icon.wmc-mic,
|
html[data-theme="white"] .white-mic-circle.state-idle > svg.wmc-icon.wmc-mic,
|
||||||
html[data-theme="white"] .white-mic-circle.state-idle .wmc-icon.wmc-mic {
|
html[data-theme="white"] .white-mic-circle.state-idle .wmc-icon.wmc-mic,
|
||||||
|
html[data-theme="white"] .white-mic-circle.state-listening .wmc-icon.wmc-mic,
|
||||||
|
html[data-theme="white"] .white-mic-circle.state-user-speaking .wmc-icon.wmc-mic,
|
||||||
|
html[data-theme="white"] .white-mic-circle.state-ai-speaking .wmc-icon.wmc-mic {
|
||||||
display: inline-flex !important;
|
display: inline-flex !important;
|
||||||
}
|
}
|
||||||
html[data-theme="white"] .white-mic-circle.state-your-turn > svg.wmc-icon.wmc-mic,
|
html[data-theme="white"] .white-mic-circle.state-your-turn > svg.wmc-icon.wmc-mic,
|
||||||
@@ -4711,13 +4619,13 @@ html[data-theme="white"] .white-mic-circle.state-queued .wmc-icon.wmc-spinner {
|
|||||||
background: transparent !important;
|
background: transparent !important;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
html[data-theme="white"] .white-mic-circle.state-ai-speaking > svg.wmc-icon.wmc-stop,
|
html[data-theme="white"] .white-mic-circle:disabled {
|
||||||
html[data-theme="white"] .white-mic-circle.state-ai-speaking .wmc-icon.wmc-stop {
|
cursor: default;
|
||||||
display: inline-flex !important;
|
}
|
||||||
width: 34px !important;
|
html[data-theme="white"] .white-mic-circle:focus-visible,
|
||||||
height: 34px !important;
|
html[data-theme="white"] .white-stop-btn:focus-visible {
|
||||||
margin: 7px !important;
|
outline: 3px solid #1d4ed8;
|
||||||
border-radius: 4px !important;
|
outline-offset: 4px;
|
||||||
}
|
}
|
||||||
/* Subtle ring pulse while listening — uses box-shadow because the ring
|
/* Subtle ring pulse while listening — uses box-shadow because the ring
|
||||||
itself is a hard 4-px painted border and transform:scale() would also
|
itself is a hard 4-px painted border and transform:scale() would also
|
||||||
@@ -4789,6 +4697,14 @@ html[data-theme="white"] .white-mic-caption {
|
|||||||
}
|
}
|
||||||
html[data-theme="white"] .white-mic-caption.muted { color: #64748b; }
|
html[data-theme="white"] .white-mic-caption.muted { color: #64748b; }
|
||||||
html[data-theme="white"] .white-mic-caption.error { color: #dc2626; }
|
html[data-theme="white"] .white-mic-caption.error { color: #dc2626; }
|
||||||
|
html[data-theme="white"] .white-mic-subcaption {
|
||||||
|
margin: 0;
|
||||||
|
max-width: 440px;
|
||||||
|
color: #475569;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── "Detener" pill ─────────────────────────────────────────────────── */
|
/* ── "Detener" pill ─────────────────────────────────────────────────── */
|
||||||
html[data-theme="white"] .white-stop-btn {
|
html[data-theme="white"] .white-stop-btn {
|
||||||
@@ -4911,32 +4827,38 @@ html[data-theme="white"] .chat-panel-header {
|
|||||||
border-bottom-width: 1px !important;
|
border-bottom-width: 1px !important;
|
||||||
text-decoration: none !important;
|
text-decoration: none !important;
|
||||||
}
|
}
|
||||||
/* Online status pill — green dot + text */
|
/* Session status uses connection events rather than a permanent online badge. */
|
||||||
html[data-theme="white"] .chat-status-pill {
|
html[data-theme="white"] .chat-status-pill {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 7px;
|
gap: 7px;
|
||||||
padding: 5px 11px 5px 8px;
|
padding: 5px 11px 5px 8px;
|
||||||
background: rgba(22, 163, 74, 0.08);
|
background: #f1f5f9;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
color: #16a34a;
|
color: #475569;
|
||||||
font-size: 12.5px;
|
font-size: 12.5px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
flex: none;
|
flex: none;
|
||||||
}
|
}
|
||||||
|
html[data-theme="white"] .chat-status-pill[data-status="online"] {
|
||||||
|
background: #ecfdf5;
|
||||||
|
color: #15803d;
|
||||||
|
}
|
||||||
|
html[data-theme="white"] .chat-status-pill[data-status="waiting"] {
|
||||||
|
background: #fffbeb;
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
html[data-theme="white"] .chat-status-pill[data-status="error"] {
|
||||||
|
background: #fef2f2;
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
html[data-theme="white"] .chat-status-dot {
|
html[data-theme="white"] .chat-status-dot {
|
||||||
width: 9px;
|
width: 9px;
|
||||||
height: 9px;
|
height: 9px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: #16a34a;
|
background: currentColor;
|
||||||
box-shadow: 0 0 0 3px rgba(22, 163, 74, 0.14);
|
|
||||||
display: block;
|
display: block;
|
||||||
flex: none;
|
flex: none;
|
||||||
animation: chatStatusDotPulse 2s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
@keyframes chatStatusDotPulse {
|
|
||||||
0%, 100% { box-shadow: 0 0 0 3px rgba(22, 163, 74, 0.14); }
|
|
||||||
50% { box-shadow: 0 0 0 5px rgba(22, 163, 74, 0.08); }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Chat header actions row: online status pill + conversation export button.
|
/* ── Chat header actions row: online status pill + conversation export button.
|
||||||
@@ -5297,95 +5219,6 @@ html[data-theme="white"] .chat-history .hist-msg.tool .hist-body::before {
|
|||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Chat form (input footer bar): clip · rounded input · blue send ────── */
|
|
||||||
html[data-theme="white"] .chat-form {
|
|
||||||
display: flex !important;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 6px 6px 6px 6px;
|
|
||||||
background: #ffffff;
|
|
||||||
border: 1px solid rgba(15, 23, 42, 0.08);
|
|
||||||
border-radius: 999px;
|
|
||||||
margin: 4px 0 0;
|
|
||||||
box-shadow: var(--shadow-soft);
|
|
||||||
/* Keep the input row pinned at the bottom of the chat card header; never
|
|
||||||
let the history (flex:1) squeeze the form out of the panel. */
|
|
||||||
flex: none !important;
|
|
||||||
}
|
|
||||||
html[data-theme="white"] .chat-form[hidden],
|
|
||||||
html[data-theme="white"] .chat-form[style*="display:none"] {
|
|
||||||
display: flex !important;
|
|
||||||
}
|
|
||||||
html[data-theme="white"] .chat-attach-btn {
|
|
||||||
flex: none;
|
|
||||||
width: 44px;
|
|
||||||
height: 44px;
|
|
||||||
padding: 0;
|
|
||||||
border-radius: 999px;
|
|
||||||
color: #64748b;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
html[data-theme="white"] .chat-attach-btn:hover {
|
|
||||||
color: #2563eb;
|
|
||||||
background: rgba(37, 99, 235, 0.08);
|
|
||||||
}
|
|
||||||
html[data-theme="white"] .chat-input {
|
|
||||||
flex: 1 1 auto;
|
|
||||||
min-width: 0;
|
|
||||||
height: 44px;
|
|
||||||
padding: 0 10px 0 6px;
|
|
||||||
margin: 0;
|
|
||||||
background: transparent !important;
|
|
||||||
border: none !important;
|
|
||||||
color: #0f172a;
|
|
||||||
font-size: clamp(14px, 3.2vw, 16px);
|
|
||||||
border-radius: 0;
|
|
||||||
box-shadow: none !important;
|
|
||||||
outline: none !important;
|
|
||||||
}
|
|
||||||
html[data-theme="white"] .chat-input::placeholder {
|
|
||||||
color: rgba(15, 23, 42, 0.48);
|
|
||||||
}
|
|
||||||
html[data-theme="white"] .chat-input:focus {
|
|
||||||
border-color: transparent;
|
|
||||||
box-shadow: none;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
/* Hide original square send button in white theme */
|
|
||||||
html[data-theme="white"] .chat-form .chat-send-btn {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
/* New big round blue send button with paper airplane */
|
|
||||||
html[data-theme="white"] .chat-send-btn-round {
|
|
||||||
flex: none;
|
|
||||||
width: 48px;
|
|
||||||
height: 48px;
|
|
||||||
padding: 0;
|
|
||||||
border: none;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: linear-gradient(135deg, #2563eb, #3b82f6);
|
|
||||||
color: #ffffff;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
cursor: pointer;
|
|
||||||
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.28), inset 0 1px 0 rgba(255, 255, 255, 0.3);
|
|
||||||
transition: transform 140ms ease, filter 140ms ease;
|
|
||||||
}
|
|
||||||
html[data-theme="white"] .chat-send-btn-round:hover {
|
|
||||||
transform: translateY(-1px);
|
|
||||||
filter: brightness(1.05);
|
|
||||||
}
|
|
||||||
html[data-theme="white"] .chat-send-btn-round svg {
|
|
||||||
width: 22px;
|
|
||||||
height: 22px;
|
|
||||||
stroke-width: 2.2;
|
|
||||||
fill: #ffffff;
|
|
||||||
stroke: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── App footer: thin white bar, just a subtle separator + brand line ─── */
|
/* ── App footer: thin white bar, just a subtle separator + brand line ─── */
|
||||||
html[data-theme="white"] .app-footer {
|
html[data-theme="white"] .app-footer {
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
@@ -5512,7 +5345,6 @@ html[data-theme="white"] .switch input:checked + .switch-track {
|
|||||||
html[data-theme="white"] .white-mic-circle.state-processing .wmc-spinner,
|
html[data-theme="white"] .white-mic-circle.state-processing .wmc-spinner,
|
||||||
html[data-theme="white"] .white-mic-circle.state-connecting .wmc-spinner,
|
html[data-theme="white"] .white-mic-circle.state-connecting .wmc-spinner,
|
||||||
html[data-theme="white"] .white-mic-circle.state-queued .wmc-spinner { width: 34px; height: 34px; }
|
html[data-theme="white"] .white-mic-circle.state-queued .wmc-spinner { width: 34px; height: 34px; }
|
||||||
html[data-theme="white"] .white-mic-circle.state-ai-speaking .wmc-stop { width: 30px; height: 30px; }
|
|
||||||
html[data-theme="white"] .white-waveform .w-bar { height: calc(var(--bar-h, 0.5) * 56px); }
|
html[data-theme="white"] .white-waveform .w-bar { height: calc(var(--bar-h, 0.5) * 56px); }
|
||||||
html[data-theme="white"] .white-waveform { height: 56px; }
|
html[data-theme="white"] .white-waveform { height: 56px; }
|
||||||
html[data-theme="white"] .white-waveform-left { left: calc(50% - 49px - 76px); top: calc(14px + 49px); }
|
html[data-theme="white"] .white-waveform-left { left: calc(50% - 49px - 76px); top: calc(14px + 49px); }
|
||||||
|
|||||||
Reference in New Issue
Block a user