feat(atc): add Live ATC v2 UI components

- TransmissionCard.vue: Communication log entry with expandable debug details
  (LLM decision, engine action, telemetry trigger, readback result)
- PhaseIndicator.vue: Horizontal flight phase progress strip with pulse animation
- PttButton.vue: Hold-to-talk recording with MediaRecorder + visual states
- FrequencyPanel.vue: Radio-style active/standby frequency display + text input

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
itsrubberduck
2026-02-14 19:14:13 +01:00
parent 5a1c06d037
commit 6eec81fc8c
4 changed files with 1359 additions and 0 deletions

View File

@@ -0,0 +1,268 @@
<template>
<div class="freq-panel glass panel">
<!-- Frequency displays -->
<div class="freq-row">
<!-- Active frequency -->
<div class="freq-block freq-block--active">
<span class="freq-label">ACTIVE</span>
<span class="freq-value freq-value--active">{{ activeFreq }}</span>
<span class="freq-unit">{{ activeUnit }}</span>
</div>
<!-- Swap button -->
<button
class="freq-swap-btn"
:disabled="!standbyFreq || loading"
:title="standbyFreq ? 'Swap frequencies' : 'No standby frequency'"
@click="handleSwap"
>
<v-icon icon="mdi-swap-horizontal" :size="20" />
</button>
<!-- Standby frequency -->
<div class="freq-block freq-block--standby">
<span class="freq-label">STBY</span>
<span class="freq-value freq-value--standby">
{{ standbyFreq || '----.---' }}
</span>
<span class="freq-unit freq-unit--standby">
{{ standbyUnit || '---' }}
</span>
</div>
</div>
<!-- Text input -->
<form class="freq-input-row" @submit.prevent="handleSubmit">
<input
v-model="textInput"
type="text"
class="freq-text-input"
placeholder="Type pilot message..."
:disabled="loading"
@keydown.enter.prevent="handleSubmit"
/>
<button
type="submit"
class="freq-send-btn"
:disabled="!textInput.trim() || loading"
title="Send message"
>
<v-icon icon="mdi-send" :size="18" />
</button>
</form>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
defineProps<{
activeFreq: string;
activeUnit: string;
standbyFreq?: string;
standbyUnit?: string;
loading?: boolean;
}>();
const emit = defineEmits<{
(e: 'swap'): void;
(e: 'text-submit', text: string): void;
}>();
const textInput = ref('');
function handleSwap() {
emit('swap');
}
function handleSubmit() {
const text = textInput.value.trim();
if (!text) return;
emit('text-submit', text);
textInput.value = '';
}
</script>
<style scoped>
/* ── Panel layout ── */
.freq-panel {
display: flex;
flex-direction: column;
gap: 14px;
width: 100%;
}
/* ── Frequency row ── */
.freq-row {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
}
.freq-block {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
min-width: 0;
flex: 1;
}
.freq-label {
font-size: 0.65rem;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--t3);
}
/* ── Frequency values ── */
.freq-value {
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', ui-monospace, monospace;
font-variant-numeric: tabular-nums;
line-height: 1;
}
.freq-value--active {
font-size: 1.6rem;
font-weight: 700;
color: var(--accent);
text-shadow: 0 0 14px color-mix(in srgb, var(--accent) 40%, transparent);
}
.freq-value--standby {
font-size: 1.15rem;
font-weight: 500;
color: var(--t3);
}
.freq-unit {
font-size: 0.75rem;
font-weight: 600;
color: var(--accent);
letter-spacing: 0.06em;
}
.freq-unit--standby {
color: var(--t3);
}
@media (min-width: 768px) {
.freq-value--active {
font-size: 1.8rem;
}
.freq-value--standby {
font-size: 1.3rem;
}
}
/* ── Swap button ── */
.freq-swap-btn {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 50%;
border: 1px solid var(--glass-border);
background: linear-gradient(
180deg,
color-mix(in srgb, var(--text) 8%, transparent),
color-mix(in srgb, var(--text) 4%, transparent)
);
color: var(--t2);
cursor: pointer;
transition: transform 0.15s ease, border-color 0.15s ease, color 0.15s ease;
flex-shrink: 0;
}
.freq-swap-btn:hover:not(:disabled) {
border-color: color-mix(in srgb, var(--accent) 50%, transparent);
color: var(--accent);
transform: scale(1.08);
}
.freq-swap-btn:active:not(:disabled) {
transform: scale(0.95);
}
.freq-swap-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
/* ── Text input row ── */
.freq-input-row {
display: flex;
gap: 8px;
align-items: center;
}
.freq-text-input {
flex: 1;
min-width: 0;
padding: 10px 14px;
border-radius: 1rem;
border: 1px solid var(--glass-border);
background: linear-gradient(
180deg,
color-mix(in srgb, var(--text) 7%, transparent),
color-mix(in srgb, var(--text) 3%, transparent)
);
color: var(--text);
font-size: 0.9rem;
outline: none;
transition: border-color 0.2s ease;
backdrop-filter: blur(var(--glass-blur, 18px));
}
.freq-text-input::placeholder {
color: var(--t3);
}
.freq-text-input:focus {
border-color: color-mix(in srgb, var(--accent) 50%, transparent);
}
.freq-text-input:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* ── Send button ── */
.freq-send-btn {
display: flex;
align-items: center;
justify-content: center;
width: 38px;
height: 38px;
border-radius: 50%;
border: none;
background: linear-gradient(
180deg,
color-mix(in srgb, var(--accent) 85%, transparent),
color-mix(in srgb, var(--accent2) 70%, transparent)
);
color: #051217;
cursor: pointer;
flex-shrink: 0;
transition: transform 0.12s ease, box-shadow 0.15s ease;
box-shadow: 0 4px 14px color-mix(in srgb, var(--accent) 30%, transparent);
}
.freq-send-btn:hover:not(:disabled) {
transform: scale(1.08);
box-shadow: 0 6px 20px color-mix(in srgb, var(--accent) 40%, transparent);
}
.freq-send-btn:active:not(:disabled) {
transform: scale(0.95);
}
.freq-send-btn:disabled {
opacity: 0.35;
cursor: not-allowed;
box-shadow: none;
}
</style>

