Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions .claude/skills/gui-usability-review/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
---
name: gui-usability-review
description: Reproducible usability review of the Blitztext main window - offscreen clicks, screenshots, hard accessibility checks and a blind LLM rating. Use after changing app/main_window.py, theme.py or popup/toolbar layout.
---

# GUI usability review

## Purpose
Measure how quickly a user reaches common goals in the Qt main window and catch
accessibility defects, without a desktop session.

## Trigger
Any change to `app/main_window.py`, `app/theme.py`, toolbar/popup controls or the
controller methods the window calls.

## Inputs
- `scripts/gui_usability_check.py` (task list `TASKS`, click budgets)
- `tests/test_gui_usability.py` (hard gates)

## Procedure
1. Run the driver: `QT_QPA_PLATFORM=offscreen .venv/bin/python scripts/gui_usability_check.py`.
It prints a fresh temp directory containing `report.json` and one PNG per click step; copy what you want to keep.
2. Hard gates (no LLM): `WHISPER_GUI_TESTS=1 QT_QPA_PLATFORM=offscreen .venv/bin/python -m pytest -q tests/test_gui_usability.py`.
Click budgets, goal reached, accessible names, keyboard focus, minimum target 22 px.
3. Blind LLM rating, at least 3 independent runs per task. The reviewer gets ONLY
the task text and the PNG sequence of that task - never code, never this file's
results. Use a fresh subagent per run.
4. Reviewer output, JSON only:
`{"task": id, "score": 1-5, "findings": [{"widget": "...", "severity": "low|medium|high", "issue": "..."}], "suggestion": "..."}`
5. Score = mean over runs. Store under `.omc/uebergabe/gui-review/<date>/`, compare with the frozen baseline.

## Rubric (score 1-5 per task)
Findability of the control, number of steps, label clarity, feedback after the
action (is the new state visible?), error/edge handling, target size and contrast.
5 = obvious and immediate, 3 = works but needs hunting, 1 = most users fail.

## Verification
Hard gates green; mean score not below the baseline; no high-severity finding unaddressed.

## Rollback / abort
The driver only reads the UI with a fake controller; nothing to roll back. Abort the
review if the screenshots are blank (check `QT_QPA_PLATFORM=offscreen`).

## Known pitfalls
- A drop-down counts as two clicks (open, choose); the driver sets the combo index directly, so budgets are assumed clicks, not measured ones.
- Offscreen renders no native Wayland decorations or real fonts; judge layout, not font metrics.
- Setting a combo to its current value emits no signal; set a different state first.
- Reviewer prompts must never include the source, or the rating is no longer blind.

