mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-11 12:05:33 +08:00
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:
268
app/components/atc/FrequencyPanel.vue
Normal file
268
app/components/atc/FrequencyPanel.vue
Normal 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>
|
||||||
237
app/components/atc/PhaseIndicator.vue
Normal file
237
app/components/atc/PhaseIndicator.vue
Normal 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>
|
||||||
323
app/components/atc/PttButton.vue
Normal file
323
app/components/atc/PttButton.vue
Normal 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 }} · {{ 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>
|
||||||
531
app/components/atc/TransmissionCard.vue
Normal file
531
app/components/atc/TransmissionCard.vue
Normal 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">⚡</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 }} ·
|
||||||
|
{{ debug.llmResponse.tokensUsed }} tokens ·
|
||||||
|
{{ 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 }} → {{ 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 }} → {{ 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>
|
||||||
Reference in New Issue
Block a user