View File

@@ -0,0 +1,237 @@
<template>
<div class="phase-indicator" role="navigation" aria-label="Flight phase progress">
<div
v-for="(phase, i) in phases"
:key="phase.id"
class="phase-step"
:class="{
'phase-step--completed': phaseIndex > i,
'phase-step--current': phaseIndex === i,
'phase-step--future': phaseIndex < i,
}"
>
<!-- Connecting line (before dot, except first) -->
<div
v-if="i > 0"
class="phase-line"
:class="{
'phase-line--completed': phaseIndex >= i,
'phase-line--future': phaseIndex < i,
}"
/>
<!-- Dot -->
<div class="phase-dot-wrap">
<div class="phase-dot">
<div v-if="phaseIndex === i" class="phase-dot-pulse" />
</div>
</div>
<!-- Label -->
<span class="phase-label">{{ phase.abbr }}</span>
</div>
<!-- Emergency overlay -->
<div v-if="isEmergency" class="phase-emergency">
<span class="phase-emergency-badge">EMRG</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{ currentPhase: string }>()
const phases = [
{ id: 'clearance', abbr: 'CLR' },
{ id: 'ground', abbr: 'GND' },
{ id: 'tower', abbr: 'TWR' },
{ id: 'departure', abbr: 'DEP' },
{ id: 'enroute', abbr: 'CTR' },
{ id: 'approach', abbr: 'APP' },
{ id: 'landing', abbr: 'LND' },
{ id: 'taxiIn', abbr: 'TXI' },
] as const
const isEmergency = computed(() => props.currentPhase === 'emergency')
const phaseIndex = computed(() => {
const idx = phases.findIndex(p => p.id === props.currentPhase)
return idx === -1 ? -1 : idx
})
</script>
<style scoped>
.phase-indicator {
position: relative;
display: flex;
align-items: flex-start;
justify-content: space-between;
width: 100%;
height: 50px;
padding: 6px 4px 0;
user-select: none;
}
/* Each step: line + dot + label stacked */
.phase-step {
display: flex;
flex-direction: column;
align-items: center;
position: relative;
flex: 1;
min-width: 0;
}
/* Connecting line */
.phase-line {
position: absolute;
top: 10px;
right: 50%;
width: 100%;
height: 2px;
z-index: 0;
}
.phase-line--completed {
background: var(--accent);
opacity: 0.6;
}
.phase-line--future {
background: repeating-linear-gradient(
90deg,
rgba(255, 255, 255, 0.12) 0,
rgba(255, 255, 255, 0.12) 4px,
transparent 4px,
transparent 8px
);
}
/* Dot wrapper for positioning */
.phase-dot-wrap {
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
}
/* Dot */
.phase-dot {
width: 10px;
height: 10px;
border-radius: 50%;
border: 2px solid rgba(255, 255, 255, 0.18);
background: transparent;
transition: all 0.3s ease;
position: relative;
}
.phase-step--completed .phase-dot {
background: var(--accent);
border-color: var(--accent);
box-shadow: 0 0 6px color-mix(in srgb, var(--accent) 40%, transparent);
}
.phase-step--current .phase-dot {
width: 14px;
height: 14px;
background: var(--accent);
border-color: var(--accent);
box-shadow: 0 0 12px color-mix(in srgb, var(--accent) 50%, transparent);
}
.phase-step--future .phase-dot {
background: transparent;
border-color: rgba(255, 255, 255, 0.18);
}
/* Pulse ring for current */
.phase-dot-pulse {
position: absolute;
inset: -6px;
border-radius: 50%;
border: 2px solid var(--accent);
opacity: 0;
animation: phase-pulse 2s ease-in-out infinite;
}
@keyframes phase-pulse {
0% {
opacity: 0.6;
transform: scale(0.8);
}
50% {
opacity: 0;
transform: scale(1.6);
}
100% {
opacity: 0;
transform: scale(1.6);
}
}
/* Label */
.phase-label {
margin-top: 4px;
font-size: 0.6rem;
font-weight: 600;
letter-spacing: 0.06em;
color: rgba(255, 255, 255, 0.25);
text-transform: uppercase;
white-space: nowrap;
}
.phase-step--completed .phase-label {
color: color-mix(in srgb, var(--accent) 70%, transparent);
}
.phase-step--current .phase-label {
color: var(--accent);
font-weight: 700;
}
/* Emergency badge */
.phase-emergency {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
}
.phase-emergency-badge {
font-size: 0.68rem;
font-weight: 800;
letter-spacing: 0.1em;
color: #ef4444;
background: rgba(239, 68, 68, 0.12);
border: 1px solid rgba(239, 68, 68, 0.4);
border-radius: 6px;
padding: 2px 10px;
animation: emergency-flash 1s ease-in-out infinite alternate;
}
@keyframes emergency-flash {
0% {
opacity: 1;
box-shadow: 0 0 8px rgba(239, 68, 68, 0.4);
}
100% {
opacity: 0.5;
box-shadow: 0 0 2px rgba(239, 68, 68, 0.1);
}
}
/* Responsive: tighten on small screens */
@media (max-width: 400px) {
.phase-label {
font-size: 0.5rem;
}
}
</style>

