feat: redesign mic button UI, restructure README (RU first) (#9)

## UI Changes

- Replace emoji icons (🎤) with clean SVG (Material Design mic, stop,
spinner)
- Move button to **top-right corner** of composer (both PC and mobile)
- Separate timer pill element — no more cramped/overlapping timer inside
button
- Pulsing red dot + monospace timer during recording
- Spinner animation during transcription
- Ensure composer is `position: relative` for absolute positioning

## README

- Russian section first, then English
- Minimalistic: removed Development/CI-CD sections
- Prominent one-click install link
- Kept: compatibility table, setup, usage, auto-update, settings

## Test Results

-  44 tests passed
-  100% coverage
-  Lint, Typecheck, Knip — clean
-  Build: 20.13 KB

Co-authored-by: opencode-agent <agent@slaid098.dev>
This commit is contained in:
Sergey 2026-07-08 09:08:12 +03:00 committed by GitHub
parent a2cf35b509
commit 33f2f48ac9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 167 additions and 256 deletions

286
README.md
View file

@ -1,268 +1,110 @@
# OpenCode Voice Dictation / Голосовая диктовка для OpenCode # OpenCode Voice Dictation / Голосовая диктовка для OpenCode
[![CI](https://github.com/slaid098/opencode-voice-dictation/actions/workflows/ci.yml/badge.svg)](https://github.com/slaid098/opencode-voice-dictation/actions/workflows/ci.yml) [![CI](https://github.com/slaid098/opencode-voice-dictation/actions/workflows/ci.yml/badge.svg)](https://github.com/slaid098/opencode-voice-dictation/actions/workflows/ci.yml)
[![Release](https://github.com/slaid098/opencode-voice-dictation/actions/workflows/release.yml/badge.svg)](https://github.com/slaid098/opencode-voice-dictation/actions/workflows/release.yml)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
Voice dictation for [OpenCode](https://opencode.ai) web interface using OpenAI Whisper via Groq API. Works on both desktop and mobile browsers through a Tampermonkey/Violentmonkey userscript.
Голосовая диктовка для веб-интерфейса [OpenCode](https://opencode.ai) с использованием OpenAI Whisper через Groq API. Работает как на десктопных, так и на мобильных браузерах через юзерскрипт Tampermonkey/Violentmonkey.
---
## English
### Features
- **Whisper large-v3** transcription via Groq API (same quality as Cursor)
- **Tap-to-toggle** recording (tap to start, tap to stop)
- **Noise suppression** + echo cancellation (great for outdoor use)
- **Recording timer** (MM:SS display)
- **Ctrl+Space** keyboard shortcut on desktop
- **Custom Whisper prompt** for software development context
- **Auto-detect language** (Russian + English technical terms)
- **Auto-submit** option (configurable)
- **No backend needed** — API calls go directly from browser via `GM_xmlhttpRequest`
### Requirements
| Component | Requirement |
|-----------|-------------|
| OpenCode | Web interface running (`opencode web`) |
| Groq API key | Free at [console.groq.com/keys](https://console.groq.com/keys) |
| PC Browser | Vivaldi / Chrome / Firefox + Violentmonkey or Tampermonkey |
| Mobile Browser | Firefox for Android + Tampermonkey |
### Compatibility
| Platform | Browser | Userscript Manager | Status |
|----------|---------|-------------------|--------|
| Desktop (Linux/macOS/Windows) | Vivaldi | Violentmonkey | ✅ Supported |
| Desktop | Chrome | Tampermonkey | ✅ Supported |
| Desktop | Firefox | Tampermonkey | ✅ Supported |
| Android | Firefox | Tampermonkey | ✅ Supported |
| Android | Vivaldi | — | ❌ No extension support |
| Android | Chrome | — | ❌ No extension support |
### Install
> **One-click install**: Open this link in your browser with Violentmonkey/Tampermonkey installed:
>
> **[Install Script](https://raw.githubusercontent.com/slaid098/opencode-voice-dictation/dist/opencode-voice-dictation.user.js)**
>
> Your userscript manager will intercept the download and show an installation dialog.
#### Desktop (Vivaldi + Violentmonkey)
1. Install [Violentmonkey](https://violentmonkey.github.io/) extension in Vivaldi
2. Click the [Install Script](https://raw.githubusercontent.com/slaid098/opencode-voice-dictation/dist/opencode-voice-dictation.user.js) link
3. Confirm installation in the Violentmonkey dialog
#### Android (Firefox + Tampermonkey)
1. Install [Firefox for Android](https://play.google.com/store/apps/details?id=org.mozilla.firefox) from Play Store
2. Install [Tampermonkey](https://addons.mozilla.org/en-US/android/addon/tampermonkey/) from Firefox Add-ons
3. In Firefox, open the [Install Script](https://raw.githubusercontent.com/slaid098/opencode-voice-dictation/dist/opencode-voice-dictation.user.js) link
4. Confirm installation in the Tampermonkey dialog
#### Manual install (from GitHub Releases)
1. Go to [Releases](https://github.com/slaid098/opencode-voice-dictation/releases)
2. Download `opencode-voice-dictation.user.js` from the latest release
3. Open Violentmonkey/Tampermonkey dashboard → **+** → paste the file contents → Save
### Setup
1. Get a free Groq API key at [console.groq.com/keys](https://console.groq.com/keys)
2. Open any OpenCode web page
3. Open Violentmonkey/Tampermonkey menu → **"Set Groq API Key"**
4. Paste your key (`gsk_...`)
### Auto-Update
The script updates automatically. No action needed.
- `@updateURL` checks for new versions periodically (every 24h in Tampermonkey, every 7 days in Violentmonkey)
- Only a tiny `.meta.js` (~1KB) is downloaded to check the version
- If a new version is found, the full `.user.js` (~18KB) is downloaded and installed
- Updates are published automatically by CI when code is pushed to `main`
### Usage
1. Open OpenCode web in your browser
2. A **microphone button** appears next to the chat input
3. **Tap** the button to start recording (red pulse + timer)
4. **Tap** again to stop — audio is transcribed and inserted into the input
5. (Desktop) Press **Ctrl+Space** as an alternative to clicking
### Configuration
All settings available via the Violentmonkey/Tampermonkey menu:
| Menu Item | Description |
|-----------|-------------|
| Set Groq API Key | Enter your `gsk_...` key |
| Toggle Auto-Submit | Auto-send message after transcription |
| Set Whisper Model | `whisper-large-v3` (default) or `whisper-large-v3-turbo` (faster) |
| Set Language | Language code (e.g., `ru`, `en`) or empty for auto-detect |
| Set Whisper Prompt | Context prompt for transcription accuracy |
--- ---
## Русский ## Русский
### Возможности Голосовая диктовка для [OpenCode](https://opencode.ai) web через Whisper (Groq API). Качество как в Cursor. Работает на ПК и телефоне.
- **Whisper large-v3** транскрипция через Groq API (качество как в Cursor) ### Установка
- **Tap-to-toggle** запись (тапнул — начал, тапнул — стоп)
- **Шумоподавление** + эхоподавление (для записи на улице)
- **Таймер записи** (формат MM:SS)
- **Ctrl+Space** горячая клавиша на ПК
- **Кастомный Whisper prompt** для контекста разработки
- **Авто-определение языка** (русский + английские технические термины)
- **Авто-отправка** опционально (настраивается)
- **Без бэкенда** — запросы идут напрямую из браузера через `GM_xmlhttpRequest`
### Требования > **[Установить скрипт](https://raw.githubusercontent.com/slaid098/opencode-voice-dictation/dist/opencode-voice-dictation.user.js)** — открой ссылку в браузере с Violentmonkey/Tampermonkey и подтверди установку.
| Компонент | Требование | Или скачай с [Releases](https://github.com/slaid098/opencode-voice-dictation/releases) и импортируй вручную.
|-----------|------------|
| OpenCode | Запущенный веб-интерфейс (`opencode web`) |
| Groq API ключ | Бесплатно на [console.groq.com/keys](https://console.groq.com/keys) |
| Браузер ПК | Vivaldi / Chrome / Firefox + Violentmonkey или Tampermonkey |
| Браузер телефон | Firefox для Android + Tampermonkey |
### Совместимость ### Совместимость
| Платформа | Браузер | Менеджер скриптов | Статус | | Платформа | Браузер | Менеджер скриптов | Статус |
|-----------|---------|-------------------|--------| |-----------|---------|-------------------|--------|
| Десктоп (Linux/macOS/Windows) | Vivaldi | Violentmonkey | ✅ Поддерживается | | ПК | Vivaldi | Violentmonkey | ✅ |
| Десктоп | Chrome | Tampermonkey | ✅ Поддерживается | | ПК | Chrome | Tampermonkey | ✅ |
| Десктоп | Firefox | Tampermonkey | ✅ Поддерживается | | ПК | Firefox | Tampermonkey | ✅ |
| Android | Firefox | Tampermonkey | ✅ Поддерживается | | Android | Firefox | Tampermonkey | ✅ |
| Android | Vivaldi | — | ❌ Нет поддержки расширений | | Android | Vivaldi | — | ❌ Нет расширений |
| Android | Chrome | — | ❌ Нет поддержки расширений | | Android | Chrome | — | ❌ Нет расширений |
### Установка
> **Установка в один клик**: Откройте эту ссылку в браузере с установленным Violentmonkey/Tampermonkey:
>
> **[Установить скрипт](https://raw.githubusercontent.com/slaid098/opencode-voice-dictation/dist/opencode-voice-dictation.user.js)**
>
> Менеджер скриптов перехватит загрузку и покажет диалог установки.
#### ПК (Vivaldi + Violentmonkey)
1. Установите расширение [Violentmonkey](https://violentmonkey.github.io/) в Vivaldi
2. Нажмите на ссылку [Установить скрипт](https://raw.githubusercontent.com/slaid098/opencode-voice-dictation/dist/opencode-voice-dictation.user.js)
3. Подтвердите установку в диалоге Violentmonkey
#### Android (Firefox + Tampermonkey)
1. Установите [Firefox для Android](https://play.google.com/store/apps/details?id=org.mozilla.firefox) из Play Store
2. Установите [Tampermonkey](https://addons.mozilla.org/ru/android/addon/tampermonkey/) из дополнений Firefox
3. В Firefox откройте ссылку [Установить скрипт](https://raw.githubusercontent.com/slaid098/opencode-voice-dictation/dist/opencode-voice-dictation.user.js)
4. Подтвердите установку в диалоге Tampermonkey
#### Ручная установка (из GitHub Releases)
1. Перейдите на страницу [Releases](https://github.com/slaid098/opencode-voice-dictation/releases)
2. Скачайте `opencode-voice-dictation.user.js` из последнего релиза
3. Откройте панель Violentmonkey/Tampermonkey → **+** → вставьте содержимое файла → Сохранить
### Настройка ### Настройка
1. Получите бесплатный ключ Groq на [console.groq.com/keys](https://console.groq.com/keys) 1. Получи бесплатный ключ на [console.groq.com/keys](https://console.groq.com/keys)
2. Откройте любую страницу OpenCode web 2. Открой OpenCode web
3. Откройте меню Violentmonkey/Tampermonkey → **"Set Groq API Key"** 3. Меню Violentmonkey/Tampermonkey → **Set Groq API Key** → вставь `gsk_...`
4. Вставьте ваш ключ (`gsk_...`)
### Автообновление
Скрипт обновляется автоматически. Ничего делать не нужно.
- `@updateURL` периодически проверяет новые версии (каждые 24ч в Tampermonkey, каждые 7 дней в Violentmonkey)
- Скачивается только маленький `.meta.js` (~1KB) для проверки версии
- Если найдена новая версия — скачивается полный `.user.js` (~18KB) и устанавливается
- Обновления публикуются автоматически CI при пуше в `main`
### Использование ### Использование
1. Откройте OpenCode web в браузере 1. Нажми кнопку микрофона в правом верхнем углу поля ввода
2. Рядом с полем ввода появится **кнопка микрофона** 2. Говори (виден таймер записи)
3. **Тапните** кнопку для начала записи (красная пульсация + таймер) 3. Нажми снова — текст появится в поле ввода
4. **Тапните** снова для остановки — аудио транскрибируется и вставляется в поле ввода 4. На ПК: **Ctrl+Space** — горячая клавиша
5. (На ПК) Нажмите **Ctrl+Space** как альтернативу клику
### Автообновление
Скрипт обновляется сам. При пуше в `main` CI собирает новую версию и публикует в ветку `dist`. Пользователи получают обновление автоматически.
### Настройки ### Настройки
Все параметры доступны через меню Violentmonkey/Tampermonkey:
| Пункт меню | Описание | | Пункт меню | Описание |
|------------|----------| |------------|----------|
| Set Groq API Key | Ввести ключ `gsk_...` | | Set Groq API Key | Ключ `gsk_...` |
| Toggle Auto-Submit | Автоматическая отправка после транскрипции | | Toggle Auto-Submit | Отправлять после транскрипции |
| Set Whisper Model | `whisper-large-v3` (по умолчанию) или `whisper-large-v3-turbo` (быстрее) | | Set Whisper Model | `whisper-large-v3` или `whisper-large-v3-turbo` |
| Set Language | Код языка (например, `ru`, `en`) или пусто для авто-определения | | Set Language | `ru`, `en` или пусто (авто) |
| Set Whisper Prompt | Контекстный промпт для точности транскрипции | | Set Whisper Prompt | Контекст для точности |
--- ---
## Development / Разработка ## English
### Prerequisites Voice dictation for [OpenCode](https://opencode.ai) web via Whisper (Groq API). Cursor-quality transcription. Works on desktop and mobile.
- Node.js 22+ ### Install
- npm
> **[Install Script](https://raw.githubusercontent.com/slaid098/opencode-voice-dictation/dist/opencode-voice-dictation.user.js)** — open this link in a browser with Violentmonkey/Tampermonkey and confirm installation.
Or download from [Releases](https://github.com/slaid098/opencode-voice-dictation/releases) and import manually.
### Compatibility
| Platform | Browser | Userscript Manager | Status |
|----------|---------|-------------------|--------|
| Desktop | Vivaldi | Violentmonkey | ✅ |
| Desktop | Chrome | Tampermonkey | ✅ |
| Desktop | Firefox | Tampermonkey | ✅ |
| Android | Firefox | Tampermonkey | ✅ |
| Android | Vivaldi | — | ❌ No extensions |
| Android | Chrome | — | ❌ No extensions |
### Setup ### Setup
```bash 1. Get a free API key at [console.groq.com/keys](https://console.groq.com/keys)
git clone https://github.com/slaid098/opencode-voice-dictation.git 2. Open OpenCode web
cd opencode-voice-dictation 3. Violentmonkey/Tampermonkey menu → **Set Groq API Key** → paste `gsk_...`
npm install
```
### Commands ### Usage
| Command | Description | 1. Click the mic button in the top-right corner of the input area
|---------|-------------| 2. Speak (recording timer is displayed)
| `npm run dev` | Development mode with auto-reload | 3. Click again — transcribed text appears in the input
| `npm run build` | Build to `dist/opencode-voice-dictation.user.js` | 4. Desktop: **Ctrl+Space** shortcut
| `npm run lint` | Lint with Biome |
| `npm run format` | Format with Biome |
| `npm run typecheck` | TypeScript type checking |
| `npm run test` | Run tests with coverage (60% threshold) |
| `npm run knip` | Find unused code |
### Project Structure ### Auto-Update
``` The script updates automatically. When code is pushed to `main`, CI builds and publishes to the `dist` branch. Users receive updates automatically.
src/
├── index.ts # Entry point — orchestrates all modules
├── config.ts # GM storage, defaults, validation, menu commands
├── audio.ts # MediaRecorder, getUserMedia, audio constraints
├── transcribe.ts # Groq API call, FormData builder, error parsing
├── insert.ts # Contenteditable text insertion + InputEvent dispatch
├── ui.ts # Button injection, MutationObserver, toast notifications
├── keyboard.ts # Ctrl+Space keyboard shortcut handler
└── types.ts # Shared TypeScript types
tests/ ### Settings
├── transcribe.test.ts # FormData format, error parsing (5 tests)
├── insert.test.ts # Contenteditable insertion, event dispatch (5 tests)
├── config.test.ts # Defaults, key validation, state (9 tests)
├── audio.test.ts # Time formatting (5 tests)
└── __mocks__/$/ # Mock for GM_api functions
```
### CI/CD | Menu Item | Description |
|-----------|-------------|
| Set Groq API Key | API key `gsk_...` |
| Toggle Auto-Submit | Auto-send after transcription |
| Set Whisper Model | `whisper-large-v3` or `whisper-large-v3-turbo` |
| Set Language | `ru`, `en`, or empty (auto-detect) |
| Set Whisper Prompt | Context for accuracy |
- **Lint**: Biome (recommended rules, `noExplicitAny: error`) ---
- **Typecheck**: `tsc --noEmit`
- **Knip**: Dead code detection
- **Test**: Vitest + happy-dom, 60% coverage threshold
- **Build**: Vite → single `.user.js` file
- **Dependabot**: Weekly npm + GitHub Actions updates
## License ## License

137
src/ui.ts
View file

@ -1,56 +1,103 @@
import type { DictationState } from "./types.js"; import type { DictationState } from "./types.js";
const BUTTON_ID = "opencode-voice-dictation-btn"; const BUTTON_ID = "opencode-voice-dictation-btn";
const CONTAINER_ID = "opencode-voice-dictation-container";
const TIMER_ID = "opencode-voice-dictation-timer";
const COMPOSER_SELECTORS = [ const COMPOSER_SELECTORS = [
'[data-component="session-composer"]', '[data-component="session-composer"]',
'[data-component="session-new-composer"]', '[data-component="session-new-composer"]',
]; ];
const ICON_MIC = `<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z"/><path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z"/></svg>`;
const ICON_STOP = `<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="3"/></svg>`;
const ICON_SPINNER = `<svg class="ocvd-spin" viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d="M12 4V1L8 5l4 4V6c3.31 0 6 2.69 6 6 0 1.01-.25 1.97-.7 2.8l1.46 1.46C19.54 15.03 20 13.57 20 12c0-4.42-3.58-8-8-8z"/></svg>`;
function createButtonStyle(): string { function createButtonStyle(): string {
return ` return `
#${CONTAINER_ID} {
position: absolute !important;
top: 8px;
right: 8px;
z-index: 999998;
display: flex;
align-items: center;
gap: 6px;
pointer-events: none;
}
#${CONTAINER_ID} > * {
pointer-events: auto;
}
#${BUTTON_ID} { #${BUTTON_ID} {
display: inline-flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 36px; width: 32px;
height: 36px; height: 32px;
border-radius: 8px; border-radius: 50%;
border: 1px solid var(--color-border, #333); border: none;
background: var(--color-bg-secondary, #1a1a1a); background: var(--color-bg-tertiary, rgba(128, 128, 128, 0.15));
color: var(--color-text-secondary, #888); color: var(--color-text-secondary, #888);
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition: all 0.2s ease;
flex-shrink: 0;
font-size: 18px;
padding: 0; padding: 0;
gap: 4px; flex-shrink: 0;
} }
#${BUTTON_ID}:hover { #${BUTTON_ID}:hover {
background: var(--color-bg-tertiary, #2a2a2a); background: var(--color-bg-hover, rgba(128, 128, 128, 0.25));
color: var(--color-text-primary, #fff); color: var(--color-text-primary, #fff);
} }
#${BUTTON_ID}.recording { #${BUTTON_ID}.recording {
background: #e53935; background: #e53935;
color: #fff; color: #fff;
border-color: #e53935; animation: ocvd-pulse 1.5s ease-in-out infinite;
animation: pulse 1.5s ease-in-out infinite;
} }
#${BUTTON_ID}.processing { #${BUTTON_ID}.processing {
background: var(--color-accent, #4a9eff); background: var(--color-accent, #4a9eff);
color: #fff; color: #fff;
border-color: var(--color-accent, #4a9eff);
pointer-events: none; pointer-events: none;
opacity: 0.7; opacity: 0.8;
} }
#${BUTTON_ID} .timer { #${TIMER_ID} {
font-size: 11px; display: none;
font-weight: 600;
font-family: monospace; font-family: monospace;
margin-right: 2px; font-size: 13px;
font-weight: 600;
color: #e53935;
background: rgba(229, 57, 53, 0.1);
padding: 3px 10px;
border-radius: 12px;
align-items: center;
gap: 5px;
line-height: 1;
white-space: nowrap;
} }
@keyframes pulse { #${TIMER_ID}.visible {
display: inline-flex;
}
#${TIMER_ID}::before {
content: "";
width: 7px;
height: 7px;
background: #e53935;
border-radius: 50%;
animation: ocvd-blink 1s ease-in-out infinite;
flex-shrink: 0;
}
@keyframes ocvd-pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(229, 57, 53, 0.4); } 0%, 100% { box-shadow: 0 0 0 0 rgba(229, 57, 53, 0.4); }
50% { box-shadow: 0 0 0 8px rgba(229, 57, 53, 0); } 50% { box-shadow: 0 0 0 6px rgba(229, 57, 53, 0); }
}
@keyframes ocvd-blink {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
@keyframes ocvd-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.ocvd-spin {
animation: ocvd-spin 0.8s linear infinite;
transform-origin: center;
} }
#opencode-voice-toast { #opencode-voice-toast {
position: fixed; position: fixed;
@ -103,35 +150,50 @@ function showToast(message: string, isError = false): void {
}, 4000); }, 4000);
} }
function createButton(): HTMLButtonElement { function createContainer(): HTMLDivElement {
const container = document.createElement("div");
container.id = CONTAINER_ID;
const timer = document.createElement("span");
timer.id = TIMER_ID;
timer.textContent = "00:00";
const button = document.createElement("button"); const button = document.createElement("button");
button.id = BUTTON_ID; button.id = BUTTON_ID;
button.type = "button"; button.type = "button";
button.title = "Voice Dictation (Ctrl+Space)"; button.title = "Voice Dictation (Ctrl+Space)";
button.innerHTML = "&#127908;"; button.innerHTML = ICON_MIC;
return button;
container.appendChild(timer);
container.appendChild(button);
return container;
} }
function updateButtonState( function updateButtonState(
button: HTMLButtonElement, button: HTMLButtonElement,
timer: HTMLElement,
state: DictationState, state: DictationState,
elapsedSeconds = 0, elapsedSeconds = 0,
): void { ): void {
button.classList.remove("recording", "processing"); button.classList.remove("recording", "processing");
timer.classList.remove("visible");
switch (state) { switch (state) {
case "idle": case "idle":
button.innerHTML = "&#127908;"; button.innerHTML = ICON_MIC;
button.title = "Voice Dictation (Ctrl+Space)"; button.title = "Voice Dictation (Ctrl+Space)";
break; break;
case "recording": case "recording":
button.classList.add("recording"); button.classList.add("recording");
button.innerHTML = `<span class="timer">${formatTimer(elapsedSeconds)}</span>&#9209;`; button.innerHTML = ICON_STOP;
button.title = "Stop recording"; button.title = "Stop recording";
timer.textContent = formatTimer(elapsedSeconds);
timer.classList.add("visible");
break; break;
case "processing": case "processing":
button.classList.add("processing"); button.classList.add("processing");
button.innerHTML = "&#8987;"; button.innerHTML = ICON_SPINNER;
button.title = "Transcribing..."; button.title = "Transcribing...";
break; break;
} }
@ -153,12 +215,12 @@ function findComposer(): HTMLElement | null {
return null; return null;
} }
function isButtonInjected(): boolean { function isContainerInjected(): boolean {
return document.getElementById(BUTTON_ID) !== null; return document.getElementById(CONTAINER_ID) !== null;
} }
function injectButton(onToggle: () => void): void { function injectButton(onToggle: () => void): void {
if (isButtonInjected()) { if (isContainerInjected()) {
return; return;
} }
@ -169,19 +231,25 @@ function injectButton(onToggle: () => void): void {
injectStyles(); injectStyles();
const button = createButton(); const computedPosition = window.getComputedStyle(composer).position;
if (computedPosition === "static") {
composer.style.position = "relative";
}
const container = createContainer();
const button = container.querySelector(`#${BUTTON_ID}`) as HTMLButtonElement;
button.addEventListener("click", (e) => { button.addEventListener("click", (e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
onToggle(); onToggle();
}); });
composer.appendChild(button); composer.appendChild(container);
} }
export function setupUI( export function setupUI(
onToggle: () => void, onToggle: () => void,
getState: () => DictationState, _getState: () => DictationState,
): { ): {
inject: () => void; inject: () => void;
updateState: (state: DictationState, elapsedSeconds?: number) => void; updateState: (state: DictationState, elapsedSeconds?: number) => void;
@ -202,8 +270,9 @@ export function setupUI(
inject: () => injectButton(onToggle), inject: () => injectButton(onToggle),
updateState: (state: DictationState, elapsedSeconds = 0) => { updateState: (state: DictationState, elapsedSeconds = 0) => {
const button = document.getElementById(BUTTON_ID) as HTMLButtonElement | null; const button = document.getElementById(BUTTON_ID) as HTMLButtonElement | null;
if (button) { const timer = document.getElementById(TIMER_ID);
updateButtonState(button, state, elapsedSeconds); if (button && timer) {
updateButtonState(button, timer, state, elapsedSeconds);
} }
}, },
toast: showToast, toast: showToast,