feat(live-atc): dock the expected-comm hint onto a round, glowing PTT pad

The PTT pad is the control the pilot uses most, but it read as one card among
several. It is now a circular transmit key that lights up cyan at rest and
green while transmitting, with the active frequency called out underneath.

Expected Communication shrinks from a full card to a two-line strip (ATC line,
pilot line) sharing a frame with the pad, so it reads as guidance for the next
transmission rather than as general reading material. The strip still renders
in text mode when the learning aid has something to say, and disappears with
the learningMode toggle as before.

The pilot line's icon was `mdi-account-pilot`, which is not an MDI icon and
rendered as an empty glyph; it is now `mdi-headset`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
itsrubberduck
2026-07-10 16:26:30 +02:00
parent 4a143de4a8
commit e836edd108
3 changed files with 423 additions and 130 deletions

View File

@@ -0,0 +1,139 @@
<script setup lang="ts">
defineProps<{
/** What ATC just said, or is about to say — rendered as the upper line. */
controllerSay: string
/** The phrase the pilot is expected to transmit next — the lower line. */
expectedPhrase: string
}>()
const showPronunciation = defineModel<boolean>('showPronunciation', { required: true })
</script>
<template>
<section class="ecs" aria-label="Expected communication">
<header class="ecs__head">
<span class="ecs__title">Expected</span>
<v-tooltip
:text="showPronunciation ? 'Switch to plain text' : 'Switch to radio pronunciation'"
location="top"
>
<template #activator="{ props: tip }">
<button
v-bind="tip"
type="button"
class="ecs__toggle"
:class="{ 'is-on': showPronunciation }"
:aria-pressed="showPronunciation ? 'true' : 'false'"
aria-label="Toggle radio pronunciation"
@click="showPronunciation = !showPronunciation"
>
<v-icon size="15">{{ showPronunciation ? 'mdi-text' : 'mdi-radio' }}</v-icon>
</button>
</template>
</v-tooltip>
</header>
<p v-if="controllerSay" class="ecs__row ecs__row--atc">
<v-icon size="14" class="ecs__icon">mdi-radio-tower</v-icon>
<span class="ecs__role">ATC</span>
<span class="ecs__text">{{ controllerSay }}</span>
</p>
<p v-if="expectedPhrase" class="ecs__row ecs__row--pilot">
<v-icon size="14" class="ecs__icon">mdi-headset</v-icon>
<span class="ecs__role">You</span>
<span class="ecs__text">{{ expectedPhrase }}</span>
</p>
</section>
</template>
<style scoped>
.ecs {
padding: 10px 12px 12px;
border-bottom: 1px solid var(--border);
background: color-mix(in srgb, var(--text) 3%, transparent);
}
.ecs__head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.ecs__title {
font-size: 9.5px;
font-weight: 700;
letter-spacing: 0.24em;
text-transform: uppercase;
color: var(--t4);
}
.ecs__toggle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border-radius: 8px;
border: 1px solid var(--border);
color: var(--t3);
background: transparent;
transition: color 120ms ease, border-color 120ms ease, background 120ms ease;
}
.ecs__toggle:hover {
color: #67e8f9;
border-color: rgba(34, 211, 238, 0.4);
}
.ecs__toggle.is-on {
color: #67e8f9;
border-color: rgba(34, 211, 238, 0.45);
background: rgba(34, 211, 238, 0.1);
}
/* One line per speaker: role tag, then the phrase. Wraps rather than truncates —
a clipped clearance is worse than a two-line strip. */
.ecs__row {
display: flex;
align-items: baseline;
gap: 7px;
padding: 5px 0;
font-size: 12.5px;
line-height: 1.45;
}
.ecs__row + .ecs__row {
border-top: 1px solid color-mix(in srgb, var(--text) 6%, transparent);
}
.ecs__icon {
align-self: center;
flex-shrink: 0;
}
.ecs__role {
flex-shrink: 0;
font-size: 9px;
font-weight: 700;
letter-spacing: 0.16em;
text-transform: uppercase;
}
.ecs__text {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
color: var(--text);
min-width: 0;
}
.ecs__row--atc .ecs__icon,
.ecs__row--atc .ecs__role {
color: #6ee7a8;
}
.ecs__row--pilot .ecs__icon,
.ecs__row--pilot .ecs__role {
color: #7dd3fc;
}
</style>

