feat(live-atc): add TextInputPanel, LastTransmissionCard, CommLogRail

Moves the last three inline cockpit blocks out of the page into components,
re-skinned onto the navy/instrument-panel language the rest of the cockpit
already uses (the v-card bg-white/5 glass treatment is gone).

LastTransmissionCard collapses the old four-button row (Mark as faulty / Edit
issue / Reset / Delete) to two icon buttons, per the design doc. Nothing is
lost: "Reset" was a duplicate of the issue dialog's own "Remove flag" action,
so the amber flag icon opens that dialog and a plain × dismisses the card.
resetLastTransmissionFault therefore has no caller left on the page.

CommLogRail wraps the readback-check block plus CommLog for the desktop rail;
the .pm-readback-* styles move with it. The mobile Log tab keeps using
CommLog directly, as the design doc specifies.

Verified by rendering all three on a throwaway route with fake data: the
suggestion chip fills the text field through v-model, the flag button flips
the card into its faulty skin, and the readback rows render matched/missing.
Typecheck clean, 167/167 tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
itsrubberduck
2026-07-10 16:48:45 +02:00
parent 39a3650b4e
commit 9f45e49f5a
4 changed files with 392 additions and 184 deletions

View File

@@ -0,0 +1,89 @@
<script setup lang="ts">
import type { ReadbackFieldDetail } from '~/composables/useRadioBackend'
withDefaults(defineProps<{
log: readonly any[]
/** Per-field STT readback breakdown for the last pilot call; empty hides the block. */
readbackReport: readonly ReadbackFieldDetail[]
readbackTranscript: string
limit?: number
dense?: boolean
}>(), {
limit: 30,
dense: true,
})
const emit = defineEmits<{ (e: 'clear'): void }>()
</script>
<template>
<div class="rail">
<!-- STT readback check: what was recognised vs missing in the last call. -->
<div v-if="readbackReport.length" class="pm-readback-check">
<div class="pm-readback-head">
<v-icon size="15">mdi-magnify-scan</v-icon>
<span>Readback check</span>
</div>
<p v-if="readbackTranscript" class="pm-readback-heard">heard: {{ readbackTranscript }}</p>
<div
v-for="r in readbackReport"
:key="r.field"
class="pm-readback-row"
:class="r.matched ? 'is-ok' : 'is-missing'"
>
<v-icon size="13">{{ r.matched ? 'mdi-check-circle' : 'mdi-close-circle' }}</v-icon>
<span class="pm-readback-text">
<span class="pm-readback-field">{{ r.field }}</span>
= <span class="pm-readback-expected">{{ r.expected || '—' }}</span>
<template v-if="r.matched"> {{ r.matched_via }}</template>
<template v-else>
not recognised<span v-if="r.accepted_forms.length" class="pm-readback-forms">
(say: {{ r.accepted_forms.join(' / ') }})</span>
</template>
</span>
</div>
</div>
<CommLog :entries="log" :limit="limit" :dense="dense" @clear="emit('clear')" />
</div>
</template>
<style scoped>
.pm-readback-check {
margin-bottom: 10px;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 12px;
background: color-mix(in srgb, var(--text) 4%, transparent);
font-size: 11.5px;
}
.pm-readback-head {
display: flex;
align-items: center;
gap: 6px;
text-transform: uppercase;
letter-spacing: .12em;
font-size: 10px;
font-weight: 600;
color: color-mix(in srgb, var(--text) 55%, transparent);
margin-bottom: 6px;
}
.pm-readback-heard {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
color: color-mix(in srgb, var(--text) 50%, transparent);
margin-bottom: 6px;
font-size: 11px;
}
.pm-readback-row {
display: flex;
align-items: flex-start;
gap: 6px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
line-height: 1.5;
}
.pm-readback-row.is-ok { color: #6ee7a8; }
.pm-readback-row.is-missing { color: #fca5a5; }
.pm-readback-field { color: var(--t2); }
.pm-readback-expected { color: var(--text); font-weight: 600; }
.pm-readback-forms { color: var(--t4); }
</style>

View File

@@ -0,0 +1,172 @@
<script setup lang="ts">
defineProps<{
/** The transcribed (or typed) text of the pilot's last transmission. */
text: string
faulty: boolean
faultNote: string
}>()
const emit = defineEmits<{
(e: 'flag-issue'): void
(e: 'clear'): void
}>()
</script>
<template>
<section class="ltx" :class="{ 'is-faulty': faulty }" aria-label="Last transmission">
<header class="ltx__head">
<v-icon size="14" class="ltx__icon">mdi-microphone-outline</v-icon>
<span class="ltx__title">Last transmission</span>
<span v-if="faulty" class="ltx__badge">Faulty</span>
<div class="ltx__actions">
<!-- One entry point instead of the old four-button row: the issue dialog
itself carries "Remove flag", so a dedicated reset button was dead weight. -->
<v-tooltip :text="faulty ? 'Edit issue' : 'Mark as faulty'" location="top">
<template #activator="{ props: tip }">
<button
v-bind="tip"
type="button"
class="ltx__btn ltx__btn--flag"
:aria-label="faulty ? 'Edit issue' : 'Mark as faulty'"
@click="emit('flag-issue')"
>
<v-icon size="16">mdi-alert-circle-outline</v-icon>
</button>
</template>
</v-tooltip>
<v-tooltip text="Dismiss" location="top">
<template #activator="{ props: tip }">
<button
v-bind="tip"
type="button"
class="ltx__btn"
aria-label="Dismiss last transmission"
@click="emit('clear')"
>
<v-icon size="16">mdi-close</v-icon>
</button>
</template>
</v-tooltip>
</div>
</header>
<p class="ltx__text">{{ text }}</p>
<p v-if="faulty" class="ltx__note">
{{ faultNote || 'Marked as faulty.' }}
</p>
</section>
</template>
<style scoped>
.ltx {
border-radius: 18px;
border: 1px solid rgba(34, 211, 238, 0.24);
background: rgba(34, 211, 238, 0.07);
padding: 12px 14px 14px;
}
.ltx.is-faulty {
border-color: rgba(248, 113, 113, 0.32);
background: rgba(248, 113, 113, 0.08);
}
.ltx__head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.ltx__icon,
.ltx__title {
color: #67e8f9;
}
.ltx.is-faulty .ltx__icon,
.ltx.is-faulty .ltx__title {
color: #fca5a5;
}
.ltx__title {
font-size: 9.5px;
font-weight: 700;
letter-spacing: 0.24em;
text-transform: uppercase;
}
.ltx__badge {
font-size: 9px;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
color: #2b0606;
background: #f87171;
border-radius: 999px;
padding: 2px 7px;
}
.ltx__actions {
display: flex;
align-items: center;
gap: 4px;
margin-left: auto;
}
.ltx__btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 9px;
border: 1px solid transparent;
color: var(--t3);
background: transparent;
transition: color 120ms ease, background 120ms ease, border-color 120ms ease;
}
.ltx__btn:hover {
color: var(--text);
background: color-mix(in srgb, var(--text) 8%, transparent);
}
.ltx__btn--flag {
color: #fbbf24;
}
.ltx__btn--flag:hover {
color: #fcd34d;
border-color: rgba(251, 191, 36, 0.4);
background: rgba(251, 191, 36, 0.12);
}
.ltx.is-faulty .ltx__btn--flag {
color: #fca5a5;
}
.ltx.is-faulty .ltx__btn--flag:hover {
color: #fecaca;
border-color: rgba(248, 113, 113, 0.45);
background: rgba(248, 113, 113, 0.14);
}
.ltx__text {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 13px;
line-height: 1.5;
white-space: pre-line;
color: var(--text);
}
.ltx__note {
margin-top: 10px;
padding: 7px 10px;
border-radius: 10px;
border: 1px solid rgba(248, 113, 113, 0.28);
background: rgba(248, 113, 113, 0.1);
font-size: 12px;
color: #fecaca;
}
</style>

View File

@@ -0,0 +1,109 @@
<script setup lang="ts">
defineProps<{
activeFrequency: string
/** The phrase the backend expects next; offered as a one-tap fill. */
expectedPhrase: string
}>()
const emit = defineEmits<{ (e: 'send'): void }>()
const pilotInput = defineModel<string>({ required: true })
</script>
<template>
<section class="txt" aria-label="Text transmission">
<header class="txt__head">
<span class="txt__title">Text input</span>
<span class="txt__freq">{{ activeFrequency || '---.---' }}</span>
</header>
<v-text-field
v-model="pilotInput"
label="Pilot transmission"
variant="outlined"
color="cyan"
density="comfortable"
hide-details
append-inner-icon="mdi-send"
@keyup.enter="emit('send')"
@click:append-inner="emit('send')"
/>
<div v-if="expectedPhrase" class="txt__suggest">
<span class="txt__suggest-label">Suggested phrase</span>
<button type="button" class="txt__suggest-chip" @click="pilotInput = expectedPhrase">
{{ expectedPhrase }}
</button>
</div>
</section>
</template>
<style scoped>
.txt {
border-radius: 18px;
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);
padding: 14px;
}
.txt__head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.txt__title {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.24em;
text-transform: uppercase;
color: var(--t4);
}
.txt__freq {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;
font-variant-numeric: tabular-nums;
color: #67e8f9;
}
.txt__suggest {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
.txt__suggest-label {
font-size: 9.5px;
font-weight: 700;
letter-spacing: 0.24em;
text-transform: uppercase;
color: var(--t4);
}
.txt__suggest-chip {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;
color: #67e8f9;
border: 1px solid rgba(34, 211, 238, 0.35);
background: rgba(34, 211, 238, 0.08);
border-radius: 999px;
padding: 4px 12px;
text-align: left;
transition: background 130ms ease, border-color 130ms ease;
}
.txt__suggest-chip:hover {
background: rgba(34, 211, 238, 0.16);
border-color: rgba(34, 211, 238, 0.5);
}
</style>

View File

@@ -305,39 +305,12 @@
<!-- Manual text input (text mode) -->
<div v-show="activeTab === 'funk' && inputMode === 'text'" 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">
<h3 class="text-lg font-semibold">Text Input</h3>
<v-chip size="small" color="cyan" variant="outlined" class="font-mono">{{ frequencies.active || '---' }}</v-chip>
</div>
<v-text-field
v-model="pilotInput"
label="Pilot Transmission (Text)"
variant="outlined"
color="cyan"
hide-details
@keyup.enter="sendPilotText"
append-inner-icon="mdi-send"
@click:append-inner="sendPilotText"
/>
<div v-if="backendExpectedPhrase" class="space-y-1">
<p class="text-[11px] uppercase tracking-[0.25em] text-white/40">Suggested phrase</p>
<div class="flex flex-wrap gap-2">
<v-chip
size="small"
color="cyan"
variant="outlined"
class="cursor-pointer text-xs font-mono"
@click="pilotInput = backendExpectedPhrase"
>
{{ backendExpectedPhrase }}
</v-chip>
</div>
</div>
</v-card-text>
</v-card>
<TextInputPanel
v-model="pilotInput"
:active-frequency="frequencies.active"
:expected-phrase="backendExpectedPhrase"
@send="sendPilotText"
/>
</div>
<!-- Last Transmission (funk) -->
@@ -346,92 +319,13 @@
v-if="lastTransmission"
class="pm-block"
>
<v-card
:class="[
'border backdrop-blur',
lastTransmissionFaulty
? 'bg-red-500/10 border-red-400/30'
: 'bg-cyan-500/10 border-cyan-400/20'
]"
>
<v-card-text class="space-y-3">
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="flex items-center gap-2">
<v-icon
icon="mdi-microphone-outline"
size="16"
:class="lastTransmissionFaulty ? 'text-red-300' : 'text-cyan-300'"
/>
<span
class="text-xs uppercase tracking-[0.3em]"
:class="lastTransmissionFaulty ? 'text-red-300' : 'text-cyan-300'"
>
Last transmission
</span>
</div>
<div class="flex flex-wrap items-center gap-1">
<v-chip
v-if="lastTransmissionFaulty"
size="x-small"
color="red"
variant="flat"
>
Faulty
</v-chip>
<v-btn
variant="text"
size="small"
:color="lastTransmissionFaulty ? 'red' : 'amber'"
class="text-[0.65rem] uppercase tracking-[0.3em]"
@click="startTransmissionIssueFlow"
>
<template #prepend>
<v-icon size="16">mdi-alert-circle-outline</v-icon>
</template>
{{ lastTransmissionFaulty ? 'Edit issue' : 'Mark as faulty' }}
</v-btn>
<v-btn
v-if="lastTransmissionFaulty"
variant="text"
size="small"
color="cyan"
class="text-[0.65rem] uppercase tracking-[0.3em]"
@click="resetLastTransmissionFault"
>
<template #prepend>
<v-icon size="16">mdi-restore</v-icon>
</template>
Reset
</v-btn>
<v-btn
variant="text"
size="small"
color="cyan"
class="text-[0.65rem] uppercase tracking-[0.3em]"
@click="clearLastTransmission"
>
<template #prepend>
<v-icon size="16">mdi-close</v-icon>
</template>
Delete
</v-btn>
</div>
</div>
<p class="text-sm text-white font-mono whitespace-pre-line">{{ lastTransmission }}</p>
<div
v-if="lastTransmissionFaulty"
class="space-y-2 rounded-xl border border-red-400/30 bg-red-500/10 px-3 py-2"
>
<p class="text-xs uppercase tracking-[0.25em] text-red-200/80">Issue description</p>
<p v-if="lastTransmissionFaultNote" class="text-sm text-red-100">
{{ lastTransmissionFaultNote }}
</p>
<p v-else class="text-xs text-red-200/70">
Marked as faulty.
</p>
</div>
</v-card-text>
</v-card>
<LastTransmissionCard
:text="lastTransmission"
:faulty="lastTransmissionFaulty"
:fault-note="lastTransmissionFaultNote"
@flag-issue="startTransmissionIssueFlow"
@clear="clearLastTransmission"
/>
</div>
<!-- =============== LOG TAB (mobile only) =============== -->
@@ -444,32 +338,12 @@
<!-- Desktop log rail -->
<aside class="pm-lograil">
<!-- STT readback check: what was recognised vs missing in the last call. -->
<div v-if="lastReadbackReport.length" class="pm-readback-check">
<div class="pm-readback-head">
<v-icon size="15">mdi-magnify-scan</v-icon>
<span>Readback check</span>
</div>
<p v-if="lastReadbackTranscript" class="pm-readback-heard">heard: “{{ lastReadbackTranscript }}”</p>
<div
v-for="r in lastReadbackReport"
:key="r.field"
class="pm-readback-row"
:class="r.matched ? 'is-ok' : 'is-missing'"
>
<v-icon size="13">{{ r.matched ? 'mdi-check-circle' : 'mdi-close-circle' }}</v-icon>
<span class="pm-readback-text">
<span class="pm-readback-field">{{ r.field }}</span>
= <span class="pm-readback-expected">{{ r.expected || '—' }}</span>
<template v-if="r.matched"> → “{{ r.matched_via }}”</template>
<template v-else>
→ not recognised<span v-if="r.accepted_forms.length" class="pm-readback-forms">
(say: {{ r.accepted_forms.join(' / ') }})</span>
</template>
</span>
</div>
</div>
<CommLog :entries="log" :limit="30" dense @clear="clearLog" />
<CommLogRail
:log="log"
:readback-report="lastReadbackReport"
:readback-transcript="lastReadbackTranscript"
@clear="clearLog"
/>
</aside>
</div>
@@ -608,6 +482,9 @@ 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 TextInputPanel from '~/components/live-atc/cockpit/TextInputPanel.vue'
import LastTransmissionCard from '~/components/live-atc/cockpit/LastTransmissionCard.vue'
import CommLogRail from '~/components/live-atc/cockpit/CommLogRail.vue'
import { useAtisPlayback } from '~/composables/useAtisPlayback'
import { usePttRecording } from '~/composables/usePttRecording'
import { useBugReport } from '~/composables/useBugReport'
@@ -750,7 +627,6 @@ const {
transmissionIssueNote,
setLastTransmission,
clearLastTransmission,
resetLastTransmissionFault,
startTransmissionIssueFlow,
confirmTransmissionIssue,
removeTransmissionIssue,
@@ -1564,44 +1440,6 @@ onUnmounted(() => {
display: none;
}
.pm-readback-check {
margin-bottom: 10px;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 12px;
background: color-mix(in srgb, var(--text) 4%, transparent);
font-size: 11.5px;
}
.pm-readback-head {
display: flex;
align-items: center;
gap: 6px;
text-transform: uppercase;
letter-spacing: .12em;
font-size: 10px;
font-weight: 600;
color: color-mix(in srgb, var(--text) 55%, transparent);
margin-bottom: 6px;
}
.pm-readback-heard {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
color: color-mix(in srgb, var(--text) 50%, transparent);
margin-bottom: 6px;
font-size: 11px;
}
.pm-readback-row {
display: flex;
align-items: flex-start;
gap: 6px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
line-height: 1.5;
}
.pm-readback-row.is-ok { color: #6ee7a8; }
.pm-readback-row.is-missing { color: #fca5a5; }
.pm-readback-field { color: var(--t2); }
.pm-readback-expected { color: var(--text); font-weight: 600; }
.pm-readback-forms { color: var(--t4); }
/* Setup screens sit above the shared radar backdrop. */
.pm-setup {
position: relative;