feat: weather precip/wind, dashboard mic, remove global voice overlay

- WeatherCard: show precipitation (mm) and max wind speed per forecast day
- DashboardChatInput: add PTT mic button (transcribe-to-input, voice-gated)
- Remove global VoiceOverlay floating button and Space PTT shortcut from
  App.vue — inline mic buttons in chat/briefing/dashboard are the right UX;
  global overlay had focus/latency/context issues

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-30 20:00:06 -04:00
parent 76c3dbc4b7
commit b4b4b0d9d6
5 changed files with 88 additions and 28 deletions
-25
View File
@@ -3,7 +3,6 @@ import { onMounted, onUnmounted, ref, watch } from "vue";
import { useRouter } from "vue-router";
import AppHeader from "@/components/AppHeader.vue";
import ToastNotification from "@/components/ToastNotification.vue";
import VoiceOverlay from "@/components/VoiceOverlay.vue";
import { useTheme } from "@/composables/useTheme";
import { useShortcuts } from "@/composables/useShortcuts";
import { useAuthStore } from "@/stores/auth";
@@ -48,21 +47,9 @@ function clearPrefix() {
prefixTimeout = null;
}
let spaceHeld = false;
function onGlobalKeydown(e: KeyboardEvent) {
if (!authStore.isAuthenticated) return;
// Space — PTT start (only when not typing, no modifiers, no repeat)
if (e.key === " " && !isInputActive() && !e.ctrlKey && !e.metaKey && !e.altKey && !e.repeat) {
e.preventDefault();
if (!spaceHeld) {
spaceHeld = true;
document.dispatchEvent(new CustomEvent("voice:ptt-toggle"));
}
return;
}
// ? — toggle shortcuts overlay (only when not typing)
if (e.key === "?" && !isInputActive() && !e.ctrlKey && !e.metaKey) {
toggleShortcuts();
@@ -135,16 +122,8 @@ function onGlobalKeydown(e: KeyboardEvent) {
}
}
function onGlobalKeyup(e: KeyboardEvent) {
if (e.key === " " && spaceHeld) {
spaceHeld = false;
document.dispatchEvent(new CustomEvent("voice:ptt-toggle"));
}
}
onMounted(async () => {
document.addEventListener("keydown", onGlobalKeydown);
document.addEventListener("keyup", onGlobalKeyup);
await authStore.checkAuth();
if (authStore.isAuthenticated) {
startAppServices();
@@ -170,7 +149,6 @@ watch(
onUnmounted(() => {
document.removeEventListener("keydown", onGlobalKeydown);
document.removeEventListener("keyup", onGlobalKeyup);
stopAppServices();
});
</script>
@@ -186,9 +164,6 @@ onUnmounted(() => {
<footer class="app-footer">v{{ appVersion }}</footer>
</div>
<!-- Global voice PTT overlay -->
<VoiceOverlay />
<!-- Keyboard shortcuts overlay -->
<Transition name="shortcuts-fade">
<div v-if="showShortcuts" class="shortcuts-overlay" @click.self="closeShortcuts">
+69 -2
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { ref, nextTick } from "vue";
import { apiGet } from "@/api/client";
import { ref, nextTick, onMounted } from "vue";
import { apiGet, getVoiceStatus, transcribeAudio } from "@/api/client";
import { useVoiceRecorder } from "@/composables/useVoiceRecorder";
import { useChatStore } from "@/stores/chat";
import type { Note } from "@/types/note";
@@ -106,6 +107,39 @@ function focus() {
inputEl.value?.focus();
}
// Voice PTT
const voiceEnabled = ref(false);
const transcribingVoice = ref(false);
const recorder = useVoiceRecorder();
onMounted(async () => {
try {
const status = await getVoiceStatus();
voiceEnabled.value = status.enabled && status.stt;
} catch { /* voice absent */ }
});
async function startPtt() {
if (!voiceEnabled.value || recorder.recording.value) return;
await recorder.startRecording();
}
async function stopPtt() {
if (!recorder.recording.value) return;
transcribingVoice.value = true;
try {
const blob = await recorder.stopRecording();
const { transcript } = await transcribeAudio(blob);
if (transcript.trim()) {
messageInput.value = (messageInput.value ? messageInput.value + " " : "") + transcript.trim();
await nextTick();
autoResize();
}
} catch { /* transcription failed silently */ } finally {
transcribingVoice.value = false;
}
}
defineExpose({ focus });
</script>
@@ -188,6 +222,27 @@ defineExpose({ focus });
rows="1"
></textarea>
<button
v-if="voiceEnabled && recorder.isSupported"
class="btn-attach btn-mic-ptt"
:class="{ 'mic-recording': recorder.recording.value, 'mic-transcribing': transcribingVoice }"
@mousedown.prevent="startPtt"
@mouseup.prevent="stopPtt"
@touchstart.prevent="startPtt"
@touchend.prevent="stopPtt"
:disabled="transcribingVoice || !store.chatReady"
:title="recorder.recording.value ? 'Release to send' : 'Hold to speak'"
aria-label="Push to talk"
>
<svg v-if="!transcribingVoice" width="17" height="17" viewBox="0 0 24 24" 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 3zm-1-9c0-.55.45-1 1-1s1 .45 1 1v6c0 .55-.45 1-1 1s-1-.45-1-1V5zm6 6c0 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>
<svg v-else width="17" height="17" viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="12" r="8" opacity="0.35"/>
<circle cx="12" cy="12" r="4"/>
</svg>
</button>
<button
class="btn-send"
@click="onSubmit"
@@ -348,4 +403,16 @@ defineExpose({ focus });
opacity: 0.35;
cursor: default;
}
.btn-mic-ptt {
transition: color 0.15s, opacity 0.15s;
}
.btn-mic-ptt.mic-recording {
color: #ef4444;
opacity: 1;
}
.btn-mic-ptt.mic-transcribing {
color: var(--color-primary);
opacity: 1;
}
</style>
+16
View File
@@ -6,6 +6,8 @@ interface ForecastDay {
condition: string
high: number
low: number
precip_mm: number
windspeed_max: number
}
interface WeatherData {
@@ -86,6 +88,9 @@ const fetchedAtLabel = computed(() => {
<span class="forecast-day-name">{{ day.day }}</span>
<span class="forecast-icon">{{ weatherIcon(day.condition) }}</span>
<span class="forecast-temps">{{ day.high }}° / {{ day.low }}°</span>
<span v-if="day.precip_mm > 0" class="forecast-precip">💧 {{ day.precip_mm }}mm</span>
<span v-else class="forecast-precip forecast-precip--dry"></span>
<span class="forecast-wind">💨 {{ day.windspeed_max }}km/h</span>
</div>
</div>
</div>
@@ -185,6 +190,17 @@ const fetchedAtLabel = computed(() => {
white-space: nowrap;
}
.forecast-precip,
.forecast-wind {
font-size: 0.72rem;
white-space: nowrap;
color: var(--color-text-muted);
}
.forecast-precip--dry {
opacity: 0.35;
}
.weather-unavailable {
color: var(--color-text-muted);
font-style: italic;
+1 -1
View File
@@ -35,7 +35,7 @@ interface WeatherData {
today_low: number | null
yesterday_high: number | null
yesterday_low: number | null
forecast: { day: string; condition: string; high: number; low: number }[]
forecast: { day: string; condition: string; high: number; low: number; precip_mm: number; windspeed_max: number }[]
}
const chatStore = useChatStore()