View File

@@ -0,0 +1,243 @@
<script setup lang="ts">
defineProps<{
isRecording: boolean
micPermission: boolean
/** True when the desktop Bridge companion has a PTT hotkey bound. */
bridgePttConnected: boolean
activeFrequency: string
}>()
const emit = defineEmits<{
(e: 'start'): void
(e: 'stop'): void
(e: 'request-mic'): void
}>()
</script>
<template>
<div class="ptt">
<v-alert
v-if="!micPermission"
type="info"
variant="tonal"
class="bg-cyan-500/10 text-cyan-100 mb-4"
density="compact"
>
Microphone permission required for push-to-talk.
<template #append>
<v-btn color="cyan" size="small" variant="flat" @click="emit('request-mic')">Allow</v-btn>
</template>
</v-alert>
<ClientOnly>
<button
type="button"
class="ptt-btn"
:class="isRecording ? 'is-live' : 'is-idle'"
:aria-pressed="isRecording ? 'true' : 'false'"
aria-label="Hold to transmit"
@touchstart.prevent="emit('start')"
@touchend.prevent="emit('stop')"
@touchcancel.prevent="emit('stop')"
@mousedown.prevent="emit('start')"
@mouseup.prevent="emit('stop')"
@mouseleave="emit('stop')"
>
<v-icon :icon="isRecording ? 'mdi-access-point' : 'mdi-radio-handheld'" size="52" class="ptt-btn__icon" />
<span class="ptt-btn__label">{{ isRecording ? 'Transmitting' : 'Hold to transmit' }}</span>
</button>
</ClientOnly>
<p v-if="bridgePttConnected" class="ptt-hotkey" :class="{ 'is-live': isRecording }">
<span class="ptt-hotkey__dot" aria-hidden="true" />
{{ isRecording ? 'Hotkey transmitting' : 'Hotkey armed' }}
</p>
<div class="ptt-freq">
<span class="ptt-freq__value">{{ activeFrequency || '---' }}</span>
<span class="ptt-freq__label">Active frequency</span>
</div>
</div>
</template>
<style scoped>
.ptt {
display: flex;
flex-direction: column;
align-items: center;
padding: 20px 14px 22px;
}
.ptt :deep(.v-alert) {
width: 100%;
}
/* The single most-used control on the screen, so it reads as a physical
transmit key: round, raised, and lit from within. */
.ptt-btn {
position: relative;
display: inline-flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
width: clamp(148px, 44vw, 188px);
aspect-ratio: 1;
border-radius: 999px;
/* 2px at 0.28 opacity, not a hairline: the border has to survive bright
ambient light on a tablet in a real cockpit. */
border: 2px solid rgba(255, 255, 255, 0.28);
background:
radial-gradient(circle at 50% 34%,
rgba(34, 211, 238, 0.16),
rgba(4, 8, 16, 0.9) 68%);
color: #67e8f9;
cursor: pointer;
user-select: none;
-webkit-user-select: none;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
transition: transform 120ms ease, border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
}
.ptt-btn.is-idle {
box-shadow:
0 0 0 1px rgba(34, 211, 238, 0.2),
0 0 34px -8px rgba(34, 211, 238, 0.5),
0 18px 38px -18px rgba(0, 0, 0, 0.9);
}
.ptt-btn.is-idle:hover {
border-color: rgba(34, 211, 238, 0.6);
box-shadow:
0 0 0 1px rgba(34, 211, 238, 0.32),
0 0 46px -6px rgba(34, 211, 238, 0.62),
0 18px 38px -18px rgba(0, 0, 0, 0.9);
}
.ptt-btn.is-live {
border-color: rgba(74, 222, 128, 0.75);
background:
radial-gradient(circle at 50% 34%,
rgba(34, 197, 94, 0.32),
rgba(4, 12, 8, 0.9) 68%);
color: #86efac;
transform: scale(0.97);
box-shadow:
0 0 0 6px rgba(34, 197, 94, 0.22),
0 0 60px -4px rgba(34, 197, 94, 0.75);
animation: ptt-live-glow 1.4s ease-in-out infinite;
}
@keyframes ptt-live-glow {
0%, 100% { box-shadow: 0 0 0 6px rgba(34, 197, 94, 0.22), 0 0 60px -4px rgba(34, 197, 94, 0.75); }
50% { box-shadow: 0 0 0 10px rgba(34, 197, 94, 0.1), 0 0 74px 0 rgba(34, 197, 94, 0.55); }
}
.ptt-btn__icon {
color: currentColor;
}
.ptt-btn__label {
font-size: 10px;
font-weight: 600;
letter-spacing: 0.3em;
text-transform: uppercase;
padding-left: 0.3em; /* balances the trailing letter-spacing */
color: currentColor;
}
.ptt-btn.is-idle .ptt-btn__label {
color: var(--t4);
}
.ptt-hotkey {
display: inline-flex;
align-items: center;
gap: 6px;
margin-top: 14px;
font-size: 10px;
letter-spacing: 0.25em;
text-transform: uppercase;
color: rgba(103, 232, 249, 0.7);
}
.ptt-hotkey.is-live {
color: #86efac;
}
.ptt-hotkey__dot {
width: 6px;
height: 6px;
border-radius: 999px;
background: currentColor;
}
.ptt-hotkey.is-live .ptt-hotkey__dot {
animation: ptt-dot-pulse 1s infinite;
}
@keyframes ptt-dot-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.35; }
}
.ptt-freq {
display: flex;
flex-direction: column;
align-items: center;
gap: 1px;
margin-top: 16px;
}
.ptt-freq__value {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: clamp(26px, 8vw, 34px);
font-weight: 700;
line-height: 1.05;
font-variant-numeric: tabular-nums;
letter-spacing: -0.01em;
color: var(--text);
}
.ptt-freq__label {
font-size: 11px;
color: var(--t4);
}
.pm-page[data-theme="light"] .ptt-btn {
border-color: rgba(15, 20, 32, 0.24);
background: radial-gradient(circle at 50% 34%, rgba(8, 145, 178, 0.14), rgba(255, 255, 255, 0.9) 70%);
color: #0e7490;
}
.pm-page[data-theme="light"] .ptt-btn.is-idle {
box-shadow:
0 0 0 1px rgba(8, 145, 178, 0.22),
0 12px 26px -16px rgba(15, 20, 32, 0.5);
}
.pm-page[data-theme="light"] .ptt-btn.is-live {
border-color: rgba(22, 163, 74, 0.6);
background: radial-gradient(circle at 50% 34%, rgba(34, 197, 94, 0.24), rgba(255, 255, 255, 0.92) 70%);
color: #15803d;
}
/* Cyan-on-white washes out; darken the hotkey line for the light skin. */
.pm-page[data-theme="light"] .ptt-hotkey {
color: #0e7490;
}
.pm-page[data-theme="light"] .ptt-hotkey.is-live {
color: #15803d;
}
@media (prefers-reduced-motion: reduce) {
.ptt-btn,
.ptt-btn.is-live,
.ptt-hotkey.is-live .ptt-hotkey__dot {
animation: none;
transition: none;
}
}
</style>