## References
`app/main_window.py`, `scripts/gui_usability_check.py`, Nielsen's 10 usability heuristics.
7 changes: 4 additions & 3 deletions README.de.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,7 @@ Das Hauptfenster ist ein kompaktes, rahmenloses Desktop-Widget mit einheitlicher
<td align="center" width="25%">
<img src="docs/screenshots/linux/main-menu-de.png" width="170"><br><br>
<b>Aktions-Menü</b><br>
<i>Workflows und Schnellzugriff direkt am Widget.</i>
<i>Details zur gewählten Seite: Aktion, Stil, KI-Anbieter, Whisper-Modell.</i>
</td>
</tr>
</table>
Expand All @@ -220,12 +220,13 @@ Das Hauptfenster ist ein kompaktes, rahmenloses Desktop-Widget mit einheitlicher
- **Bereit (IDLE):** Grüner Statuspunkt und Zeitanzeige (`Bereit: 00:00`).
- **Aufnahme (RECORDING):** Gelber Statuspunkt, sekundengenaue Zeitanzeige und animierte Wellenbalken links und rechts des Mikrofons.
- **Verarbeitung (TRANSCRIBING):** Rotierender goldener Kreisbogen um das Mikrofon (`Transkribiere…`).
- **Umschalter Lokal | Mit KI:** Zwei Knöpfe direkt unter dem Mikrofon. **Lokal** = nur lokale Whisper-Transkription, nichts verlässt den Rechner. **Mit KI** = lokal transkribieren, danach bearbeitet dein Cloud-KI-Anbieter den Text. Die gewählte Seite ist gold; die Zustandszeile darunter (zum Beispiel `Lokal · Whisper base` oder `Mit KI · OpenAI · Verbessern`) zeigt, was die nächste Aufnahme tut, und öffnet per Klick das Detail-Popup. Hotkey-Workflows stellen den Umschalter auf die passende Seite, während der Aufnahme ist er gesperrt.
- **Kompakte Werkzeugleiste:**
- ✍ **Compose-Button** (hervorgehobenes Stift-Symbol): Öffnet das Entwurfsfenster für Texteingabe und KI-Verfeinerung.
- 🕒 **Verlauf-Button** (mit Zähler-Badge): Öffnet den Transkript- und Diktat-Verlauf.
- 🔊 **Vorlesen-Button** (Lautsprecher-Symbol): Öffnet das TTS-Fenster zur Sprachausgabe.
- ⚙ **Einstellungen-Button** (Zahnrad): Öffnet den Konfigurations-Dialog.
- ⋯ **Mehr-Button**: Öffnet das Popup-Menü für *Diktieren & verbessern*, Text-Aktionen (*Kürzen*, *Ausformulieren*, *Tonfall ändern*), *Eigene Anweisung*, *Sammel-Diktat* und *Verwerfen*.
- ⋯ **Mehr-Button** (gleiches Popup wie die Zustandszeile): Zeigt nur die Felder, die zur gewählten Seite passen — *Lokal*: Whisper-Modell; *Mit KI*: Aktion (*Diktieren & verbessern*, *Sachlich formulieren*, *Emojis ergänzen*), Schreibstil für *Diktieren & verbessern* (*Kürzen*, *Ausformulieren*, *Tonfall ändern*, *Eigene Anweisung*), KI-Anbieter (OpenAI oder OpenRouter; einen eigenen Endpunkt änderst du nur in den Einstellungen) und Whisper-Modell. Ein **Fertig**-Knopf schließt das Popup; *Sammel-Diktat* und *Verwerfen* stehen darunter. Der Anbieterwechsel braucht die passende API-Key-Variable, größere Whisper-Modelle fragen vorher, weil sie heruntergeladen werden können.
- **Einstellungs-Zahnrad:** Kleiner ⚙-Knopf in der Titelleiste neben Minimieren und Schließen; öffnet den Konfigurations-Dialog.

*Das Widget öffnet sich beim Start sowie über den Tray-Eintrag **Fenster anzeigen** oder einen Klick auf das Tray-Icon. Schließen blendet das Widget aus — die App läuft im Hintergrund im Tray weiter.*

Expand Down
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,7 @@ The main window is a compact, frameless desktop widget with a consistent dark lo
<td align="center" width="25%">
<img src="docs/screenshots/linux/main-menu-en.png" width="170"><br><br>
<b>Options menu</b><br>
<i>Workflows and quick actions.</i>
<i>Details for the chosen side: action, style, AI provider, Whisper model.</i>
</td>
</tr>
</table>
Expand All @@ -219,12 +219,13 @@ The main window is a compact, frameless desktop widget with a consistent dark lo
- **Idle:** Green status dot with duration counter (`Ready: 00:00`).
- **Recording:** Amber indicator, live recording timer, and animated stereo wave bars flanking the mic.
- **Processing:** Rotating gold progress ring around the microphone (`Transcribing…`).
- **Local | With AI switch:** Two buttons right under the microphone. **Local** = local Whisper transcription only, nothing leaves your machine. **With AI** = local transcription, then the text is edited by your cloud AI provider. The chosen side is gold; the state line below it (for example `Local · Whisper base` or `With AI · OpenAI · Improve`) shows what the next recording does and opens the details popup when clicked. Hotkey workflows move the switch to the matching side, and it is locked while recording.
- **Tool row:**
- ✍ **Compose button** (highlighted pen icon): Opens the Compose window for drafting and rewriting.
- 🕒 **History button** (with counter badge): Opens the transcript history with recent entries.
- 🔊 **Read aloud button** (speaker icon): Opens the TTS window to synthesize speech.
- ⚙ **Settings button** (gear icon): Opens the settings dialog.
- ⋯ **More options button**: Pops up quick access to *Dictate & improve*, text improvement presets (*Shorten*, *Expand*, *Change tone*), *Custom instruction*, *Batch dictation*, and *Discard*.
- ⋯ **More options button** (same popup as the state line): Shows only the fields that apply to the chosen side — *Local*: Whisper model; *With AI*: action (*Dictate & improve*, *Make it factual*, *Add emojis*), the writing style for *Dictate & improve* (*Shorten*, *Expand*, *Change tone*, *Custom instruction*), the AI provider (OpenAI or OpenRouter; a custom endpoint is changed in the settings only), and the Whisper model. A **Done** button closes the popup; *Batch dictation* and *Discard* sit below it. Switching the AI provider needs the matching API key variable; larger Whisper models ask before they may be downloaded.
- **Settings gear:** Small ⚙ button in the title bar next to minimize and close; opens the settings dialog.

