diff --git a/app/components/HoldSelect.vue b/app/components/HoldSelect.vue index 6aa0914..342251c 100644 --- a/app/components/HoldSelect.vue +++ b/app/components/HoldSelect.vue @@ -7,6 +7,9 @@ export interface HoldSelectOption { sublabel?: string color?: string disabled?: boolean + // Allow callers to attach extra metadata that custom #option slots can read + // (e.g. tooltip source labels, badges) without widening the contract here. + [key: string]: unknown } const props = withDefaults(defineProps<{ @@ -14,10 +17,14 @@ const props = withDefaults(defineProps<{ placement?: 'auto' | 'up' | 'down' disabled?: boolean title?: string + dense?: boolean + menuClass?: string }>(), { placement: 'auto', disabled: false, title: '', + dense: false, + menuClass: '', }) const emit = defineEmits<{ @@ -211,6 +218,7 @@ onBeforeUnmount(() => { v-if="open" ref="menuRef" class="hold-select-menu" + :class="[{ 'is-dense': dense }, menuClass]" :style="menuStyle" >

{{ title }}

@@ -290,6 +298,11 @@ onBeforeUnmount(() => { border: 1px solid transparent; transition: background 90ms ease, border-color 90ms ease; } +.hold-select-menu.is-dense .hs-option { + padding: 6px 10px; + gap: 1px; + border-radius: 10px; +} .hs-option.is-highlight { background: color-mix(in srgb, var(--hs-accent) 22%, transparent); border-color: color-mix(in srgb, var(--hs-accent) 60%, transparent); diff --git a/app/pages/pm.vue b/app/pages/pm.vue index 4797470..ffc017e 100644 --- a/app/pages/pm.vue +++ b/app/pages/pm.vue @@ -168,36 +168,128 @@ -
-

{{ flightContext.callsign || 'N/A' }}

-

{{ flightContext.dep }} → {{ flightContext.dest }}

-
-
- - {{ flags.in_air ? 'IN-AIR' : 'GROUND' }} - - {{ flags.current_unit }} +
+ + + + +
- - + + +
mdi-logout Logout @@ -244,21 +345,6 @@
- - -
@@ -508,506 +594,11 @@
- -
- - -

Radio Setup

- -
- - - -
-
-
-
- - -
- - -
-

Frequency overview

-
- - -
-
- -
- - Loading frequencies from the network... -
-
- No frequencies available. Please try again later. -
-
-
-
-
- {{ freq.type }} - - - -
- -
- {{ freq.frequency }} -
- -
- {{ freq.label }} - · {{ freq.callsign }} - · ATIS {{ freq.atisCode }} -
-
- -
- - -
-
-
-
-
-
-
- -
- - -
-
-

Active flight

-

{{ flightContext.callsign || 'N/A' }}

-

{{ flightContext.dep }} → {{ flightContext.dest }}

-
-
- - {{ flags.in_air ? 'IN-AIR' : 'GROUND' }} - -
- - EMERG - - - {{ flags.current_unit }} - -
-
-
- -
-
-

Stand

-

{{ vars.stand }}

-
-
-

Runway

-

{{ vars.runway }}

-
-
-

Squawk

-

{{ vars.squawk }}

-
-
-

SID

-

{{ vars.sid }}

-
-
- -
-
-

{{ flags.radio_checks_done || 0 }}

-

Radio Checks

-
-
-

{{ flags.off_schema_count || 0 }}

-

Off-Schema

-
-
-

{{ log.length }}

-

Transmissions

-
-
- - - Select new flight - -
-
-
- - -
- - -

Settings

- -
-
-
- - {{ speechSpeedLabel }} -
- -
- -
- - - - -
- -
-
-

Voice input

-

- Pre-recording keeps the mic listening in the background so the start of your transmission isn't clipped. -

-
- -
-
- - {{ prerecSeconds.toFixed(1) }}s -
- -
-
-
-
-
-
- - -
- - -
-
-

Simulation Trace

- - {{ completedPilotSteps }} / {{ simulationStepCount }} - -
- - {{ simulationRunning ? 'Running' : 'Ready' }} - -
- - - {{ simulationError }} - - -
- Simulation initializing... -
- -
-
-
- {{ entry.label }} - {{ entry.id }} -
-
-

{{ entry.payload?.text }}

-

{{ entry.payload?.normalized }}

-
-
{{ formatTracePayload(entry.payload) }}
-
-
-
-
- - - -
-

Debug: Flow Insights

- LLM -
- -
- Session: {{ sessionLabel }} -
- - Auto: {{ traceAutoSelection.id }} - - - Fallback candidates - - - Fallback: {{ traceFallback.reason || 'triggered' }} - -
-
- -
-

Current node

-

{{ debugState?.id || '—' }}

-

- {{ debugState ? `${debugState.role} • ${debugState.phase}` : 'N/A' }} - ({{ debugState.frequencyName }}) -

-

- Auto (LLM): {{ debugState.sayPlain }} -

-

- Radio: {{ debugState.sayNormalized }} -

-
- -
-

Upcoming decisions

-
-
-
-
-

{{ state.id }}

-

- {{ state.role || '—' }} • {{ state.phase || '—' }} - ({{ state.frequencyName }}) -

-
- - Jump - -
-

- ATC: {{ state.sayPlain }} -

-

- Radio: {{ state.sayNormalized }} -

-
-
-

No further decisions available.

-
- -
-

Decision timeline

-
-
-
-
-

{{ step.label }}

-

{{ step.stage }}

-
- - {{ step.candidates.length }} candidates - -
-

{{ step.note }}

-
-
-
- {{ candidate.id }} - {{ candidate.flow || 'current' }} -
-

{{ candidate.summary }}

-
-
-
-

Eliminated

-
-
- {{ elim.candidate.id }} - {{ elim.kind }} -
-

{{ elim.reason }}

-

{{ describeElimination(elim) }}

-
-
-
-
-

No decision timeline available yet.

-
-
-
-
@@ -1032,6 +623,412 @@ + + + + + + + + + + + + +
+ + Flight info +
+ +
+ +
+
+

Active flight

+

{{ flightContext.callsign || 'N/A' }}

+

{{ flightContext.dep }} → {{ flightContext.dest }}

+
+
+ + {{ flags.in_air ? 'IN-AIR' : 'GROUND' }} + +
+ + EMERG + + + {{ flags.current_unit }} + +
+
+
+ +
+
+

Stand

+

{{ vars.stand }}

+
+
+

Runway

+

{{ vars.runway }}

+
+
+

Squawk

+

{{ vars.squawk }}

+
+
+

SID

+

{{ vars.sid }}

+
+
+ +
+
+

{{ flags.radio_checks_done || 0 }}

+

Radio Checks

+
+
+

{{ flags.off_schema_count || 0 }}

+

Off-Schema

+
+
+

{{ log.length }}

+

Transmissions

+
+
+ + + Select new flight + +
+
+
+ + + + + +
+ + Settings +
+ +
+ +
+
+ + {{ speechSpeedLabel }} +
+ +
+ +
+ + + + +
+ +
+
+

Voice input

+

+ Pre-recording keeps the mic listening in the background so the start of your transmission isn't clipped. +

+
+ +
+
+ + {{ prerecSeconds.toFixed(1) }}s +
+ +
+
+
+
+
+ @@ -1589,28 +1586,28 @@ const prerecEnabled = ref(true) const prerecSeconds = ref(1.0) // Layout / view state -const activeTab = ref<'funk' | 'freq' | 'log' | 'flug' | 'more' | 'debug'>('funk') +const activeTab = ref<'funk' | 'log'>('funk') const experienceMenu = ref(false) const inputMode = ref<'voice' | 'text'>('voice') const learningMode = ref(true) +// Overlays / sheets opened from HUD controls +const showFlightSheet = ref(false) +const showSettingsSheet = ref(false) +const showDebugDrawer = ref(false) +const hudStatusMenu = ref(false) + type PmTab = { - id: 'funk' | 'freq' | 'log' | 'flug' | 'more' | 'debug' + id: 'funk' | 'log' label: string icon: string mobileOnly?: boolean - debugOnly?: boolean } const TABS: PmTab[] = [ { id: 'funk', label: 'Radio', icon: 'mdi-radio-handheld' }, - { id: 'freq', label: 'Freq', icon: 'mdi-sine-wave' }, { id: 'log', label: 'Log', icon: 'mdi-format-list-bulleted', mobileOnly: true }, - { id: 'flug', label: 'Flight', icon: 'mdi-airplane' }, - { id: 'more', label: 'More', icon: 'mdi-cog' }, - { id: 'debug', label: 'Debug', icon: 'mdi-bug', debugOnly: true }, ] -const visibleTabs = computed(() => TABS.filter((tab) => !tab.debugOnly || debugMode.value)) -const desktopTabs = computed(() => visibleTabs.value.filter((tab) => !tab.mobileOnly)) +const visibleTabs = computed(() => TABS) const simulationRunning = ref(false) const simulationTrace = ref([]) @@ -3128,35 +3125,33 @@ const FREQ_ROLE_LABEL: Record = { DEP: 'Departure', APP: 'Approach', CTR: 'Center', ACC: 'Center', FSS: 'Radio', } -const frequencyPresets = computed(() => { - const byType = new Map() - const prioritized = [...airportFrequencies.value].sort((a, b) => - a.source === b.source ? 0 : (a.source === 'vatsim' ? -1 : 1) - ) - for (const entry of prioritized) { - if (!entry.frequency || entry.frequency === FREQUENCY_PLACEHOLDER) continue - if (!byType.has(entry.type)) byType.set(entry.type, entry) - } - const result: AirportFrequencyEntry[] = [] - for (const type of FREQ_ROLE_ORDER) { - const entry = byType.get(type) - if (entry) result.push(entry) - } - for (const [type, entry] of byType) { - if (!FREQ_ROLE_ORDER.includes(type)) result.push(entry) - } - return result +const frequencyPresets = computed(() => { + // Use the grouped overview list so every distinct frequency shows up in the dropdown + // (deduped by type+frequency, with merged sources for tooltip). + return displayAirportFrequencies.value }) -const presetKey = (entry: AirportFrequencyEntry) => `${entry.type}-${entry.frequency}` +const presetKey = (entry: AirportFrequencyEntry | DisplayAirportFrequencyEntry) => + 'displayKey' in entry ? entry.displayKey : `${entry.type}-${entry.frequency}` const presetLabel = (entry: AirportFrequencyEntry) => FREQ_ROLE_LABEL[entry.type] || entry.type -const presetOptions = computed(() => +type FrequencyPresetOption = { + value: string + label: string + sublabel: string + color: string + sourceLabel: string + callsign?: string +} + +const presetOptions = computed(() => frequencyPresets.value.map((entry) => ({ value: presetKey(entry), label: presetLabel(entry), sublabel: entry.frequency, color: entry.type === 'ATIS' ? '#f59e0b' : '#22d3ee', + sourceLabel: entry.sourceLabel, + callsign: entry.callsign, })) ) @@ -3513,14 +3508,6 @@ onUnmounted(() => { transition: transform 0.5s ease; } -.freq-input-active :deep(.v-field__outline) { - --v-field-border-color: #4ade80; -} - -.freq-input-standby :deep(.v-field__outline) { - --v-field-border-color: #f59e0b; -} - @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.7; } @@ -3700,12 +3687,39 @@ onUnmounted(() => { color: var(--t3); } -.hud-context { +/* Flight context: styled like freq-control-group, with status dropdown toggle */ +.hud-context-group { + display: inline-flex; + align-items: stretch; + min-height: 40px; + margin-left: 4px; + overflow: hidden; + border-radius: 12px; + border: 1px solid rgba(34, 211, 238, 0.32); + background: rgba(34, 211, 238, 0.08); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.03); +} +.hud-context-btn { display: flex; flex-direction: column; - min-width: 0; - margin-left: 4px; + align-items: flex-start; + justify-content: center; + gap: 1px; + min-width: 110px; + padding: 4px 12px; + background: transparent; + text-align: left; line-height: 1.15; + color: inherit; + cursor: pointer; + transition: background 120ms ease, transform 80ms ease; +} +.hud-context-btn:hover { + background: rgba(34, 211, 238, 0.1); +} +.hud-context-btn:active { + transform: scale(0.98); + background: rgba(34, 211, 238, 0.18); } .hud-context-callsign { font-size: 14px; @@ -3714,6 +3728,7 @@ onUnmounted(() => { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + max-width: 160px; } .hud-context-route { font-size: 11px; @@ -3721,17 +3736,67 @@ onUnmounted(() => { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + max-width: 160px; } -.hud-status { +.hud-context-toggle { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + min-height: 100%; + border-left: 1px solid rgba(34, 211, 238, 0.24); + color: #67e8f9; + background: rgba(34, 211, 238, 0.06); + transition: background 120ms ease, color 120ms ease, transform 120ms ease; + -webkit-tap-highlight-color: transparent; +} +.hud-context-toggle:hover { + color: #cffafe; + background: rgba(34, 211, 238, 0.16); +} +.hud-context-toggle.is-open { + background: rgba(34, 211, 238, 0.22); +} +.hud-context-toggle.is-open .v-icon { + transform: rotate(180deg); +} + +.hud-status-panel { display: flex; flex-direction: column; - gap: 3px; - flex-shrink: 0; + gap: 8px; + min-width: 220px; + padding: 12px 14px; + border-radius: 14px; + border: 1px solid color-mix(in srgb, var(--border) 90%, transparent); + background: color-mix(in srgb, var(--bg) 94%, transparent); + box-shadow: 0 18px 40px rgba(2, 6, 23, .45); + backdrop-filter: blur(10px); +} +.hud-status-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + font-size: 12px; +} +.hud-status-key { + color: var(--t3); + text-transform: uppercase; + letter-spacing: 0.18em; + font-size: 10px; +} +.hud-status-val { + color: var(--text); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 13px; + font-weight: 600; } @media (max-width: 1180px) { - .hud-context, - .hud-status, + .hud-context-btn .hud-context-route { + display: none; + } .sep, .mode-switch, .hud-divider, @@ -3739,6 +3804,11 @@ onUnmounted(() => { display: none; } } +@media (max-width: 720px) { + .hud-context-group { + display: none; + } +} @media (max-width: 860px) { .hud-inner { gap: 8px; @@ -3763,10 +3833,6 @@ onUnmounted(() => { margin: 0 auto; } -.pm-sidenav { - display: none; -} - .pm-main { flex: 1 1 auto; min-width: 0; @@ -3832,34 +3898,49 @@ onUnmounted(() => { border-color: color-mix(in srgb, var(--accent) 32%, transparent); } -/* Frequency control group in the top bar */ +/* Frequency control group in the top bar: SBY (top) + ACT (bottom) + swap */ .freq-control-group { display: inline-flex; align-items: stretch; - height: 46px; + min-height: 56px; overflow: hidden; border-radius: 14px; border: 1px solid rgba(34, 211, 238, 0.35); background: rgba(34, 211, 238, 0.09); box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.03); } -.freq-chip { +.freq-stack { display: flex; flex-direction: column; - align-items: flex-end; - gap: 1px; - min-width: 98px; - padding: 6px 11px 6px 12px; + min-width: 0; +} +.freq-chip { + display: flex; + align-items: baseline; + justify-content: flex-end; + gap: 8px; + min-width: 130px; + padding: 4px 12px; background: transparent; line-height: 1.1; transition: background 120ms ease, transform 80ms ease; + cursor: pointer; +} +.freq-chip-sby { + border-bottom: 1px solid rgba(34, 211, 238, 0.18); + padding-top: 5px; + padding-bottom: 4px; +} +.freq-chip-act { + padding-top: 4px; + padding-bottom: 5px; } .freq-chip:hover { background: rgba(34, 211, 238, 0.08); } .freq-chip.is-open { - background: rgba(34, 211, 238, 0.18); - transform: scale(0.98); + background: rgba(34, 211, 238, 0.2); + transform: scale(0.99); } .freq-swap-btn { display: inline-flex; @@ -3880,11 +3961,6 @@ onUnmounted(() => { .freq-swap-btn:active { background: rgba(34, 211, 238, 0.22); } -.freq-chip-row { - display: flex; - align-items: baseline; - gap: 6px; -} .freq-chip-tag { font-size: 9px; letter-spacing: 0.2em; @@ -3892,14 +3968,54 @@ onUnmounted(() => { } .freq-chip-value { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; - font-size: 18px; + font-size: 16px; font-weight: 700; color: #fff; } -.freq-chip-standby { +.freq-chip-value-sm { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; - font-size: 10px; - color: rgba(255, 255, 255, 0.58); + font-size: 13px; + font-weight: 600; + color: rgba(255, 255, 255, 0.78); +} + +/* Dropdown option row with source label on the right */ +.freq-option { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + width: 100%; +} +.freq-option-main { + display: flex; + flex-direction: column; + gap: 0; + min-width: 0; +} +.freq-option-label { + font-size: 13px; + font-weight: 600; + color: #fff; + line-height: 1.1; +} +.freq-option-sub { + font-size: 11px; + color: rgba(255, 255, 255, 0.55); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + line-height: 1.1; +} +.freq-option-source { + font-size: 9px; + letter-spacing: 0.14em; + text-transform: uppercase; + color: rgba(103, 232, 249, 0.78); + padding: 2px 6px; + border-radius: 999px; + border: 1px solid rgba(34, 211, 238, 0.4); + background: rgba(34, 211, 238, 0.08); + white-space: nowrap; + flex-shrink: 0; } /* Preset hold-select trigger buttons (freq tab) */ @@ -3984,101 +4100,8 @@ onUnmounted(() => { gap: 2px; } -.frequency-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); - gap: 10px; -} -.frequency-card { - display: flex; - min-height: 136px; - flex-direction: column; - justify-content: space-between; - border-radius: 14px; - border: 1px solid rgba(255, 255, 255, 0.1); - background: rgba(0, 0, 0, 0.32); - overflow: hidden; -} -.frequency-card-content { - display: flex; - flex: 1 1 auto; - flex-direction: column; - justify-content: center; - padding: 14px; -} -.frequency-card-topline { - display: flex; - align-items: center; - justify-content: space-between; - min-height: 24px; -} -.frequency-value { - margin-top: 6px; - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; - font-size: 30px; - font-weight: 750; - line-height: 1; - color: #fff; -} -.frequency-description { - margin-top: 6px; - min-height: 18px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-size: 12px; - color: rgba(255, 255, 255, 0.5); -} -.frequency-actions { - display: grid; - grid-template-columns: 1fr 1fr; - border-top: 1px solid rgba(255, 255, 255, 0.1); - background: rgba(34, 211, 238, 0.06); -} -.frequency-action-btn { - min-height: 38px; - font-size: 11px; - font-weight: 700; - letter-spacing: 0.12em; - color: rgba(103, 232, 249, 0.95); - transition: background 120ms ease, color 120ms ease; - -webkit-tap-highlight-color: transparent; -} -.frequency-action-btn + .frequency-action-btn { - border-left: 1px solid rgba(255, 255, 255, 0.1); -} -.frequency-action-btn:hover { - background: rgba(34, 211, 238, 0.14); - color: #cffafe; -} -.frequency-action-btn:active { - background: rgba(34, 211, 238, 0.22); -} - /* Tablet / desktop: use the extra space ----------------------------------- */ @media (min-width: 1024px) { - .pm-sidenav { - display: flex; - flex: 0 0 auto; - flex-direction: column; - gap: 6px; - width: 168px; - padding: 16px 12px; - border-right: 1px solid var(--border); - background: color-mix(in srgb, var(--bg) 70%, transparent); - overflow-y: auto; - } - .pm-sidenav .pm-navbtn { - flex: 0 0 auto; - flex-direction: row; - justify-content: flex-start; - gap: 10px; - padding: 11px 14px; - border-radius: 12px; - font-size: 14px; - font-weight: 600; - letter-spacing: 0.01em; - } .pm-lograil { display: block; flex: 0 0 360px; @@ -4098,5 +4121,110 @@ onUnmounted(() => { .pm-log-tab { display: none !important; } + /* No bottom nav on desktop: drop the FAB back down to the corner. */ + .debug-fab { + bottom: calc(env(safe-area-inset-bottom) + 16px); + } + .debug-drawer { + bottom: calc(env(safe-area-inset-bottom) + 16px); + } +} + +/* ========================================================================= + * Debug FAB + drawer (left, non-blocking, can stay open in parallel) + * ======================================================================= */ +.debug-fab { + position: fixed; + left: 16px; + /* Clear the mobile bottom nav (~64px) by default; desktop overrides below. */ + bottom: calc(env(safe-area-inset-bottom) + 78px); + z-index: 60; + display: inline-flex; + align-items: center; + justify-content: center; + width: 48px; + height: 48px; + border-radius: 16px; + border: 1px solid rgba(249, 115, 22, 0.45); + background: rgba(249, 115, 22, 0.16); + color: #fdba74; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.45); + cursor: pointer; + transition: background 160ms ease, color 160ms ease, transform 160ms ease, border-color 160ms ease; +} +.debug-fab:hover { + background: rgba(249, 115, 22, 0.26); + color: #fed7aa; + transform: translateY(-1px); +} +.debug-fab.is-open { + background: rgba(249, 115, 22, 0.35); + border-color: rgba(249, 115, 22, 0.7); + color: #fff; +} + +.debug-drawer { + position: fixed; + top: 72px; + left: 12px; + bottom: calc(env(safe-area-inset-bottom) + 76px); + width: min(420px, calc(100vw - 24px)); + z-index: 55; + display: flex; + flex-direction: column; + border-radius: 18px; + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(8, 13, 24, 0.96); + box-shadow: 0 24px 60px rgba(0, 0, 0, 0.6); + backdrop-filter: blur(14px); + pointer-events: auto; +} +.debug-drawer-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 12px 14px; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); +} +.debug-drawer-close { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: 8px; + color: rgba(255, 255, 255, 0.6); + background: transparent; + transition: background 120ms ease, color 120ms ease; +} +.debug-drawer-close:hover { + background: rgba(255, 255, 255, 0.08); + color: #fff; +} +.debug-drawer-body { + padding: 12px 14px; + overflow-y: auto; + -webkit-overflow-scrolling: touch; +} + +.debug-drawer-enter-active, +.debug-drawer-leave-active { + transition: transform 220ms ease, opacity 220ms ease; +} +.debug-drawer-enter-from, +.debug-drawer-leave-to { + transform: translateX(-12px); + opacity: 0; +} + +@media (max-width: 640px) { + .debug-drawer { + top: 64px; + left: 8px; + right: 8px; + width: auto; + bottom: calc(env(safe-area-inset-bottom) + 84px); + } }