View File

@@ -254,49 +254,6 @@
/>
</div>
<!-- Expected Communication (learning aid) -->
<div
v-show="activeTab === 'funk'"
v-if="learningMode && (displayControllerSay || displayExpectedPhrase)"
class="pm-block"
>
<v-card class="bg-white/5 border border-white/10">
<v-card-text class="space-y-3">
<div class="flex items-center justify-between">
<p class="text-xs uppercase tracking-[0.3em] text-white/40">Expected communication</p>
<v-tooltip :text="showRadioPronunciation ? 'Switch to plain text' : 'Switch to radio pronunciation'" location="top">
<template #activator="{ props: tip }">
<v-btn
v-bind="tip"
:icon="showRadioPronunciation ? 'mdi-text' : 'mdi-radio'"
size="x-small"
variant="text"
:color="showRadioPronunciation ? 'cyan' : 'white'"
@click="showRadioPronunciation = !showRadioPronunciation"
/>
</template>
</v-tooltip>
</div>
<div v-if="displayControllerSay" class="space-y-2 rounded-2xl bg-green-500/10 border border-green-500/20 p-3 text-sm">
<div class="flex items-center gap-2 text-green-300">
<v-icon size="16">mdi-radio-tower</v-icon>
<span class="text-xs uppercase font-semibold">ATC</span>
</div>
<p class="font-mono text-white">{{ displayControllerSay }}</p>
</div>
<div v-if="displayExpectedPhrase" class="space-y-2 rounded-2xl bg-blue-500/10 border border-blue-500/20 p-3 text-sm">
<div class="flex items-center gap-2 text-blue-300">
<v-icon size="16">mdi-account-pilot</v-icon>
<span class="text-xs uppercase font-semibold">Pilot (You)</span>
</div>
<p class="font-mono text-white">{{ displayExpectedPhrase }}</p>
</div>
</v-card-text>
</v-card>
</div>
<!-- Input mode switch: voice / text -->
<div v-show="activeTab === 'funk'" class="pm-block">
<div class="pm-seg">
@@ -321,62 +278,29 @@
</div>
</div>
<!-- Push to Talk (voice mode) -->
<div v-show="activeTab === 'funk' && inputMode === 'voice'" class="pm-block">
<v-sheet class="rounded-3xl border border-white/10 bg-gradient-to-br from-white/5 via-white/10 to-transparent p-4 shadow-lg">
<v-alert
v-if="!micPermission"
type="info"
variant="tonal"
class="bg-cyan-500/10 text-cyan-100 mb-4"
density="compact"
>
Microphone permission required for push-to-talk.
<template #append>
<v-btn color="cyan" size="small" variant="flat" @click="requestMicAccess">Allow</v-btn>
</template>
</v-alert>
<ClientOnly>
<div
class="ptt-pad flex h-52 lg:h-60 items-center justify-center rounded-2xl border-2 text-center transition cursor-pointer"
:class="isRecording ? 'ptt-pad--active border-green-400/50 ring-4 ring-green-400/40 bg-green-500/10' : 'ptt-pad--idle bg-black/40'"
@touchstart.prevent="startRecording(false)"
@touchend.prevent="stopRecording"
@touchcancel.prevent="stopRecording"
@mousedown.prevent="startRecording(false)"
@mouseup.prevent="stopRecording"
@mouseleave="stopRecording"
>
<div class="space-y-1">
<v-icon
:icon="isRecording ? 'mdi-access-point' : 'mdi-radio-handheld'"
size="44"
:class="isRecording ? 'text-green-400 animate-pulse' : 'text-cyan-300'"
/>
<p
class="text-[11px] uppercase tracking-[0.35em] mt-1"
:class="isRecording ? 'text-green-300' : 'text-white/40'"
>
{{ isRecording ? 'Transmitting' : 'Hold to transmit' }}
</p>
<p
v-if="bridgePttConnected"
class="text-[10px] uppercase tracking-[0.25em] flex items-center justify-center gap-1.5"
:class="isRecording ? 'text-green-300' : 'text-cyan-300/70'"
>
<span
class="inline-block h-1.5 w-1.5 rounded-full"
:class="isRecording ? 'bg-green-400 animate-pulse' : 'bg-cyan-300/60'"
/>
{{ isRecording ? 'Hotkey transmitting' : 'Hotkey armed' }}
</p>
<p class="pt-2 text-4xl font-bold font-mono tracking-tight">{{ frequencies.active || '---' }}</p>
<p class="text-xs text-white/45">Active frequency</p>
</div>
</div>
</ClientOnly>
</v-sheet>
<!-- Transmit block: the expected-comm hint is docked to the PTT pad so it
reads as guidance for the next transmission, not as a separate card. -->
<div
v-show="activeTab === 'funk'"
v-if="inputMode === 'voice' || showExpectedComm"
class="pm-block transmit-stack"
>
<ExpectedCommStrip
v-if="learningMode && (displayControllerSay || displayExpectedPhrase)"
:controller-say="displayControllerSay"
:expected-phrase="displayExpectedPhrase"
v-model:show-pronunciation="showRadioPronunciation"
/>
<PttPad
v-show="inputMode === 'voice'"
:is-recording="isRecording"
:mic-permission="micPermission"
:bridge-ptt-connected="bridgePttConnected"
:active-frequency="frequencies.active"
@start="startRecording(false)"
@stop="stopRecording"
@request-mic="requestMicAccess"
/>
</div>
<!-- Manual text input (text mode) -->
@@ -1205,6 +1129,8 @@ import { usePmTheme } from '~/composables/usePmTheme'
import { normalizeManualFreq } from '../../shared/utils/frequency'
import { useFrequencyPresets } from '~/composables/useFrequencyPresets'
import RadioPanel from '~/components/live-atc/cockpit/RadioPanel.vue'
import ExpectedCommStrip from '~/components/live-atc/cockpit/ExpectedCommStrip.vue'
import PttPad from '~/components/live-atc/cockpit/PttPad.vue'
import { useAtisPlayback } from '~/composables/useAtisPlayback'
import { usePttRecording } from '~/composables/usePttRecording'
import { useBugReport } from '~/composables/useBugReport'
@@ -1388,6 +1314,9 @@ const activeTab = ref<'funk' | 'log'>('funk')
const experienceMenu = ref(false)
const inputMode = ref<'voice' | 'text'>('voice')
const learningMode = ref(true)
const showExpectedComm = computed(
() => learningMode.value && Boolean(displayControllerSay.value || displayExpectedPhrase.value),
)
// Overlays / sheets opened from HUD controls
const showFlightSheet = ref(false)
@@ -1693,37 +1622,19 @@ onUnmounted(() => {
</script>
<style scoped>
.ptt-pad {
user-select: none;
-webkit-user-select: none;
touch-action: manipulation;
}
/* Idle state needs a boundary that survives bright ambient light/glare — a
hairline low-opacity border reads as "no border at all" outdoors. Width +
opacity + a soft shadow together are more glare-resistant than opacity alone. */
.ptt-pad--idle {
border-color: rgba(255, 255, 255, 0.28);
box-shadow: 0 0 0 1px rgba(34, 211, 238, 0.16), 0 14px 30px rgba(0, 0, 0, 0.35);
}
.pm-page[data-theme="light"] .ptt-pad--idle {
border-color: rgba(15, 20, 32, 0.24);
background-color: rgba(15, 20, 32, 0.045) !important;
box-shadow: 0 0 0 1px rgba(8, 145, 178, 0.22), 0 10px 24px rgba(15, 20, 32, 0.1);
}
.pm-page[data-theme="light"] .ptt-pad--active {
box-shadow: 0 0 0 4px rgba(34, 197, 94, 0.28);
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
.animate-pulse {
animation: pulse 1s infinite;
/* Expected-comm strip + PTT pad share one frame, so the hint reads as part of
the transmit control rather than as another card above it. */
.transmit-stack {
overflow: hidden;
border-radius: 24px;
border: 1px solid var(--border);
background:
linear-gradient(165deg,
color-mix(in srgb, var(--bg2) 90%, transparent),
color-mix(in srgb, var(--bg) 96%, transparent));
box-shadow:
inset 0 1px 0 color-mix(in srgb, var(--text) 6%, transparent),
0 20px 44px -28px rgba(0, 0, 0, 0.9);
}
/* ---------------------------------------------------------------------------