*The widget opens at startup, via the tray entry **Show window**, or by clicking the tray icon. Closing only hides the widget — the app keeps running in the background tray.*

Expand Down
100 changes: 100 additions & 0 deletions app/blitztext_linux.py
Original file line number Diff line number Diff line change
Expand Up @@ -1067,6 +1067,95 @@ def main_window_preset_changed(self, key: str) -> None:
self._refresh_preset_menu()
logger.info("Writing preset changed via main window: %s", key)

def main_window_provider_changed(self, provider: str) -> None:
"""Switch between OpenAI and OpenRouter from the main window.

A custom endpoint is only changed in the settings dialog: leaving it here would
overwrite the user's URL and key variable, and entering it here could pair a stale
URL with an OpenAI key. Without a usable key the switch is refused.
"""
cfg = self.config
if provider == cfg.llm_provider:
return
previous = (cfg.llm_provider, cfg.llm_base_url, cfg.openai_api_key_env)
problem = None
if provider == "custom" or cfg.llm_provider == "custom":
problem = (t("settings.save_error.title"), t("mainwindow.provider.custom_in_settings.message"))
else:
if cfg.llm_provider == "openai":
# Remember a personal OpenAI key variable so the way back restores it.
self._openai_key_env_memo = cfg.openai_api_key_env
cfg.llm_provider = provider
if provider == "openrouter":
cfg.llm_base_url = "https://openrouter.ai/api/v1"
cfg.openai_api_key_env = "OPENROUTER_API_KEY"
Comment on lines +1088 to +1091

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve a provider-compatible model when switching

When an OpenRouter configuration uses a normal namespaced model ID such as openai/gpt-4o, selecting OpenAI here changes the provider, URL, and key but leaves cfg.llm_model untouched; _build_llm_service() then sends that OpenRouter ID to OpenAI, causing every subsequent AI workflow to fail. The reverse switch can likewise send an OpenAI ID such as gpt-4o-mini to OpenRouter, whose catalog uses namespaced IDs. Preserve a model per provider or require selecting a compatible model during the switch. OpenRouter model IDs OpenAI model IDs

Useful? React with 👍 / 👎.

else:
cfg.llm_base_url = ""
cfg.openai_api_key_env = getattr(
self, "_openai_key_env_memo", DEFAULTS["openai_api_key_env"]
)
Comment on lines +1093 to +1096

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Persist the remembered OpenAI key variable

If a user configured OpenAI with a nondefault variable such as WORK_OPENAI_KEY, switches to OpenRouter, and restarts before switching back, the old name existed only in _openai_key_env_memo while the saved config now contains OPENROUTER_API_KEY. After restart this fallback silently chooses OPENAI_API_KEY, so the valid custom key is forgotten and the quick switch back to OpenAI is refused. Store provider-specific key-variable names persistently rather than relying on an in-memory memo.

Useful? React with 👍 / 👎.

