refactor(ui): brand-accent dictation mode with capture bar and full-width wave

This commit is contained in:
vyctorbrzezowski
2026-08-21 23:37:34 -03:00
parent 8d2d9d15d1
commit 462667468d
6 changed files with 133 additions and 70 deletions
+10
View File
@@ -55,3 +55,13 @@ Lane: composer selects (worktree `composer-bench`). Bench files intocados.
- Causa da diferença apontada em review: `wa-dropdown-item::part(icon)` tem 24px intrínsecos (SVG 16px centrado → gap visual maior) e labels 12px/400 `--text` vs 13px/600 `--text-strong` do permission.
- Fix (`layout.css`): `::part(icon)` forçado a 16px nos attach/capability items; `::part(label)` = 13px/600 `--text-strong`; notes voltam a 11px/400 muted; mic device labels e model option titles alinhados ao mesmo 13px/600.
- Verificado visualmente (dark) em :5230 — attach/capability e permission agora com a mesma tipografia e mesmo trilho de ícone.
## Voice mode (dictation) refactor
- Referências: barra estilo "Listening" (Cancel ✕ à esquerda, waveform larga, ✓ à direita) + luz circulando a borda.
- Cor: dictation migrou de `--danger` para `--accent` (red brand) — borda orbitante (`chat-dictation-edge-orbit`, mantida), tint da borda, barras da wave e ✓ confirm (accent sólido, hover `--accent-hover`).
- Wave: `openclaw-microphone-activity` ganhou atributo `bars` (perfil senoidal simétrico); default 7 intacto (e2e do Talk). Dictation usa 36 barras em strip full-width acima do editor; partial transcript centralizado e ellipsized abaixo.
- Barra de captura: footer vira Cancel (✕ + "Cancel", pill neutra — mesmo elemento do mic, preservando pointer capture do hold) · label central `role="status"` (Starting…/Recording m:ss/Finishing…) · ✓ accent à direita (insere). Elementos inúteis já ocultos (lead/meta/controls/mic-picker), agora com actions esticada em `space-between`.
- i18n: `+discardDictation` ("Cancel dictation"), `-dictationReleaseToInsert` (sem usos); baseline+verify ok.
- Limpeza: CSS morto `chat-send-btn__dictation-time` e `agent-chat__dictation-copy` removidos.
- Verificação: DOM do estado dictating injetado no bench (:5230) — screenshots dark/light inspecionados (`/tmp/herdr/dict-*.png`); vitest dictation/composer suites: mesmas 5 falhas pré-existentes do checkpoint, 72 pass; tsgo:ui sem erros nos arquivos tocados; stylelint só com os 3 erros pré-existentes.
+1 -1
View File
@@ -5978,7 +5978,7 @@ export const en: TranslationMap = {
dictationProviderUnavailable:
"No transcription provider is configured for dictation. Choose one in Settings to dictate.",
dictationRecording: "Recording {elapsed}",
dictationReleaseToInsert: "Release to insert dictation",
discardDictation: "Cancel dictation",
realtimeTalkRequiresMicrophone: "Realtime voice input requires browser microphone access.",
selectedMicrophoneUnavailable:
"The selected microphone is unavailable. Choose another input or System default.",
@@ -232,7 +232,7 @@ export function renderComposerVoiceButton(props: ComposerVoiceButtonProps) {
const label = finalizing
? t("chat.composer.dictationFinalizing")
: active
? t("chat.composer.dictationReleaseToInsert")
? t("chat.composer.discardDictation")
: (props.idleLabel ?? t("chat.composer.startVoiceInput"));
const tooltip =
props.dictation && !(active || finalizing) ? t("chat.composer.voiceGestureHint") : label;
@@ -266,7 +266,12 @@ export function renderComposerVoiceButton(props: ComposerVoiceButtonProps) {
${finalizing
? icons.loader
: active
? icons.stop
? html`
${icons.x}
<span class="chat-send-btn__dictation-cancel-label"
>${t("common.cancel")}</span
>
`
: html`
${icons.mic}
<span class="agent-chat__control-label">${label}</span>
@@ -359,6 +364,19 @@ export function renderChatPrimaryActions(props: ChatRunControlsProps) {
);
const dictationConfirmAction = props.dictation?.active
? html`
<span
class="agent-chat__dictation-label"
role="status"
aria-live="polite"
aria-atomic="true"
>${props.dictation.finalizing
? t("chat.composer.dictationFinalizing")
: props.dictation.connecting
? t("chat.composer.dictationConnecting")
: t("chat.composer.dictationRecording", {
elapsed: props.dictation.elapsed,
})}</span
>
<openclaw-tooltip .content=${t("chat.composer.insertDictation")}>
<button
class="chat-send-btn chat-send-btn--dictation-confirm"
@@ -356,32 +356,19 @@ export function renderChatComposerView(context: ChatComposerViewContext) {
? html`
<div
class=${`agent-chat__dictation-status${dictation.finalizing ? " agent-chat__dictation-status--finalizing" : ""}`}
role="status"
aria-live="polite"
aria-atomic="true"
>
<span class="agent-chat__dictation-wave">
${renderMicrophoneActivity({
status: dictation.connecting ? "connecting" : "listening",
inputLevel: dictation.inputLevel,
bars: 36,
})}
</span>
<span class="agent-chat__dictation-copy">
<span class="agent-chat__dictation-label"
>${dictation.finalizing
? t("chat.composer.dictationFinalizing")
: dictation.connecting
? t("chat.composer.dictationConnecting")
: t("chat.composer.dictationRecording", {
elapsed: dictation.elapsed,
})}</span
>
${dictation.partial
? html`<span class="agent-chat__dictation-partial"
>${dictation.partial}</span
>`
: nothing}
</span>
${dictation.partial
? html`<span class="agent-chat__dictation-partial"
>${dictation.partial}</span
>`
: nothing}
</div>
`
: nothing}
@@ -1,4 +1,5 @@
import { html, nothing, type TemplateResult } from "lit";
import { ifDefined } from "lit/directives/if-defined.js";
import { icons } from "../../../components/icons.ts";
import "../../../components/tooltip.ts";
import { t } from "../../../i18n/index.ts";
@@ -9,9 +10,23 @@ const BAR_GAINS = [0.38, 0.62, 0.84, 1, 0.84, 0.62, 0.38];
const MICROPHONE_ACTIVITY_TAG = "openclaw-microphone-activity";
const EMPTY_LEVEL_SIGNAL = new RealtimeTalkLevelSignal();
// Wider meters (the dictation strip) ask for more bars via the `bars`
// attribute; the default 7-bar profile stays byte-identical for the talk
// button, whose bar count the talk e2e suite observes.
function activityBarGains(count: number): number[] {
if (count === BAR_GAINS.length) {
return BAR_GAINS;
}
return Array.from(
{ length: count },
(_, index) => 0.35 + 0.65 * Math.sin(Math.PI * ((index + 0.5) / count)),
);
}
class MicrophoneActivityElement extends HTMLElement {
private levelSignal: RealtimeTalkLevelSignal | undefined;
private unsubscribe: (() => void) | null = null;
private gains: number[] = BAR_GAINS;
set signal(signal: RealtimeTalkLevelSignal | undefined) {
if (signal === this.levelSignal) {
@@ -39,7 +54,11 @@ class MicrophoneActivityElement extends HTMLElement {
private ensureBars(): void {
if (!this.firstElementChild) {
for (const [index] of BAR_GAINS.entries()) {
const requested = Number(this.getAttribute("bars"));
this.gains = activityBarGains(
Number.isInteger(requested) && requested > 0 ? requested : BAR_GAINS.length,
);
for (const [index] of this.gains.entries()) {
const bar = document.createElement("span");
bar.className = "agent-chat__voice-activity-bar";
bar.style.setProperty("--talk-bar-delay", `${index * -70}ms`);
@@ -56,7 +75,7 @@ class MicrophoneActivityElement extends HTMLElement {
private renderLevel(level: number): void {
this.dataset.level = String(level);
for (const [index, bar] of [...this.children].entries()) {
const gain = BAR_GAINS[index] ?? 1;
const gain = this.gains[index] ?? 1;
(bar as HTMLElement).style.setProperty(
"--talk-bar-scale",
String(0.18 + level * gain * 0.82),
@@ -95,6 +114,7 @@ export function voiceStatusLabel(
type MicrophoneActivityProps = {
status?: RealtimeTalkStatus;
inputLevel?: RealtimeTalkLevelSignal;
bars?: number;
};
// Class names and data attributes are asserted by the talk e2e suite; the
@@ -106,6 +126,7 @@ export function renderMicrophoneActivity(props: MicrophoneActivityProps): Templa
class="agent-chat__voice-activity"
data-status=${activeStatus(props.status)}
data-source="microphone"
bars=${ifDefined(props.bars)}
aria-hidden="true"
.signal=${props.inputLevel ?? EMPTY_LEVEL_SIGNAL}
>
+73 -46
View File
@@ -3041,12 +3041,13 @@ button.chat-pr__diff {
}
/* Dictation is a temporary mode of the writing surface, not another card in
it. A narrow moving edge signals live capture while the editor and its
transcript stay stable underneath. */
it. A narrow moving edge in the brand accent signals live capture while the
editor and its transcript stay stable underneath. Accent, not danger, on
purpose: capture is the product's signature action, not a destructive one. */
.agent-chat__input--dictating,
.agent-chat__input--dictating:focus-within {
isolation: isolate;
border-color: color-mix(in srgb, var(--danger) 24%, var(--chat-composer-hairline));
border-color: color-mix(in srgb, var(--accent) 24%, var(--chat-composer-hairline));
box-shadow: 0 4px 18px rgb(0 0 0 / 6%);
}
@@ -3060,9 +3061,9 @@ button.chat-pr__diff {
background: conic-gradient(
from var(--chat-dictation-edge-angle),
transparent 0deg 220deg,
color-mix(in srgb, var(--danger) 18%, transparent) 256deg,
color-mix(in srgb, var(--danger) 88%, white) 294deg,
color-mix(in srgb, var(--danger) 30%, transparent) 326deg,
color-mix(in srgb, var(--accent) 18%, transparent) 256deg,
color-mix(in srgb, var(--accent) 88%, white) 294deg,
color-mix(in srgb, var(--accent) 30%, transparent) 326deg,
transparent 360deg
);
-webkit-mask:
@@ -3497,9 +3498,9 @@ button.chat-pr__diff {
padding: 12px 14px 0;
}
/* Capture mode pares the footer back to the two decisions that matter: stop
recording or accept the transcript. Draft content and attachments remain in
place so entering the mode never discards operator context. */
/* Capture mode pares the footer back to the two decisions that matter: cancel
the recording or accept the transcript. Draft content and attachments remain
in place so entering the mode never discards operator context. */
.agent-chat__input--dictating .agent-chat__composer-lead,
.agent-chat__input--dictating .agent-chat__composer-meta,
.agent-chat__input--dictating .agent-chat__composer-controls,
@@ -3515,6 +3516,17 @@ button.chat-pr__diff {
grid-column: 1;
}
/* Capture bar: cancel pill on the leading edge, live status centered, accept
on the trailing edge — one row, mirroring the reference voice UIs. */
.agent-chat__input--dictating .agent-chat__composer-trail,
.agent-chat__input--dictating .agent-chat__composer-actions {
width: 100%;
}
.agent-chat__input--dictating .agent-chat__composer-actions {
justify-content: space-between;
}
/* Keep settings, split view, and run status together in the footer's middle
track. The track is sized to content, so this group shrinks before it ever
pushes mic and send off the right edge. */
@@ -4939,14 +4951,19 @@ button.chat-pr__diff {
user-select: none;
}
/* Cancel pill while capturing. Same element as the idle mic button — swapping
it would release pointer capture mid-hold — so the mode only restyles it. */
.chat-send-btn.chat-send-btn--dictating {
width: var(--chat-composer-send-size);
display: inline-flex;
align-items: center;
gap: 6px;
width: auto;
min-width: var(--chat-composer-send-size);
height: var(--chat-composer-send-size);
padding: 5px;
padding: 0 12px;
border-radius: var(--radius-full);
color: var(--danger);
background: color-mix(in srgb, var(--danger) 14%, transparent);
color: var(--text-strong);
background: color-mix(in srgb, var(--text) 10%, transparent);
box-shadow: none;
touch-action: none;
-webkit-touch-callout: none;
@@ -4954,81 +4971,91 @@ button.chat-pr__diff {
}
.agent-chat__input .chat-send-btn.chat-send-btn--dictating:hover:not(:disabled) {
background: color-mix(in srgb, var(--danger) 20%, transparent);
color: var(--danger);
background: color-mix(in srgb, var(--text) 16%, transparent);
color: var(--text-strong);
}
.chat-send-btn--dictating > svg {
width: 14px;
height: 14px;
fill: currentColor;
stroke: none;
fill: none;
stroke: currentColor;
stroke-width: 2px;
}
.chat-send-btn__dictation-cancel-label {
font-size: 13px;
font-weight: 600;
line-height: 1;
}
.chat-send-btn--dictating .agent-chat__voice-activity-bar {
background: color-mix(in srgb, var(--danger) 88%, var(--text));
box-shadow: 0 0 5px color-mix(in srgb, var(--danger) 30%, transparent);
background: color-mix(in srgb, var(--accent) 88%, var(--text));
box-shadow: 0 0 5px color-mix(in srgb, var(--accent) 30%, transparent);
}
.chat-send-btn__dictation-time {
min-width: 3ch;
font-size: 11px;
font-variant-numeric: tabular-nums;
/* Accept keeps the send button's station and the brand accent: committing the
transcript is this mode's primary action. */
.agent-chat__input .chat-send-btn--dictation-confirm {
background: var(--accent);
color: var(--accent-foreground);
}
.agent-chat__input .chat-send-btn--dictation-confirm:hover:not(:disabled) {
background: var(--accent-hover);
}
.agent-chat__dictation-status {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
min-width: 0;
align-items: center;
gap: 8px;
justify-items: center;
gap: 6px;
width: 100%;
color: var(--danger);
color: var(--accent);
font-size: 12px;
}
/* Full-width strip: the level meter is the room's main signal while capturing,
so it spans the editor instead of hiding in a pill. */
.agent-chat__dictation-wave {
display: inline-flex;
display: flex;
align-items: center;
justify-content: center;
min-width: 58px;
min-height: 24px;
padding: 0 10px;
border-radius: var(--radius-full);
background: color-mix(in srgb, var(--danger) 11%, transparent);
width: 100%;
min-height: 28px;
}
.agent-chat__dictation-wave .agent-chat__voice-activity {
width: 100%;
justify-content: space-between;
}
.agent-chat__dictation-wave .agent-chat__voice-activity-bar {
background: color-mix(in srgb, var(--danger) 88%, var(--text));
box-shadow: 0 0 5px color-mix(in srgb, var(--danger) 24%, transparent);
}
.agent-chat__dictation-copy {
display: flex;
min-width: 0;
align-items: baseline;
gap: 8px;
background: color-mix(in srgb, var(--accent) 88%, var(--text));
box-shadow: 0 0 5px color-mix(in srgb, var(--accent) 24%, transparent);
}
.agent-chat__dictation-status--finalizing {
color: var(--muted);
}
.agent-chat__dictation-status--finalizing .agent-chat__dictation-wave {
background: var(--chat-composer-hover);
}
.agent-chat__dictation-status--finalizing .agent-chat__voice-activity-bar {
background: var(--muted);
box-shadow: none;
}
.agent-chat__dictation-label {
flex: 0 0 auto;
min-width: 0;
flex: 1 1 auto;
color: var(--muted);
font-size: 13px;
font-variant-numeric: tabular-nums;
text-align: center;
}
.agent-chat__dictation-partial {
max-width: 100%;
min-width: 0;
overflow: hidden;
color: var(--muted);