diff --git a/AGENTS.md b/AGENTS.md index 91c119a..805af39 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -750,18 +750,23 @@ session with **no other memory of this project**. So: it** (it's the point of the screen), then the **mode/vehicle row**, then the **"From" row**, then the scrolling details, with the **Start/Stop bar pinned** at the bottom. The map is `shrink-0` at - `h-[34vh] min-h-[220px] sm:h-[400px]`, or `h-[54vh] min-h-[320px]`/`sm:h-[60vh]` + `h-[56vh] min-h-[300px] sm:h-[60vh]`, or `h-[60vh] min-h-[340px]`/`sm:h-[66vh]` while following. The **summary is a compact one-line strip** (distance · time · arrive) — `StatTile` was **removed**; the three ~90px - tiles were eating the map's space. **Owner feedback (2026-10-01): the map - dominated the phone screen** — it was `50vh` (and `38vh` before that); `34vh` - gives the itinerary and controls room while staying pannable, and follow mode - restores a bigger map. The dialog's tagline ("In-app route · OpenStreetMap - routing — nothing leaves to Google or Apple Maps") was **removed from the - visible chrome** (kept `sr-only` for a11y); that promise now lives on - **/privacy → "What leaves your device" → Directions**. ⚠️ Don't go back to a single - `overflow-hidden` content box: that made everything below the fold - **unreachable** once the map grew. The map carries a metric + tiles were eating the map's space. **Owner feedback (2026-11-02): the map + should be the main thing**; the map grew from `34vh` to `56vh` and the + itinerary/details remain in the scrollable `min-h-0 flex-1 overflow-y-auto` + region. This supersedes the 2026-10-01 “34vh gives the itinerary room” + decision; keep the details scroller, don't make the content box one + `overflow-hidden` block. **Navigation voice (2026-11-02):** TTS follows the + **app language** (`dateLocaleCode()`, covering all 24 locales), not + `navigator.language`; it prefers an installed local voice for the exact + BCP-47 code, then its primary language, and sets `utter.lang` as fallback. + Route prompts already use translated `routeUi.*` keys. The dialog's + tagline (“In-app route · OpenStreetMap routing — nothing leaves to Google + or Apple Maps”) remains **removed from the visible chrome** (kept `sr-only` + for a11y); that promise lives on **/privacy → “What leaves your device” → + Directions**. The map carries a metric `L.control.scale`. **Transit** mode uses **Transitous** (`api.transitous.org`, MOTIS 2, open CORS) — note origin/destination go to that service, unlike road modes. **No external map-app handoff anywhere** (product decision) and no diff --git a/src/components/RouteDialog.tsx b/src/components/RouteDialog.tsx index 3639b28..35c72f8 100644 --- a/src/components/RouteDialog.tsx +++ b/src/components/RouteDialog.tsx @@ -331,6 +331,21 @@ export function RouteDialog({ }, [route]); // ── Voice guidance (device TTS — free, offline, no server, no permission) ─── + // Keep the installed voices fresh; Chrome often fills this list only after + // the first voiceschanged event. + const voicesRef = useRef([]); + + useEffect(() => { + if (typeof window === 'undefined' || !('speechSynthesis' in window)) return; + const synth = window.speechSynthesis; + const refresh = () => { + voicesRef.current = synth.getVoices(); + }; + refresh(); + synth.addEventListener?.('voiceschanged', refresh); + return () => synth.removeEventListener?.('voiceschanged', refresh); + }, []); + const speak = useCallback( (text: string) => { if (!voiceOn) return; @@ -339,24 +354,34 @@ export function RouteDialog({ const synth = window.speechSynthesis; const utter = new SpeechSynthesisUtterance(text); utter.rate = 1; - const lang = (navigator.language || 'en').slice(0, 2).toLowerCase(); - const voices = synth.getVoices(); - // Prefer an on-device voice in the user's language (offline, no network). + + // Follow the APP language, not the phone/OS language. A German user + // with an English device must hear German route prompts. + const lang = dateLocaleCode(); + const normalized = lang.toLowerCase(); + const primary = normalized.split('-')[0]; + const voices = voicesRef.current.length > 0 ? voicesRef.current : synth.getVoices(); const voice = - voices.find((v) => v.localService && v.lang.toLowerCase().startsWith(lang)) ?? - voices.find((v) => v.lang.toLowerCase().startsWith(lang)); + voices.find((v) => v.localService && v.lang.toLowerCase() === normalized) ?? + voices.find((v) => v.localService && v.lang.toLowerCase().startsWith(primary)) ?? + voices.find((v) => v.lang.toLowerCase() === normalized) ?? + voices.find((v) => v.lang.toLowerCase().startsWith(primary)); + if (voice) { utter.voice = voice; utter.lang = voice.lang; } else { utter.lang = lang; } + synth.cancel(); // never let prompts queue up and lag behind you synth.speak(utter); } catch { // No TTS — haptics still guide. } }, + // dateLocaleCode() reads the active app locale at call time, so this + // callback stays correct after a language change. [voiceOn], ); @@ -864,7 +889,7 @@ export function RouteDialog({ `.leaflet-container { height: 100% }` override beats a `h-64` on the map element itself, and against a height-less parent that resolved to 0 — a fully invisible map with no route line. */} -
+