View File

@@ -0,0 +1,323 @@
<template>
<div class="ptt-wrapper">
<button
class="ptt-btn"
:class="{
'ptt-btn--recording': isRecording,
'ptt-btn--loading': loading,
'ptt-btn--disabled': disabled,
}"
:disabled="disabled || loading"
@mousedown.prevent="startRecording"
@mouseup.prevent="stopRecording"
@mouseleave="stopRecording"
@touchstart.prevent="startRecording"
@touchend.prevent="stopRecording"
@touchcancel="stopRecording"
>
<!-- Pulsing ring (recording state) -->
<span v-if="isRecording" class="ptt-pulse-ring" />
<!-- Spinning ring (loading state) -->
<span v-if="loading" class="ptt-spinner-ring" />
<!-- Default cyan ring -->
<span v-if="!isRecording && !loading" class="ptt-idle-ring" />
<v-icon
:icon="isRecording ? 'mdi-microphone' : 'mdi-microphone-outline'"
:size="28"
:color="isRecording ? '#ef4444' : 'white'"
/>
</button>
<div class="ptt-label">
<span v-if="permissionDenied" class="ptt-status ptt-status--error">
Microphone access denied
</span>
<span v-else-if="isRecording" class="ptt-status ptt-status--recording">
Transmitting...
</span>
<span v-else-if="loading" class="ptt-status ptt-status--loading">
Processing...
</span>
<span v-else class="ptt-status ptt-status--idle">
Hold to transmit
</span>
<span class="ptt-freq">{{ unit }} &middot; {{ frequency }}</span>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
const props = defineProps<{
frequency: string;
unit: string;
disabled?: boolean;
loading?: boolean;
}>();
const emit = defineEmits<{
(e: 'recording-complete', payload: { audio: string; format: string }): void;
}>();
const isRecording = ref(false);
const permissionDenied = ref(false);
let mediaRecorder: MediaRecorder | null = null;
let audioChunks: Blob[] = [];
let stream: MediaStream | null = null;
function getPreferredMimeType(): string {
if (MediaRecorder.isTypeSupported('audio/webm;codecs=opus')) {
return 'audio/webm;codecs=opus';
}
if (MediaRecorder.isTypeSupported('audio/webm')) {
return 'audio/webm';
}
return '';
}
async function startRecording() {
if (props.disabled || props.loading || isRecording.value) return;
permissionDenied.value = false;
try {
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
} catch {
permissionDenied.value = true;
return;
}
audioChunks = [];
const mimeType = getPreferredMimeType();
const options: MediaRecorderOptions = mimeType ? { mimeType } : {};
mediaRecorder = new MediaRecorder(stream, options);
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
audioChunks.push(event.data);
}
};
mediaRecorder.onstop = () => {
const blob = new Blob(audioChunks, {
type: mediaRecorder?.mimeType || 'audio/webm',
});
blobToBase64(blob).then((base64) => {
emit('recording-complete', {
audio: base64,
format: mediaRecorder?.mimeType || 'audio/webm',
});
});
cleanupStream();
};
mediaRecorder.start();
isRecording.value = true;
}
function stopRecording() {
if (!isRecording.value || !mediaRecorder) return;
isRecording.value = false;
if (mediaRecorder.state === 'recording') {
mediaRecorder.stop();
} else {
cleanupStream();
}
}
function cleanupStream() {
if (stream) {
stream.getTracks().forEach((track) => track.stop());
stream = null;
}
mediaRecorder = null;
}
function blobToBase64(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => {
const result = reader.result as string;
// Strip the data URL prefix to return raw base64
const base64 = result.split(',')[1] || result;
resolve(base64);
};
reader.onerror = reject;
reader.readAsDataURL(blob);
});
}
</script>
<style scoped>
.ptt-wrapper {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
}
/* ── Button ── */
.ptt-btn {
position: relative;
width: 80px;
height: 80px;
border-radius: 50%;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(
180deg,
color-mix(in srgb, var(--text) 8%, transparent),
color-mix(in srgb, var(--text) 4%, transparent)
);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.08),
0 6px 18px rgba(0, 0, 0, 0.28);
backdrop-filter: blur(var(--glass-blur, 18px)) saturate(var(--glass-sat, 140%));
-webkit-backdrop-filter: blur(var(--glass-blur, 18px)) saturate(var(--glass-sat, 140%));
transition: transform 0.12s ease, box-shadow 0.15s ease, background 0.2s ease;
outline: none;
touch-action: none;
}
@media (min-width: 768px) {
.ptt-btn {
width: 100px;
height: 100px;
}
}
.ptt-btn:active:not(.ptt-btn--disabled) {
transform: scale(0.95);
}
.ptt-btn--disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* ── Idle ring ── */
.ptt-idle-ring {
position: absolute;
inset: -3px;
border-radius: 50%;
border: 2px solid color-mix(in srgb, var(--accent) 50%, transparent);
pointer-events: none;
}
/* ── Recording pulse ring ── */
.ptt-pulse-ring {
position: absolute;
inset: -6px;
border-radius: 50%;
border: 3px solid #ef4444;
box-shadow: 0 0 20px rgba(239, 68, 68, 0.4), inset 0 0 12px rgba(239, 68, 68, 0.15);
animation: ptt-pulse 1s ease-in-out infinite;
pointer-events: none;
}
@keyframes ptt-pulse {
0%,
100% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(1.08);
opacity: 0.7;
}
}
.ptt-btn--recording {
background: linear-gradient(
180deg,
rgba(239, 68, 68, 0.2),
rgba(239, 68, 68, 0.1)
);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.08),
0 0 28px rgba(239, 68, 68, 0.35);
}
/* ── Loading spinner ring ── */
.ptt-spinner-ring {
position: absolute;
inset: -4px;
border-radius: 50%;
border: 2px solid color-mix(in srgb, var(--accent) 20%, transparent);
border-top-color: var(--accent);
animation: ptt-spin 0.8s linear infinite;
pointer-events: none;
}
@keyframes ptt-spin {
to {
transform: rotate(360deg);
}
}
.ptt-btn--loading {
cursor: wait;
}
/* ── Labels ── */
.ptt-label {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
}
.ptt-status {
font-size: 0.8rem;
letter-spacing: 0.02em;
}
.ptt-status--idle {
color: var(--t3);
}
.ptt-status--recording {
color: #ef4444;
animation: ptt-status-blink 1s ease-in-out infinite;
}
@keyframes ptt-status-blink {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.ptt-status--loading {
color: var(--t3);
}
.ptt-status--error {
color: #ef4444;
font-size: 0.75rem;
}
.ptt-freq {
font-size: 0.85rem;
font-weight: 600;
color: var(--accent);
letter-spacing: 0.04em;
}
</style>

View File

@@ -0,0 +1,531 @@
<template>
<div
class="tx-card"
:class="[`tx-card--${speaker}`, { 'tx-card--entered': entered }]"
>
<!-- Header row -->
<div class="tx-header">
<div class="tx-speaker">
<v-icon :icon="speakerIcon" size="16" :color="speakerColor" />
<span class="tx-speaker-label" :style="{ color: speakerColor }">
{{ speakerLabel }}
</span>
</div>
<div class="tx-badges">
<span v-if="transmission.debug?.telemetryTrigger" class="tx-badge tx-badge--telemetry">
<span class="tx-badge-bolt">&#9889;</span> TELEMETRY
</span>
<span v-if="transmission.debug?.readbackResult && !transmission.debug.readbackResult.complete" class="tx-badge tx-badge--readback">
READBACK INCOMPLETE
</span>
<span class="tx-badge tx-badge--phase">{{ transmission.phase }}</span>
<span class="tx-badge tx-badge--freq">{{ transmission.frequency }}</span>
<span class="tx-timestamp">{{ formattedTime }}</span>
</div>
</div>
<!-- Message body -->
<div class="tx-message">{{ transmission.message }}</div>
<!-- Normalized text (if different) -->
<div
v-if="transmission.normalized && transmission.normalized !== transmission.message"
class="tx-normalized"
>
<span class="tx-normalized-label">Normalized:</span> {{ transmission.normalized }}
</div>
<!-- Debug toggle -->
<button
v-if="hasDebug"
class="tx-details-toggle"
:aria-expanded="expanded"
@click="expanded = !expanded"
>
<v-icon
:icon="expanded ? 'mdi-chevron-up' : 'mdi-chevron-down'"
size="14"
/>
<span>{{ expanded ? 'Hide details' : 'Details' }}</span>
</button>
<!-- Debug section -->
<div v-show="expanded" class="tx-debug glass">
<!-- STT Raw -->
<div v-if="debug.sttRaw" class="tx-debug-section">
<div class="tx-debug-title">STT Raw Input</div>
<code class="tx-debug-code">{{ debug.sttRaw }}</code>
</div>
<!-- LLM Decision -->
<div v-if="debug.llmRequest || debug.llmResponse" class="tx-debug-section">
<div class="tx-debug-title">LLM Decision</div>
<div v-if="debug.llmResponse" class="tx-debug-meta">
<span
class="tx-badge tx-badge--confidence"
:class="`tx-badge--confidence-${debug.llmResponse.confidence}`"
>
{{ debug.llmResponse.confidence }}
</span>
<span class="tx-debug-dim">
{{ debug.llmResponse.model }} &middot;
{{ debug.llmResponse.tokensUsed }} tokens &middot;
{{ debug.llmResponse.durationMs }}ms
</span>
</div>
<!-- Candidates -->
<div v-if="debug.llmRequest?.candidates" class="tx-debug-candidates">
<div
v-for="c in debug.llmRequest.candidates"
:key="c.id"
class="tx-candidate"
:class="{ 'tx-candidate--chosen': isChosen(c.id) }"
>
<span class="tx-candidate-icon">{{ isChosen(c.id) ? '\u2713' : '\u2717' }}</span>
<span class="tx-candidate-id">{{ c.id }}</span>
<span class="tx-candidate-intent">{{ c.intent }}</span>
</div>
</div>
<div v-if="debug.llmResponse?.reason" class="tx-debug-reason">
<span class="tx-debug-dim">Reason:</span> {{ debug.llmResponse.reason }}
</div>
</div>
<!-- Engine Action -->
<div v-if="debug.engineAction" class="tx-debug-section">
<div class="tx-debug-title">Engine Action</div>
<div v-if="debug.engineAction.templateUsed" class="tx-debug-row">
<span class="tx-debug-dim">Template:</span>
<code class="tx-debug-code">{{ debug.engineAction.templateUsed }}</code>
</div>
<div v-if="debug.engineAction.variablesUpdated && Object.keys(debug.engineAction.variablesUpdated).length" class="tx-debug-row">
<span class="tx-debug-dim">Variables:</span>
<span
v-for="(val, key) in debug.engineAction.variablesUpdated"
:key="String(key)"
class="tx-var-chip"
>
{{ key }}={{ val }}
</span>
</div>
<div v-if="debug.engineAction.handoff" class="tx-debug-row">
<span class="tx-debug-dim">Handoff:</span>
{{ debug.engineAction.handoff.from }} &rarr; {{ debug.engineAction.handoff.to }}
</div>
<div v-if="debug.engineAction.phaseChanged" class="tx-debug-row">
<span class="tx-debug-dim">Phase:</span>
{{ debug.engineAction.phaseChanged.from }} &rarr; {{ debug.engineAction.phaseChanged.to }}
</div>
</div>
<!-- Telemetry Trigger -->
<div v-if="debug.telemetryTrigger" class="tx-debug-section">
<div class="tx-debug-title">Telemetry Trigger</div>
<div class="tx-debug-row">
<span class="tx-debug-dim">Parameter:</span> {{ debug.telemetryTrigger.parameter }}
</div>
<div class="tx-debug-row">
<span class="tx-debug-dim">Condition:</span> {{ debug.telemetryTrigger.condition }}
</div>
<div class="tx-debug-row">
<span class="tx-debug-dim">Value:</span>
<code class="tx-debug-code">{{ debug.telemetryTrigger.value }}</code>
</div>
</div>
<!-- Readback Result -->
<div v-if="debug.readbackResult" class="tx-debug-section">
<div class="tx-debug-title">Readback</div>
<div class="tx-debug-row">
<span
class="tx-badge"
:class="debug.readbackResult.complete ? 'tx-badge--rb-ok' : 'tx-badge--rb-fail'"
>
{{ debug.readbackResult.complete ? 'Complete' : 'Incomplete' }}
</span>
<span v-if="debug.readbackResult.missing?.length" class="tx-debug-dim">
Missing: {{ debug.readbackResult.missing.join(', ') }}
</span>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
interface TransmissionDebug {
sttRaw?: string
llmRequest?: { currentPhase: string; currentInteraction: string; pilotSaid: string; candidates: { id: string; intent: string }[]; contextSent: unknown }
llmResponse?: { chosenInteraction: string; confidence: 'high' | 'medium' | 'low'; reason: string; tokensUsed: number; durationMs: number; model: string }
engineAction?: { templateUsed: string; variablesUpdated: Record<string, unknown>; handoff?: { from: string; to: string }; phaseChanged?: { from: string; to: string } }
telemetryTrigger?: { parameter: string; condition: string; value: unknown }
readbackResult?: { complete: boolean; missing?: string[] }
}
interface Transmission {
id: string
timestamp: Date
speaker: 'pilot' | 'atc' | 'system'
message: string
normalized?: string
phase: string
frequency: string
debug: TransmissionDebug
}
const props = defineProps<{ transmission: Transmission }>()
const expanded = ref(false)
const entered = ref(false)
onMounted(() => {
requestAnimationFrame(() => { entered.value = true })
})
const speaker = computed(() => props.transmission.speaker)
const debug = computed(() => props.transmission.debug ?? ({} as TransmissionDebug))
const hasDebug = computed(() => {
const d = debug.value
return !!(d.sttRaw || d.llmRequest || d.llmResponse || d.engineAction || d.telemetryTrigger || d.readbackResult)
})
const speakerIcon = computed(() => {
const map = { pilot: 'mdi-account-voice', atc: 'mdi-tower-fire', system: 'mdi-cog' }
return map[speaker.value] ?? 'mdi-cog'
})
const speakerColor = computed(() => {
const map = { pilot: '#3b82f6', atc: '#22d3ee', system: 'rgba(255,255,255,0.3)' }
return map[speaker.value] ?? 'rgba(255,255,255,0.3)'
})
const speakerLabel = computed(() => {
const map = { pilot: 'PILOT', atc: 'ATC', system: 'SYSTEM' }
return map[speaker.value] ?? 'SYSTEM'
})
const formattedTime = computed(() => {
const d = new Date(props.transmission.timestamp)
return [d.getHours(), d.getMinutes(), d.getSeconds()]
.map(n => String(n).padStart(2, '0'))
.join(':')
})
function isChosen(id: string): boolean {
return debug.value.llmResponse?.chosenInteraction === id
}
</script>
<style scoped>
.tx-card {
position: relative;
padding: 10px 14px;
border-left: 3px solid rgba(255, 255, 255, 0.1);
opacity: 0;
transform: translateY(6px);
transition: opacity 0.3s ease, transform 0.3s ease;
}
.tx-card--entered {
opacity: 1;
transform: translateY(0);
}
.tx-card--pilot {
border-left-color: #3b82f6;
}
.tx-card--atc {
border-left-color: var(--accent);
}
.tx-card--system {
border-left-color: rgba(255, 255, 255, 0.15);
}
.tx-card--system .tx-message {
font-size: 0.82rem;
color: var(--t3);
}
/* Header */
.tx-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 4px;
}
.tx-speaker {
display: inline-flex;
align-items: center;
gap: 5px;
}
.tx-speaker-label {
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.tx-badges {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.tx-badge {
display: inline-flex;
align-items: center;
gap: 3px;
font-size: 0.65rem;
font-weight: 600;
letter-spacing: 0.04em;
padding: 2px 7px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.08);
color: var(--t2);
}
.tx-badge--phase {
color: var(--accent);
border-color: color-mix(in srgb, var(--accent) 25%, transparent);
background: color-mix(in srgb, var(--accent) 8%, transparent);
}
.tx-badge--freq {
color: var(--t3);
}
.tx-badge--telemetry {
color: #facc15;
border-color: rgba(250, 204, 21, 0.3);
background: rgba(250, 204, 21, 0.08);
}
.tx-badge-bolt {
font-size: 0.7rem;
}
.tx-badge--readback {
color: #f87171;
border-color: rgba(248, 113, 113, 0.3);
background: rgba(248, 113, 113, 0.08);
}
.tx-timestamp {
font-size: 0.65rem;
color: var(--t3);
font-family: 'JetBrains Mono', 'Fira Code', monospace;
}
/* Message */
.tx-message {
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 0.95rem;
line-height: 1.5;
color: var(--text);
margin: 2px 0 4px;
}
.tx-normalized {
font-size: 0.76rem;
color: var(--t3);
margin-bottom: 4px;
}
.tx-normalized-label {
font-weight: 600;
color: var(--t3);
}
/* Details toggle */
.tx-details-toggle {
display: inline-flex;
align-items: center;
gap: 4px;
background: none;
border: none;
color: var(--t3);
font-size: 0.72rem;
font-weight: 500;
cursor: pointer;
padding: 2px 4px;
border-radius: 6px;
transition: color 0.15s ease, background 0.15s ease;
}
.tx-details-toggle:hover {
color: var(--accent);
background: rgba(255, 255, 255, 0.04);
}
/* Debug panel */
.tx-debug {
margin-top: 8px;
padding: 10px 12px;
border-radius: 12px;
font-size: 0.78rem;
line-height: 1.6;
color: var(--t2);
}
.tx-debug-section {
padding: 6px 0;
}
.tx-debug-section + .tx-debug-section {
border-top: 1px solid rgba(255, 255, 255, 0.06);
}
.tx-debug-title {
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--accent);
margin-bottom: 4px;
}
.tx-debug-meta {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
flex-wrap: wrap;
}
.tx-debug-dim {
color: var(--t3);
font-size: 0.74rem;
}
.tx-debug-code {
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 0.74rem;
background: rgba(255, 255, 255, 0.04);
padding: 1px 5px;
border-radius: 4px;
color: var(--t2);
}
.tx-debug-row {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
padding: 1px 0;
}
.tx-debug-reason {
margin-top: 4px;
font-style: italic;
color: var(--t3);
}
/* Confidence badges */
.tx-badge--confidence {
font-weight: 700;
text-transform: uppercase;
}
.tx-badge--confidence-high {
color: #4ade80;
border-color: rgba(74, 222, 128, 0.3);
background: rgba(74, 222, 128, 0.08);
}
.tx-badge--confidence-medium {
color: #facc15;
border-color: rgba(250, 204, 21, 0.3);
background: rgba(250, 204, 21, 0.08);
}
.tx-badge--confidence-low {
color: #f87171;
border-color: rgba(248, 113, 113, 0.3);
background: rgba(248, 113, 113, 0.08);
}
/* Candidates */
.tx-debug-candidates {
display: flex;
flex-direction: column;
gap: 2px;
margin: 4px 0;
}
.tx-candidate {
display: flex;
align-items: center;
gap: 6px;
padding: 2px 6px;
border-radius: 4px;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 0.72rem;
color: var(--t3);
}
.tx-candidate--chosen {
background: rgba(74, 222, 128, 0.06);
color: #4ade80;
}
.tx-candidate-icon {
font-size: 0.8rem;
width: 14px;
text-align: center;
}
.tx-candidate--chosen .tx-candidate-icon {
color: #4ade80;
}
.tx-candidate-id {
font-weight: 600;
min-width: 80px;
}
.tx-candidate-intent {
color: var(--t3);
}
/* Variable chips */
.tx-var-chip {
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 0.7rem;
background: rgba(255, 255, 255, 0.04);
padding: 1px 6px;
border-radius: 4px;
color: var(--t2);
}
/* Readback */
.tx-badge--rb-ok {
color: #4ade80;
border-color: rgba(74, 222, 128, 0.3);
background: rgba(74, 222, 128, 0.08);
}
.tx-badge--rb-fail {
color: #f87171;
border-color: rgba(248, 113, 113, 0.3);
background: rgba(248, 113, 113, 0.08);
}
</style>