feat(ui): clearer onboarding across start, classroom, live-atc & bridge

- index: replace header GitHub link with a "Sign up / Log in" primary button
- start: number the modes (1. Classroom / 2. Live ATC), add headset/mic
  equipment chips, and unlock Live ATC immediately for new accounts
- classroom: replace brand logo + experience dropdown with a clear
  "Back / Switch mode" link back to /start; remove the mic/STT readback
  input so the readback is text-only again
- live-atc: first-run coach mark pointing at the "Report issue" button
- bridge: add a "How do you want to train?" split — flight sim via the
  Bridge vs. a browser-only web version (dry run, no live sim link)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
itsrubberduck
2026-07-22 08:47:21 +02:00
parent 6dbd9c3ef4
commit 38226d3afb
3 changed files with 299 additions and 235 deletions

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { computed, onMounted, ref } from 'vue'
import type { HoldSelectOption } from '~/components/HoldSelect.vue'
import type { DisplayAirportFrequencyEntry } from '~/composables/useFrequencyPresets'
import type { ReadbackFieldDetail } from '~/composables/useRadioBackend'
@@ -97,6 +97,32 @@ const visibleTabs = computed(() => TABS)
const showExpectedComm = computed(
() => props.learningMode && Boolean(props.controllerSay || props.expectedPhrase),
)
// First-run coach mark that points pilots at the "Report issue" button. Live ATC
// is an early alpha and bug reports are how we improve it, so make it obvious on
// the very first visit. Dismissed permanently once seen.
const REPORT_HINT_STORAGE_KEY = 'os_live_atc_report_hint_seen'
const showReportCoach = ref(false)
function dismissReportCoach() {
showReportCoach.value = false
try {
window.localStorage.setItem(REPORT_HINT_STORAGE_KEY, '1')
} catch { /* ignore */ }
}
function onReportClick() {
dismissReportCoach()
emit('open-bug-report')
}
onMounted(() => {
try {
if (!window.localStorage.getItem(REPORT_HINT_STORAGE_KEY)) {
showReportCoach.value = true
}
} catch { /* ignore */ }
})
</script>
<template>
@@ -237,16 +263,38 @@ const showExpectedComm = computed(
</v-tooltip>
<!-- Report issue carries a filled treatment: it is the most-reached-for
secondary tool, so it outranks the neighbouring ghost icons. -->
<button
type="button"
class="btn hud-report-btn"
title="Fehler melden"
:disabled="bugReportCapturing"
@click="emit('open-bug-report')"
>
<v-icon size="18">{{ bugReportCapturing ? 'mdi-loading mdi-spin' : 'mdi-bug-outline' }}</v-icon>
<span class="btn-label">{{ bugReportCapturing ? '…' : 'Report issue' }}</span>
</button>
<div class="hud-report-wrap">
<button
type="button"
class="btn hud-report-btn"
:class="{ 'is-coaching': showReportCoach }"
title="Fehler melden"
:disabled="bugReportCapturing"
@click="onReportClick"
>
<v-icon size="18">{{ bugReportCapturing ? 'mdi-loading mdi-spin' : 'mdi-bug-outline' }}</v-icon>
<span class="btn-label">{{ bugReportCapturing ? '…' : 'Report issue' }}</span>
</button>
<span v-if="showReportCoach" class="report-coach-ring" aria-hidden="true"></span>
<transition name="coach-fade">
<div v-if="showReportCoach" class="report-coach" role="dialog" aria-label="Report issues">
<span class="report-coach-arrow" aria-hidden="true"></span>
<div class="report-coach-body">
<div class="report-coach-title">
<v-icon size="16">mdi-bug-outline</v-icon>
Found a bug?
</div>
<p class="report-coach-text">
Live ATC is an early alpha. Hit <strong>Report issue</strong> anytime something
feels off it captures the session and helps us fix it.
</p>
<button type="button" class="report-coach-dismiss" @click="dismissReportCoach">
Got it
</button>
</div>
</div>
</transition>
</div>
<button
type="button"
class="btn ghost hud-icon-btn"
@@ -511,6 +559,113 @@ const showExpectedComm = computed(
border-color: rgba(180, 83, 9, 0.4);
color: #92400e;
}
/* ── First-run "report issue" coach mark ─────────────────────────────── */
.hud-report-wrap {
position: relative;
display: inline-flex;
}
.hud-report-btn.is-coaching {
animation: report-coach-bob 1.6s ease-in-out infinite;
}
.report-coach-ring {
position: absolute;
inset: -6px;
border-radius: 12px;
border: 2px solid rgba(245, 158, 11, 0.8);
pointer-events: none;
animation: report-coach-pulse 1.6s ease-out infinite;
}
@keyframes report-coach-pulse {
0% { transform: scale(0.92); opacity: 0.9; }
70% { transform: scale(1.18); opacity: 0; }
100% { transform: scale(1.18); opacity: 0; }
}
@keyframes report-coach-bob {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-2px); }
}
.report-coach {
position: absolute;
top: calc(100% + 14px);
right: 0;
z-index: 60;
width: 260px;
max-width: 78vw;
}
.report-coach-arrow {
position: absolute;
top: -7px;
right: 22px;
width: 14px;
height: 14px;
background: var(--panel, #10161f);
border-left: 1px solid rgba(245, 158, 11, 0.55);
border-top: 1px solid rgba(245, 158, 11, 0.55);
transform: rotate(45deg);
}
.report-coach-body {
position: relative;
border-radius: 14px;
border: 1px solid rgba(245, 158, 11, 0.55);
background: var(--panel, #10161f);
box-shadow: 0 18px 44px rgba(2, 6, 23, 0.5);
padding: 14px 16px;
}
.report-coach-title {
display: flex;
align-items: center;
gap: 6px;
font-weight: 700;
font-size: 0.9rem;
color: #fcd34d;
}
.report-coach-text {
margin: 6px 0 12px;
font-size: 0.8rem;
line-height: 1.45;
color: var(--text, #e5e7eb);
opacity: 0.9;
}
.report-coach-text strong {
color: #fcd34d;
}
.report-coach-dismiss {
display: inline-flex;
align-items: center;
border-radius: 9px;
border: 1px solid rgba(245, 158, 11, 0.5);
background: rgba(245, 158, 11, 0.18);
color: #fcd34d;
font-weight: 600;
font-size: 0.78rem;
padding: 6px 14px;
cursor: pointer;
transition: background .2s ease, border-color .2s ease;
}
.report-coach-dismiss:hover {
background: rgba(245, 158, 11, 0.28);
border-color: rgba(245, 158, 11, 0.7);
}
.pm-page[data-theme="light"] .report-coach-arrow,
.pm-page[data-theme="light"] .report-coach-body {
background: #ffffff;
}
.coach-fade-enter-active,
.coach-fade-leave-active {
transition: opacity .25s ease, transform .25s ease;
}
.coach-fade-enter-from,
.coach-fade-leave-to {
opacity: 0;
transform: translateY(-6px);
}
@media (prefers-reduced-motion: reduce) {
.hud-report-btn.is-coaching,
.report-coach-ring {
animation: none;
}
}
.bridge-badge {
display: inline-flex;
align-items: center;

View File

@@ -4,50 +4,18 @@
<header class="hud" role="banner">
<nav class="hud-inner" aria-label="Global">
<div class="hud-left">
<button class="hud-logo" type="button" title="Mission hub" @click="panel='hub'">
<v-icon size="22" class="hud-logo-icon">mdi-radar</v-icon>
</button>
<NuxtLink class="hud-back" to="/start" title="Back to mode selection">
<v-icon size="20" class="hud-back-icon">mdi-arrow-left</v-icon>
<span class="hud-back-body">
<span class="hud-back-label">Back</span>
<span class="hud-back-sub">Switch mode</span>
</span>
</NuxtLink>
<div class="hud-divider" aria-hidden="true"></div>
<span class="brand">OpenSquawk</span>
<span class="sep">|</span>
<v-menu v-model="experienceMenu" :offset="[0, 8]" location="bottom start" transition="scale-transition">
<template #activator="{ props }">
<button
class="mode-switch"
type="button"
v-bind="props"
aria-haspopup="menu"
:aria-expanded="experienceMenu ? 'true' : 'false'"
>
<span class="mode-switch-label">{{ activeExperience.label }}</span>
<v-icon size="16" class="mode-switch-icon">mdi-chevron-down</v-icon>
</button>
</template>
<div class="experience-menu" role="menu" aria-label="Select experience">
<button
v-for="option in experiences"
:key="option.id"
type="button"
role="menuitemradio"
class="experience-option"
:class="{ 'is-active': option.id === activeExperience.id }"
:aria-checked="option.id === activeExperience.id"
@click="handleExperienceSelect(option)"
>
<v-icon size="18" class="experience-option-icon">{{ option.icon }}</v-icon>
<div class="experience-option-body">
<div class="experience-option-title">{{ option.label }}</div>
<div v-if="option.description" class="experience-option-sub">{{ option.description }}</div>
</div>
<v-icon
v-if="option.id === activeExperience.id"
size="16"
class="experience-option-check"
>mdi-check
</v-icon>
</button>
</div>
</v-menu>
<button class="hud-logo" type="button" title="Mission hub" @click="panel='hub'">
<v-icon size="18" class="hud-logo-icon">mdi-radar</v-icon>
<span class="brand">Classroom</span>
</button>
</div>
<div class="hud-center">
@@ -1086,14 +1054,6 @@
<v-icon v-if="fieldPass(segment.key)" size="16" class="blank-status ok">mdi-check</v-icon>
<v-icon v-else-if="fieldHasAnswer(segment.key)" size="16" class="blank-status warn">mdi-alert
</v-icon>
<v-icon
v-if="sttFilledFields[segment.key]"
size="14"
class="blank-status stt-marker"
title="Filled by mic"
>
mdi-microphone
</v-icon>
<small v-if="result" class="blank-feedback">
Expected: {{ fieldExpectedValue(segment.key) }}
</small>
@@ -1126,105 +1086,6 @@
<v-icon size="18">mdi-volume-high</v-icon>
Speak answer
</button>
<button
v-if="sttFeatureVisible"
class="btn"
:class="sttRecording ? 'danger' : 'ghost'"
type="button"
:disabled="sttTranscribing"
@click="toggleSTTRecording"
:title="sttRecording ? 'Stop recording' : 'Speak your readback into the mic'"
>
<v-icon size="18" :class="{ spin: sttTranscribing }">
{{ sttTranscribing ? 'mdi-loading' : (sttRecording ? 'mdi-stop-circle' : 'mdi-microphone') }}
</v-icon>
<template v-if="sttTranscribing">Transcribing…</template>
<template v-else-if="sttRecording">Stop ({{ formatSttDuration(sttRecordingSeconds) }})</template>
<template v-else>Speak readback</template>
</button>
<span
v-else-if="sttSupported && !sttServerAvailable"
class="muted small stt-hint"
title="The speech-to-text backend is currently unreachable"
>
<v-icon size="14">mdi-microphone-off</v-icon>
Speech server unavailable
</span>
</div>
<!-- STT panel: shown once we have a transcription, an error, or
are actively recording. Lets the pilot review what Whisper
heard, edit any mistakes, and re-apply the mapping. -->
<div
v-if="sttFeatureVisible && (sttRecording || sttTranscribing || sttLastTranscription || sttError)"
class="stt-panel"
:class="{ 'is-recording': sttRecording, 'is-error': !!sttError }"
>
<div class="stt-panel-head">
<div class="stt-panel-title">
<v-icon size="16" :class="{ spin: sttTranscribing }">
{{ sttRecording ? 'mdi-microphone' : (sttTranscribing ? 'mdi-loading' : (sttError ? 'mdi-alert-circle' : 'mdi-microphone-message')) }}
</v-icon>
<template v-if="sttRecording">Recording — {{ formatSttDuration(sttRecordingSeconds) }}</template>
<template v-else-if="sttTranscribing">Transcribing your readback…</template>
<template v-else-if="sttError">Couldn't transcribe</template>
<template v-else>Whisper heard</template>
</div>
<div v-if="sttLastFillSummary" class="stt-summary">
Filled {{ sttLastFillSummary.filled }} / {{ sttLastFillSummary.total }} field{{ sttLastFillSummary.total === 1 ? '' : 's' }}
</div>
</div>
<!-- Per-field readback debug: what was recognised vs missing. -->
<div v-if="!sttRecording && !sttTranscribing && sttLastReport && sttLastReport.fields.length" class="stt-report">
<div
v-for="f in sttLastReport.fields"
:key="f.key"
class="stt-report-row"
:class="f.matched ? 'is-ok' : 'is-missing'"
>
<v-icon size="13">{{ f.matched ? 'mdi-check-circle' : 'mdi-close-circle' }}</v-icon>
<span class="stt-report-field">{{ f.key }}</span>
<span class="stt-report-expected">{{ f.expected || '—' }}</span>
<span v-if="f.matched" class="stt-report-via">recognised ({{ f.view }})</span>
<span v-else class="stt-report-via">not recognised</span>
</div>
<div class="stt-report-folded">folded: {{ sttLastReport.denormalized }}</div>
</div>
<div v-if="sttError" class="stt-error-body">{{ sttError }}</div>
<div v-else-if="sttRecording || sttTranscribing" class="stt-waiting">
<span v-if="sttRecording" class="stt-rec-dot" aria-hidden="true"></span>
<span v-if="sttRecording">Speak clearly into your microphone. Click <strong>Stop</strong> when done.</span>
<span v-else>Sending audio to the speech server</span>
</div>
<template v-else-if="sttLastTranscription">
<textarea
v-model="sttEditableTranscription"
class="stt-textarea"
rows="2"
aria-label="Transcribed readback (editable)"
spellcheck="false"
autocorrect="off"
autocapitalize="none"
/>
<div class="stt-panel-actions">
<button class="btn primary" type="button" @click="applySttTranscription">
<v-icon size="16">mdi-arrow-down-bold-circle</v-icon>
Apply to fields
</button>
<button class="btn ghost" type="button" @click="toggleSTTRecording" :disabled="sttTranscribing">
<v-icon size="16">mdi-microphone</v-icon>
Record again
</button>
<button class="btn ghost" type="button" @click="clearSttResult">
<v-icon size="16">mdi-close</v-icon>
Dismiss
</button>
</div>
</template>
</div>
<div v-if="result" class="score">
<div class="score-num">{{ result.score }}%</div>
@@ -1926,53 +1787,6 @@ const manualSectionsOpen = reactive<Record<ManualSection, boolean>>({
const router = useRouter()
const route = useRoute()
type ExperienceId = 'classroom' | 'live'
type ExperienceOption = {
id: ExperienceId
label: string
description: string
icon: string
to: string
target?: '_self' | '_blank'
matches: (path: string) => boolean
}
const experiences: ExperienceOption[] = [
{
id: 'classroom',
label: 'Classroom',
description: 'Mission hub & drills',
icon: 'mdi-school',
to: '/classroom',
target: '_self',
matches: path => path.startsWith('/classroom') || path.startsWith('/classroom-introduction')
},
{
id: 'live',
label: 'Live ATC',
description: 'Live radio with AI controllers',
icon: 'mdi-radio-handheld',
to: '/bridge',
target: '_blank',
matches: path => path.startsWith('/live-atc') || path.startsWith('/pm')
}
]
const experienceMenu = ref(false)
const activeExperience = computed<ExperienceOption>(() => {
const path = route.path
return experiences.find(option => option.matches(path)) ?? experiences[0]
})
async function handleExperienceSelect(option: ExperienceOption) {
experienceMenu.value = false
if (option.matches(route.path)) return
if (option.target === '_blank') {
window.open(option.to, '_blank')
return
}
await router.push(option.to)
}
const ROUTE_STATE_KEYS = ['panel', 'module', 'stage', 'lesson', 'plan'] as const
type RouteStateKey = typeof ROUTE_STATE_KEYS[number]
@@ -5244,16 +5058,64 @@ onBeforeUnmount(() => {
padding: 0 16px;
}
.hud-logo {
.hud-back {
display: inline-flex;
align-items: center;
justify-content: center;
width: 44px;
gap: 8px;
height: 44px;
padding: 0 14px 0 10px;
border-radius: 14px;
border: 1px solid color-mix(in srgb, var(--accent) 38%, transparent);
background: color-mix(in srgb, var(--accent) 14%, transparent);
color: var(--accent);
text-decoration: none;
transition: border-color .2s ease, background .2s ease, color .2s ease;
}
.hud-back:hover,
.hud-back:focus-visible {
border-color: color-mix(in srgb, var(--accent) 54%, transparent);
background: color-mix(in srgb, var(--accent) 22%, transparent);
outline: none;
}
.hud-back:focus-visible {
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 30%, transparent);
}
.hud-back-icon {
color: currentColor;
}
.hud-back-body {
display: flex;
flex-direction: column;
line-height: 1.05;
}
.hud-back-label {
font-weight: 600;
font-size: 14px;
}
.hud-back-sub {
font-size: 10px;
text-transform: uppercase;
letter-spacing: .12em;
opacity: .7;
}
.hud-logo {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
height: 44px;
padding: 0 14px;
border-radius: 14px;
border: 1px solid var(--border);
background: transparent;
color: var(--t2);
transition: border-color .2s ease, background .2s ease, color .2s ease;
}

View File

@@ -34,10 +34,20 @@
<v-icon icon="mdi-school-outline" size="20" class="text-cyan-200" />
</div>
<div class="flex-1">
<p class="text-base font-semibold text-white">Classroom</p>
<p class="text-base font-semibold text-white">1. Classroom</p>
<p class="mt-1 text-sm text-white/70">
Structured, self-paced radio practice. No simulator required this is where most pilots start.
</p>
<div class="mt-3 flex flex-wrap items-center gap-2">
<span class="equip-chip equip-on">
<v-icon icon="mdi-headphones" size="14" />
Headphones
</span>
<span class="equip-chip equip-off">
<v-icon icon="mdi-microphone-off" size="14" />
No mic needed
</span>
</div>
</div>
<span class="btn primary mt-1 self-start">
Start Classroom
@@ -67,11 +77,21 @@
<v-icon icon="mdi-radar" size="20" class="text-amber-200" />
</div>
<div class="flex-1">
<p class="text-base font-semibold text-white">Live ATC</p>
<p class="text-base font-semibold text-white">2. Live ATC</p>
<p class="mt-1 text-sm text-white/70">
Connect your simulator and fly against our live AI controller. Early alpha not everything works
reliably yet. We'd love your bug reports; this is also how we figure out where to take it next.
</p>
<div class="mt-3 flex flex-wrap items-center gap-2">
<span class="equip-chip equip-on equip-amber">
<v-icon icon="mdi-headphones" size="14" />
Headphones
</span>
<span class="equip-chip equip-on equip-amber">
<v-icon icon="mdi-microphone" size="14" />
Microphone
</span>
</div>
</div>
<span class="btn soft mt-1 self-start">
Try Live ATC (Alpha)
@@ -100,11 +120,21 @@
<v-icon icon="mdi-radar" size="20" class="text-white/50" />
</div>
<div class="flex-1">
<p class="text-base font-semibold text-white">Live ATC</p>
<p class="text-base font-semibold text-white">2. Live ATC</p>
<p class="mt-1 text-sm text-white/70">
Connect your simulator and fly against our live AI controller. Early alpha — not everything works
reliably yet. We'd love your bug reports; this is also how we figure out where to take it next.
</p>
<div class="mt-3 flex flex-wrap items-center gap-2">
<span class="equip-chip equip-on">
<v-icon icon="mdi-headphones" size="14" />
Headphones
</span>
<span class="equip-chip equip-on">
<v-icon icon="mdi-microphone" size="14" />
Microphone
</span>
</div>
</div>
<p class="mt-1 text-xs uppercase tracking-[0.2em] text-white/40">Unlocks {{ unlockEstimate }} after signup</p>
</div>
@@ -116,7 +146,6 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useHead, useRoute, useRouter } from '#imports'
import { useAuthStore } from '~/stores/auth'
import { CLASSROOM_INTRO_STORAGE_KEY } from '~~/shared/constants/storage'
definePageMeta({ middleware: 'require-auth' })
@@ -125,29 +154,12 @@ useHead({ title: 'Choose your mode • OpenSquawk' })
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const LIVE_ATC_UNLOCK_AFTER_MS = 24 * 60 * 60 * 1000
const isFirstTime = computed(() => route.query.firstTime === '1')
const liveAtcUnlocked = computed(() => {
const createdAt = auth.user?.createdAt
if (!createdAt) return true
const createdAtMs = new Date(createdAt).getTime()
if (Number.isNaN(createdAtMs)) return true
return Date.now() - createdAtMs >= LIVE_ATC_UNLOCK_AFTER_MS
})
const unlockEstimate = computed(() => {
const createdAt = auth.user?.createdAt
if (!createdAt) return ''
const createdAtMs = new Date(createdAt).getTime()
if (Number.isNaN(createdAtMs)) return ''
const remainingMs = LIVE_ATC_UNLOCK_AFTER_MS - (Date.now() - createdAtMs)
const remainingHours = Math.max(1, Math.ceil(remainingMs / (60 * 60 * 1000)))
return `in ~${remainingHours}h`
})
// Both modes are unlocked immediately on signup.
const liveAtcUnlocked = computed(() => true)
const unlockEstimate = computed(() => '')
function goToClassroom() {
const introCompleted = typeof window !== 'undefined' && localStorage.getItem(CLASSROOM_INTRO_STORAGE_KEY) === 'true'
@@ -155,3 +167,38 @@ function goToClassroom() {
}
</script>
<style scoped>
.equip-chip {
display: inline-flex;
align-items: center;
gap: 0.3rem;
border-radius: 9999px;
border: 1px solid rgba(255, 255, 255, 0.16);
background: rgba(255, 255, 255, 0.06);
padding: 0.2rem 0.6rem;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.02em;
color: rgba(255, 255, 255, 0.75);
white-space: nowrap;
}
.equip-chip.equip-on {
border-color: rgba(103, 232, 249, 0.4);
background: rgba(34, 211, 238, 0.12);
color: rgb(165, 243, 252);
}
.equip-chip.equip-amber {
border-color: rgba(251, 191, 36, 0.4);
background: rgba(251, 191, 36, 0.12);
color: rgb(253, 230, 138);
}
.equip-chip.equip-off {
border-color: rgba(255, 255, 255, 0.14);
background: rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.55);
}
</style>