diff --git a/Doku/ui-patterns.md b/Doku/ui-patterns.md
index 4b5a34b..ef49c9e 100644
--- a/Doku/ui-patterns.md
+++ b/Doku/ui-patterns.md
@@ -68,3 +68,14 @@ Komponente: [CountBadge.tsx](../frontend/src/components/CountBadge.tsx), CSS-Kla
- `index.html` enthält in `#root` einen reinen HTML-Platzhalter (vollflächig Blau, Logo in der Mitte, kein Text). React ersetzt ihn beim ersten Rendern, dazwischen blitzt nichts Weißes auf.
- `meta theme-color` startet blau und wird von [`theme.ts`](../frontend/src/lib/theme.ts) nach dem Start und bei jedem Theme-Wechsel auf `--bg-app` des aktiven Themes gesetzt, damit die Statusleiste zum Header passt.
+## +-Button: Tippen und Halten
+
+Komponente: [HoldToRecordFab.tsx](../frontend/src/features/capture/HoldToRecordFab.tsx)
+
+- **Kurz tippen (< 350 ms):** Führt wie bisher die primäre Aktion der aktuellen Ansicht (`primaryAction`) oder den mobilen Erfassungsdialog (`openMobileTriageCapture`) aus.
+- **Gedrückt halten (≥ 350 ms):** Startet direkt eine Sprachaufnahme über die Web Speech API (`de-DE`, `continuous`, `interimResults`). Der Button wird zum pulsierenden Mikrofon (`bg-status-err`), darüber zeigt eine schwebende Pille „Ich höre zu… (loslassen speichert)“ samt Live-Transkript.
+- **Loslassen:** Stoppt die Aufnahme, wartet kurz auf das letzte Wort und speichert den Text unabhängig von der aktiven Ansicht immer als Gedanken in die Inbox (`source: 'voice'`, `target: 'inbox'`).
+- **Nach oben ziehen (> 60 px):** Schaltet die Pille in den Abbruch-Zustand („🗑️ Zum Abbrechen loslassen“); Loslassen dort verwirft die Aufnahme (`abort()`).
+- **Fallback:** Unterstützt der Browser keine Spracherkennung oder ist das Mikrofon gesperrt (`not-allowed`), öffnet Halten wie beim Tippen den Dialog und blendet eine kurze Statusmeldung über der Bottom-Leiste ein.
+
+
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 8c4df30..e18a409 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -44,6 +44,7 @@ import {
ThemeId,
} from './lib/theme';
import { InstantCaptureBar } from './features/capture/InstantCaptureBar';
+import { HoldToRecordFab } from './features/capture/HoldToRecordFab';
import { CountBadge } from './components/CountBadge';
import { TriageDeck } from './features/triage/TriageDeck';
import { TasksView } from './features/triage/TasksView';
@@ -1246,9 +1247,8 @@ export const App: React.FC = () => {
{/* Col 3 (EXACT CENTER): + Quick-Add FAB */}
-
{
+ {
triggerHaptic(15);
if (primaryAction) {
primaryAction();
@@ -1256,11 +1256,11 @@ export const App: React.FC = () => {
openMobileTriageCapture();
}
}}
- className="w-14 h-14 -mt-6 rounded-full bg-accent text-on-accent shadow-lg flex items-center justify-center active:scale-90 transition-transform border-4 border-panel cursor-pointer"
- title="Schnell erfassen (+)"
- >
-
-
+ onTranscript={async (text) => {
+ const thought = await api.captureThought(text, 'normal', 'voice', '', { target: 'inbox' });
+ handleCaptured(thought, 'inbox');
+ }}
+ />
{/* Col 4: Aufgaben */}
diff --git a/frontend/src/features/capture/HoldToRecordFab.tsx b/frontend/src/features/capture/HoldToRecordFab.tsx
new file mode 100644
index 0000000..43b2f7a
--- /dev/null
+++ b/frontend/src/features/capture/HoldToRecordFab.tsx
@@ -0,0 +1,359 @@
+import React, { useEffect, useRef, useState } from 'react';
+import { Mic, Plus } from 'lucide-react';
+import { triggerHaptic } from '../../lib/api';
+
+export interface HoldToRecordFabProps {
+ onTap: () => void;
+ onTranscript: (text: string) => Promise | void;
+}
+
+const HOLD_DELAY_MS = 350;
+const CANCEL_DRAG_PX = 60;
+const STOP_TIMEOUT_MS = 1500;
+const TOAST_DURATION_MS = 2500;
+
+export const HoldToRecordFab: React.FC = ({ onTap, onTranscript }) => {
+ const [isRecording, setIsRecording] = useState(false);
+ const [isCancelZone, setIsCancelZone] = useState(false);
+ const [liveTranscript, setLiveTranscript] = useState('');
+ const [toastMessage, setToastMessage] = useState(null);
+
+ const isPressedRef = useRef(false);
+ const didEnterHoldRef = useRef(false);
+ const fallbackHandledRef = useRef(false);
+ const isCancelZoneRef = useRef(false);
+ const abortedRef = useRef(false);
+ const isStoppingRef = useRef(false);
+ const hasEndedRef = useRef(false);
+ const finalizedRef = useRef(false);
+ const startYRef = useRef(0);
+ const transcriptRef = useRef('');
+
+ const recognitionRef = useRef(null);
+ const holdTimerRef = useRef(null);
+ const stopTimerRef = useRef(null);
+ const toastTimerRef = useRef(null);
+
+ const clearHoldTimer = () => {
+ if (holdTimerRef.current !== null) {
+ window.clearTimeout(holdTimerRef.current);
+ holdTimerRef.current = null;
+ }
+ };
+
+ const clearStopTimer = () => {
+ if (stopTimerRef.current !== null) {
+ window.clearTimeout(stopTimerRef.current);
+ stopTimerRef.current = null;
+ }
+ };
+
+ const showToast = (msg: string) => {
+ if (toastTimerRef.current !== null) {
+ window.clearTimeout(toastTimerRef.current);
+ }
+ setToastMessage(msg);
+ toastTimerRef.current = window.setTimeout(() => {
+ setToastMessage(null);
+ toastTimerRef.current = null;
+ }, TOAST_DURATION_MS);
+ };
+
+ useEffect(() => {
+ return () => {
+ clearHoldTimer();
+ clearStopTimer();
+ if (toastTimerRef.current !== null) {
+ window.clearTimeout(toastTimerRef.current);
+ }
+ if (recognitionRef.current) {
+ try {
+ recognitionRef.current.abort?.();
+ } catch {
+ // ignore cleanup errors
+ }
+ }
+ };
+ }, []);
+
+ const finalizeRecording = async () => {
+ if (finalizedRef.current) return;
+ finalizedRef.current = true;
+ clearStopTimer();
+ recognitionRef.current = null;
+
+ if (abortedRef.current) return;
+
+ const text = transcriptRef.current.trim();
+ setLiveTranscript('');
+ if (!text) {
+ showToast('Nichts erkannt');
+ return;
+ }
+
+ triggerHaptic(25);
+ showToast('Im Quick Dump gespeichert');
+ try {
+ await onTranscript(text);
+ } catch {
+ showToast('Fehler beim Speichern');
+ }
+ };
+
+ const startVoiceRecording = () => {
+ const SpeechRecognition =
+ (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition;
+
+ if (!SpeechRecognition) {
+ fallbackHandledRef.current = true;
+ showToast('Spracheingabe wird von diesem Browser nicht unterstützt');
+ onTap();
+ return;
+ }
+
+ try {
+ const recognition = new SpeechRecognition();
+ recognition.lang = 'de-DE';
+ recognition.interimResults = true;
+ recognition.continuous = true;
+
+ transcriptRef.current = '';
+ abortedRef.current = false;
+ isStoppingRef.current = false;
+ hasEndedRef.current = false;
+ finalizedRef.current = false;
+ isCancelZoneRef.current = false;
+
+ recognition.onresult = (event: any) => {
+ let combined = '';
+ for (let i = 0; i < event.results.length; i++) {
+ combined += event.results[i][0].transcript;
+ }
+ transcriptRef.current = combined;
+ setLiveTranscript(combined);
+ };
+
+ recognition.onerror = (event: any) => {
+ const errCode = event?.error;
+ if (errCode === 'not-allowed' || errCode === 'service-not-allowed') {
+ abortedRef.current = true;
+ clearStopTimer();
+ setIsRecording(false);
+ setIsCancelZone(false);
+ setLiveTranscript('');
+ if (!fallbackHandledRef.current) {
+ fallbackHandledRef.current = true;
+ showToast('Mikrofon nicht freigegeben');
+ onTap();
+ }
+ return;
+ }
+ if (isStoppingRef.current) {
+ void finalizeRecording();
+ }
+ };
+
+ recognition.onend = () => {
+ hasEndedRef.current = true;
+ if (isStoppingRef.current) {
+ void finalizeRecording();
+ }
+ };
+
+ recognitionRef.current = recognition;
+ setLiveTranscript('');
+ setIsCancelZone(false);
+ setIsRecording(true);
+ triggerHaptic([15, 60, 25]);
+ recognition.start();
+ } catch {
+ fallbackHandledRef.current = true;
+ setIsRecording(false);
+ showToast('Spracheingabe wird von diesem Browser nicht unterstützt');
+ onTap();
+ }
+ };
+
+ const handlePointerDown = (e: React.PointerEvent) => {
+ if (e.pointerType === 'mouse' && e.button !== 0) return;
+
+ try {
+ e.currentTarget.setPointerCapture?.(e.pointerId);
+ } catch {
+ // ignore if unsupported in environment
+ }
+
+ clearHoldTimer();
+ clearStopTimer();
+
+ isPressedRef.current = true;
+ didEnterHoldRef.current = false;
+ fallbackHandledRef.current = false;
+ isCancelZoneRef.current = false;
+ startYRef.current = e.clientY;
+
+ holdTimerRef.current = window.setTimeout(() => {
+ holdTimerRef.current = null;
+ if (!isPressedRef.current) return;
+ didEnterHoldRef.current = true;
+ startVoiceRecording();
+ }, HOLD_DELAY_MS);
+ };
+
+ const handlePointerMove = (e: React.PointerEvent) => {
+ if (!isPressedRef.current || !didEnterHoldRef.current || fallbackHandledRef.current) return;
+ const deltaUp = startYRef.current - e.clientY;
+ const inCancel = deltaUp > CANCEL_DRAG_PX;
+ if (inCancel !== isCancelZoneRef.current) {
+ isCancelZoneRef.current = inCancel;
+ setIsCancelZone(inCancel);
+ triggerHaptic(10);
+ }
+ };
+
+ const handlePointerUp = (e: React.PointerEvent) => {
+ try {
+ e.currentTarget.releasePointerCapture?.(e.pointerId);
+ } catch {
+ // ignore
+ }
+
+ if (!isPressedRef.current) return;
+ isPressedRef.current = false;
+ clearHoldTimer();
+
+ if (!didEnterHoldRef.current) {
+ onTap();
+ return;
+ }
+
+ if (fallbackHandledRef.current) {
+ return;
+ }
+
+ setIsRecording(false);
+ setIsCancelZone(false);
+
+ if (isCancelZoneRef.current) {
+ abortedRef.current = true;
+ clearStopTimer();
+ setLiveTranscript('');
+ triggerHaptic(15);
+ try {
+ recognitionRef.current?.abort?.();
+ } catch {
+ // ignore
+ }
+ recognitionRef.current = null;
+ return;
+ }
+
+ isStoppingRef.current = true;
+ if (hasEndedRef.current || !recognitionRef.current) {
+ void finalizeRecording();
+ return;
+ }
+
+ stopTimerRef.current = window.setTimeout(() => {
+ void finalizeRecording();
+ }, STOP_TIMEOUT_MS);
+
+ try {
+ recognitionRef.current.stop();
+ } catch {
+ void finalizeRecording();
+ }
+ };
+
+ const handlePointerCancel = (e: React.PointerEvent) => {
+ try {
+ e.currentTarget.releasePointerCapture?.(e.pointerId);
+ } catch {
+ // ignore
+ }
+
+ if (!isPressedRef.current) return;
+ isPressedRef.current = false;
+ clearHoldTimer();
+ clearStopTimer();
+
+ if (didEnterHoldRef.current && !fallbackHandledRef.current) {
+ abortedRef.current = true;
+ setIsRecording(false);
+ setIsCancelZone(false);
+ setLiveTranscript('');
+ try {
+ recognitionRef.current?.abort?.();
+ } catch {
+ // ignore
+ }
+ recognitionRef.current = null;
+ }
+ };
+
+ const displayedTranscript =
+ liveTranscript.length > 180 ? `…${liveTranscript.slice(-180)}` : liveTranscript;
+
+ return (
+ <>
+ {isRecording && (
+
+ {isCancelZone ? (
+
🗑️ Zum Abbrechen loslassen
+ ) : (
+
+
+ Ich höre zu… (loslassen speichert)
+
+ {displayedTranscript ? (
+
+ {displayedTranscript}
+
+ ) : (
+
Nach oben ziehen zum Abbrechen
+ )}
+
+ )}
+
+ )}
+
+ {!isRecording && toastMessage && (
+
+ {toastMessage}
+
+ )}
+
+ e.preventDefault()}
+ className={
+ isRecording
+ ? 'w-14 h-14 -mt-6 rounded-full bg-status-err text-on-accent shadow-lg flex items-center justify-center transition-transform border-4 border-panel cursor-pointer touch-none select-none animate-pulse'
+ : 'w-14 h-14 -mt-6 rounded-full bg-accent text-on-accent shadow-lg flex items-center justify-center active:scale-90 transition-transform border-4 border-panel cursor-pointer touch-none select-none'
+ }
+ title="Schnell erfassen (+)"
+ aria-label="Schnell erfassen – tippen zum Schreiben, gedrückt halten zum Sprechen"
+ >
+ {isRecording ? (
+
+ ) : (
+
+ )}
+
+ >
+ );
+};
diff --git a/frontend/src/features/capture/__tests__/HoldToRecordFab.test.tsx b/frontend/src/features/capture/__tests__/HoldToRecordFab.test.tsx
new file mode 100644
index 0000000..4ee6863
--- /dev/null
+++ b/frontend/src/features/capture/__tests__/HoldToRecordFab.test.tsx
@@ -0,0 +1,257 @@
+import React from 'react';
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { render, screen, fireEvent, act } from '@testing-library/react';
+import { HoldToRecordFab } from '../HoldToRecordFab';
+
+class FakeSpeechRecognition {
+ static instances: FakeSpeechRecognition[] = [];
+
+ lang = '';
+ interimResults = false;
+ continuous = false;
+ started = false;
+ stopped = false;
+ aborted = false;
+
+ onresult: ((event: any) => void) | null = null;
+ onerror: ((event: any) => void) | null = null;
+ onend: (() => void) | null = null;
+
+ constructor() {
+ FakeSpeechRecognition.instances.push(this);
+ }
+
+ start() {
+ this.started = true;
+ }
+
+ stop() {
+ this.stopped = true;
+ this.onend?.();
+ }
+
+ abort() {
+ this.aborted = true;
+ this.onend?.();
+ }
+
+ emitResult(transcript: string) {
+ this.onresult?.({
+ results: [[{ transcript }]],
+ });
+ }
+
+ emitError(error: string) {
+ this.onerror?.({ error });
+ }
+}
+
+describe('HoldToRecordFab', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ FakeSpeechRecognition.instances = [];
+ (window as any).SpeechRecognition = FakeSpeechRecognition;
+ delete (window as any).webkitSpeechRecognition;
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ delete (window as any).SpeechRecognition;
+ delete (window as any).webkitSpeechRecognition;
+ });
+
+ it('calls onTap on a short tap (< 350 ms)', () => {
+ const onTap = vi.fn();
+ const onTranscript = vi.fn();
+
+ render( );
+ const btn = screen.getByRole('button', {
+ name: /Schnell erfassen – tippen zum Schreiben, gedrückt halten zum Sprechen/i,
+ });
+
+ fireEvent.pointerDown(btn, { clientY: 600, button: 0 });
+ act(() => {
+ vi.advanceTimersByTime(150);
+ });
+ fireEvent.pointerUp(btn, { clientY: 600 });
+
+ expect(onTap).toHaveBeenCalledTimes(1);
+ expect(onTranscript).not.toHaveBeenCalled();
+ expect(FakeSpeechRecognition.instances).toHaveLength(0);
+ });
+
+ it('starts recording on hold (>= 350 ms) and calls onTranscript with recognized text on release', async () => {
+ const onTap = vi.fn();
+ const onTranscript = vi.fn().mockResolvedValue(undefined);
+
+ render( );
+ const btn = screen.getByRole('button', {
+ name: /Schnell erfassen – tippen zum Schreiben, gedrückt halten zum Sprechen/i,
+ });
+
+ fireEvent.pointerDown(btn, { clientY: 600, button: 0 });
+ act(() => {
+ vi.advanceTimersByTime(350);
+ });
+
+ expect(FakeSpeechRecognition.instances).toHaveLength(1);
+ const rec = FakeSpeechRecognition.instances[0];
+ expect(rec.started).toBe(true);
+ expect(rec.lang).toBe('de-DE');
+ expect(rec.interimResults).toBe(true);
+ expect(rec.continuous).toBe(true);
+ expect(screen.getByText(/Ich höre zu… \(loslassen speichert\)/i)).toBeTruthy();
+
+ act(() => {
+ rec.emitResult('Milch und Kaffee einkaufen');
+ });
+ expect(screen.getByText('Milch und Kaffee einkaufen')).toBeTruthy();
+
+ await act(async () => {
+ fireEvent.pointerUp(btn, { clientY: 600 });
+ });
+
+ expect(rec.stopped).toBe(true);
+ expect(onTap).not.toHaveBeenCalled();
+ expect(onTranscript).toHaveBeenCalledTimes(1);
+ expect(onTranscript).toHaveBeenCalledWith('Milch und Kaffee einkaufen');
+ expect(screen.getByRole('status').textContent).toContain('Im Quick Dump gespeichert');
+ });
+
+ it('cancels recording when dragged up by more than 60 px and released', () => {
+ const onTap = vi.fn();
+ const onTranscript = vi.fn();
+
+ render( );
+ const btn = screen.getByRole('button', {
+ name: /Schnell erfassen – tippen zum Schreiben, gedrückt halten zum Sprechen/i,
+ });
+
+ fireEvent.pointerDown(btn, { clientY: 600, button: 0 });
+ act(() => {
+ vi.advanceTimersByTime(350);
+ });
+
+ const rec = FakeSpeechRecognition.instances[0];
+ act(() => {
+ rec.emitResult('Verwerfen Test');
+ });
+
+ // Drag up by 80px (> 60px threshold)
+ fireEvent.pointerMove(btn, { clientY: 520 });
+ expect(screen.getByText(/🗑️ Zum Abbrechen loslassen/i)).toBeTruthy();
+
+ fireEvent.pointerUp(btn, { clientY: 520 });
+
+ expect(rec.aborted).toBe(true);
+ expect(onTap).not.toHaveBeenCalled();
+ expect(onTranscript).not.toHaveBeenCalled();
+ });
+
+ it('resumes normal recording if dragged above 60 px and then pulled back down before release', async () => {
+ const onTap = vi.fn();
+ const onTranscript = vi.fn().mockResolvedValue(undefined);
+
+ render( );
+ const btn = screen.getByRole('button', {
+ name: /Schnell erfassen – tippen zum Schreiben, gedrückt halten zum Sprechen/i,
+ });
+
+ fireEvent.pointerDown(btn, { clientY: 600, button: 0 });
+ act(() => {
+ vi.advanceTimersByTime(350);
+ });
+
+ const rec = FakeSpeechRecognition.instances[0];
+ act(() => {
+ rec.emitResult('Doch behalten');
+ });
+
+ // Drag up into cancel zone
+ fireEvent.pointerMove(btn, { clientY: 510 });
+ expect(screen.getByText(/🗑️ Zum Abbrechen loslassen/i)).toBeTruthy();
+
+ // Drag back down below threshold
+ fireEvent.pointerMove(btn, { clientY: 580 });
+ expect(screen.getByText(/Ich höre zu… \(loslassen speichert\)/i)).toBeTruthy();
+
+ await act(async () => {
+ fireEvent.pointerUp(btn, { clientY: 580 });
+ });
+
+ expect(rec.aborted).toBe(false);
+ expect(onTranscript).toHaveBeenCalledWith('Doch behalten');
+ });
+
+ it('shows "Nichts erkannt" and does not save when transcript is empty', async () => {
+ const onTap = vi.fn();
+ const onTranscript = vi.fn();
+
+ render( );
+ const btn = screen.getByRole('button', {
+ name: /Schnell erfassen – tippen zum Schreiben, gedrückt halten zum Sprechen/i,
+ });
+
+ fireEvent.pointerDown(btn, { clientY: 600, button: 0 });
+ act(() => {
+ vi.advanceTimersByTime(350);
+ });
+ await act(async () => {
+ fireEvent.pointerUp(btn, { clientY: 600 });
+ });
+
+ expect(onTap).not.toHaveBeenCalled();
+ expect(onTranscript).not.toHaveBeenCalled();
+ expect(screen.getByRole('status').textContent).toContain('Nichts erkannt');
+ });
+
+ it('falls back to onTap and shows message when microphone permission is denied', () => {
+ const onTap = vi.fn();
+ const onTranscript = vi.fn();
+
+ render( );
+ const btn = screen.getByRole('button', {
+ name: /Schnell erfassen – tippen zum Schreiben, gedrückt halten zum Sprechen/i,
+ });
+
+ fireEvent.pointerDown(btn, { clientY: 600, button: 0 });
+ act(() => {
+ vi.advanceTimersByTime(350);
+ });
+
+ const rec = FakeSpeechRecognition.instances[0];
+ act(() => {
+ rec.emitError('not-allowed');
+ });
+ fireEvent.pointerUp(btn, { clientY: 600 });
+
+ expect(onTap).toHaveBeenCalledTimes(1);
+ expect(onTranscript).not.toHaveBeenCalled();
+ expect(screen.getByRole('status').textContent).toContain('Mikrofon nicht freigegeben');
+ });
+
+ it('falls back to onTap and shows a status message when SpeechRecognition is unsupported', () => {
+ delete (window as any).SpeechRecognition;
+ delete (window as any).webkitSpeechRecognition;
+
+ const onTap = vi.fn();
+ const onTranscript = vi.fn();
+
+ render( );
+ const btn = screen.getByRole('button', {
+ name: /Schnell erfassen – tippen zum Schreiben, gedrückt halten zum Sprechen/i,
+ });
+
+ fireEvent.pointerDown(btn, { clientY: 600, button: 0 });
+ act(() => {
+ vi.advanceTimersByTime(350);
+ });
+ fireEvent.pointerUp(btn, { clientY: 600 });
+
+ expect(onTap).toHaveBeenCalledTimes(1);
+ expect(onTranscript).not.toHaveBeenCalled();
+ expect(screen.getByRole('status').textContent).toContain(
+ 'Spracheingabe wird von diesem Browser nicht unterstützt'
+ );
+ });
+});
diff --git a/frontend/src/features/help/HelpView.tsx b/frontend/src/features/help/HelpView.tsx
index fe47577..e969824 100644
--- a/frontend/src/features/help/HelpView.tsx
+++ b/frontend/src/features/help/HelpView.tsx
@@ -408,7 +408,7 @@ export const HelpView: React.FC = ({ onBack }) => {
Sofortiger Fokus: Drücke irgendwo in der App C oder / (sofern du nicht bereits in einem Textfeld tippst).
- Sprach-Diktat: Klicke auf das Mikrofon-Symbol ( ) für kontinuierliche Spracheingabe über die Web Speech API. Gesprochene Gedanken werden automatisch mit dem Tag #diktat versehen.
+ Sprach-Diktat: Klicke auf das Mikrofon-Symbol ( ) für kontinuierliche Spracheingabe über die Web Speech API. Gesprochene Gedanken werden automatisch mit dem Tag #diktat versehen. Auf dem Smartphone kannst du außerdem den runden + -Button in der unteren Leiste gedrückt halten, um direkt einzusprechen – beim Loslassen landet der Text sofort im Posteingang (nach oben ziehen bricht ab).
Ziel-Auswahl: Gedanken landen standardmäßig im Posteingang. Über die Optionen kannst du sie jedoch sofort als Aufgabe (mit Priorität und Fälligkeit), Listeneintrag (z.B. auf die Einkaufsliste) oder neue Notiz routen.
diff --git a/frontend/src/test/setup.ts b/frontend/src/test/setup.ts
index 01a7765..c376651 100644
--- a/frontend/src/test/setup.ts
+++ b/frontend/src/test/setup.ts
@@ -4,6 +4,19 @@ import { cleanup } from '@testing-library/react';
// jsdom has no layout engine, so scrolling is a no-op here
Element.prototype.scrollIntoView = () => {};
+if (typeof window !== 'undefined' && !window.PointerEvent) {
+ class PolyfillPointerEvent extends MouseEvent {
+ pointerId: number;
+ pointerType: string;
+ constructor(type: string, params: PointerEventInit = {}) {
+ super(type, params);
+ this.pointerId = params.pointerId ?? 1;
+ this.pointerType = params.pointerType ?? 'touch';
+ }
+ }
+ (window as any).PointerEvent = PolyfillPointerEvent;
+}
+
afterEach(() => {
cleanup();
});