mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-05 00:46:00 +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