if not cfg.resolve_llm_api_key():
problem = (
t("mainwindow.provider.no_key.title"),
t("mainwindow.provider.no_key.message").format(env=cfg.openai_api_key_env),
)
if problem is None:
try:
cfg.save()
except Exception as exc:
logger.error("Failed to save LLM provider: %s", exc)
problem = (
t("settings.save_error.title"),
t("settings.save_error.message").format(error=exc),
)
if problem is not None:
cfg.llm_provider, cfg.llm_base_url, cfg.openai_api_key_env = previous
if self._main_window is not None:
self._main_window.set_llm_provider(cfg.llm_provider)
self.show_tray_warning(*problem)
return
self._rebuild_llm_service()
self.update_menu_availability()
if self._main_window is not None:
self._main_window.set_llm_provider(provider)
logger.info("LLM provider changed via main window: %s", provider)

def _confirm_model_download(self, model: str) -> bool:
if self._main_window is not None:
# The selector popup is a Qt.Popup; a modal box over it would be unreliable.
self._main_window._options_popup.hide()
answer = QMessageBox.question(
self._main_window,
t("mainwindow.model.download.title"),
t("mainwindow.model.download.message").format(model=model),
)
return answer == QMessageBox.StandardButton.Yes

def main_window_model_changed(self, model: str) -> None:
"""Switch the Whisper model; larger models ask first because they may download."""
cfg = self.config
if model == cfg.model:
return
previous = cfg.model
if model not in ("tiny", "base") and not self._confirm_model_download(model):
if self._main_window is not None:
self._main_window.set_model(previous)
return
cfg.model = model
try:
cfg.save()
except Exception as exc:
cfg.model = previous
if self._main_window is not None:
self._main_window.set_model(previous)
logger.error("Failed to save model: %s", exc)
self.show_tray_warning(
t("settings.save_error.title"),
t("settings.save_error.message").format(error=exc),
)
return
logger.info("Whisper model changed via main window: %s", model)

def start_hotkey_worker(self) -> None:
self.stop_hotkey_worker()

Expand Down Expand Up @@ -1120,6 +1209,8 @@ def show_settings_dialog(self) -> None:
self._refresh_preset_menu()
if self._main_window is not None:
self._main_window.set_preset(self.config.writing_preset)
self._main_window.set_llm_provider(self.config.llm_provider)
self._main_window.set_model(self.config.model)

# Restart hotkey listener if mode or key changed
if self.hotkey_worker and (
Expand Down Expand Up @@ -1194,6 +1285,13 @@ def gui_toggle_recording(self, workflow: WorkflowType) -> None:
Fallback wenn der Hotkey nicht greift oder keine Tastatur zur Hand ist.
"""
if self.state == "IDLE":
if workflow in LLM_WORKFLOWS and not self.llm_service.is_available():
# Fail before speaking: otherwise the transcript is lost at the rewrite step.
self.show_tray_warning(
t("mainwindow.provider.no_key.title"),
t("mainwindow.provider.no_key.message").format(env=self.config.openai_api_key_env),
)
return
self._start_recording(workflow)
elif self.state == "RECORDING":
self._stop_recording_and_process()
Expand Down Expand Up @@ -1456,6 +1554,8 @@ def _ensure_main_window(self) -> MainWindow:
window.set_history_count(self._history_panel.entry_count)
window.update_state(self.state, self.current_workflow, self._tray_error_message)
window.set_preset(self.config.writing_preset)
window.set_llm_provider(self.config.llm_provider)
window.set_model(self.config.model)
self._main_window = window
return self._main_window

Expand Down
9 changes: 8 additions & 1 deletion app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,14 +159,21 @@ def save(self) -> None:
tmp = self.config_file.with_suffix(".json.tmp")
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
fd = os.open(str(tmp), flags, 0o600)
# Fix the mode before the file can replace the config: a failure after the
# replace would report an error although the new content is already live.
try:
os.fchmod(fd, 0o600)
except OSError:
os.close(fd)
tmp.unlink(missing_ok=True)
raise
payload = _deep_copy_without_legacy_key(self._data)
if not self._tts_env_explicit:
payload.pop("tts_openai_api_key_env", None)
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(payload, f, indent=2, ensure_ascii=False)
f.write("\n")
tmp.replace(self.config_file)
self.config_file.chmod(0o600)
self._data = payload
self._legacy_openai_api_key_present = False
self._legacy_openai_api_key_value = ""
Expand Down
Loading
Loading