diff --git a/app/pages/classroom.vue b/app/pages/classroom.vue
index d0ed58b..8598e7e 100644
--- a/app/pages/classroom.vue
+++ b/app/pages/classroom.vue
@@ -1035,6 +1035,23 @@
{{ info }}
+
+
+
+
+ {{ entry.label }}
+ {{ entry.value }}
+
+
+
@@ -1182,6 +1199,9 @@
Next mission: {{ nextMissionMeta.module.title }} · Mission {{ nextMissionMeta.position }} of
{{ nextMissionMeta.total }}
+
@@ -2686,6 +2706,7 @@ type CachedAudio = { base64: string; mime?: string; model?: string | null; speed
const sayCache = new Map()
const pendingSayRequests = new Map>()
const audioReveal = ref(true)
+const referenceOpen = ref(false)
const toast = ref({show: false, text: ''})
const showSettings = ref(false)
@@ -3241,6 +3262,45 @@ const targetPhrase = computed(() => {
return displayCallsign(activeLesson.value.phrase(scenario.value), scenario.value)
})
const lessonInfo = computed(() => (activeLesson.value && scenario.value ? activeLesson.value.info(scenario.value) : []))
+
+const lessonReference = computed(() => {
+ if (!activeLesson.value || !scenario.value) return []
+ const lesson = activeLesson.value
+ const s = scenario.value
+ if (lesson.reference) return lesson.reference(s)
+ const entries: { label: string; value: string }[] = []
+ const fieldKeys = new Set(lesson.fields.map(f => f.key))
+ const phraseText = lesson.phrase(s)
+ const hasCallsign = fieldKeys.has('callsign') || fieldKeys.has('rc-callsign')
+ || fieldKeys.has('tko-callsign') || fieldKeys.has('landing-callsign')
+ || phraseText.includes(s.radioCall)
+ if (hasCallsign) {
+ entries.push({ label: 'Callsign', value: s.callsign })
+ entries.push({ label: 'Radio call', value: s.radioCall })
+ if (s.airlineCode && s.airlineCall) {
+ entries.push({ label: 'Airline', value: `${s.airlineCode} = ${s.airlineCall}` })
+ }
+ }
+ if (phraseText.includes(s.airport.city) || phraseText.includes(s.airport.icao)) {
+ entries.push({ label: 'Airport', value: `${s.airport.icao} ${s.airport.name}` })
+ }
+ if (phraseText.includes(s.destination.city) || phraseText.includes(s.destination.icao)) {
+ entries.push({ label: 'Destination', value: `${s.destination.icao} ${s.destination.name}` })
+ }
+ if (phraseText.includes(s.sid)) {
+ entries.push({ label: 'SID', value: s.sid })
+ }
+ if (phraseText.includes(s.transition)) {
+ entries.push({ label: 'Transition', value: s.transition })
+ }
+ if (phraseText.includes(s.approach)) {
+ entries.push({ label: 'Approach', value: s.approach })
+ }
+ if (s.arrivalStar && phraseText.includes(s.arrivalStar)) {
+ entries.push({ label: 'STAR', value: s.arrivalStar })
+ }
+ return entries
+})
const showScenarioPracticeHint = computed(() => {
if (!current.value || !activeLesson.value) return false
const index = modules.value.findIndex(module => module.id === current.value?.id)
@@ -3324,6 +3384,15 @@ const lessonHasInput = computed(() => {
})
})
+const isAtEndOfCurriculum = computed(() => {
+ if (!current.value || !activeLesson.value) return false
+ if (nextLessonMeta.value || nextMissionMeta.value) return false
+ const lastModule = modules.value[modules.value.length - 1]
+ if (!lastModule || current.value.id !== lastModule.id) return false
+ const lessons = lastModule.lessons
+ return activeLesson.value.id === lessons[lessons.length - 1]?.id
+})
+
const canAdvanceLesson = computed(() => Boolean(nextLessonMeta.value || nextMissionMeta.value))
const missionFooterNoop = () => {
@@ -3341,6 +3410,15 @@ const missionFooterPrimary = computed(() => {
}
if (!lessonHasInput.value) {
+ if (isAtEndOfCurriculum.value) {
+ return {
+ label: 'Back to hub',
+ icon: 'mdi-view-dashboard',
+ disabled: false,
+ action: () => { panel.value = 'hub' },
+ mode: 'is-skip'
+ }
+ }
return {
label: 'Skip lesson',
icon: 'mdi-skip-next',
@@ -3350,6 +3428,16 @@ const missionFooterPrimary = computed(() => {
}
}
+ if (isAtEndOfCurriculum.value) {
+ return {
+ label: 'Back to hub',
+ icon: 'mdi-view-dashboard',
+ disabled: false,
+ action: () => { panel.value = 'hub' },
+ mode: 'is-next'
+ }
+ }
+
const icon = nextLessonMeta.value ? 'mdi-arrow-right' : nextMissionMeta.value ? 'mdi-flag-checkered' : 'mdi-arrow-right'
return {
label: nextActionLabel.value,
@@ -6504,6 +6592,54 @@ onMounted(() => {
opacity: 0.8;
}
+.reference-section {
+ margin-top: 8px;
+ border-top: 1px solid var(--b2, rgba(255, 255, 255, 0.08));
+ padding-top: 6px;
+}
+
+.reference-toggle {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ background: none;
+ border: none;
+ color: var(--t2, rgba(255, 255, 255, 0.6));
+ font-size: 12px;
+ cursor: pointer;
+ padding: 2px 0;
+ transition: color 0.15s;
+}
+
+.reference-toggle:hover {
+ color: var(--t1, rgba(255, 255, 255, 0.9));
+}
+
+.reference-toggle-label {
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ font-weight: 600;
+}
+
+.reference-table {
+ display: grid;
+ grid-template-columns: auto 1fr;
+ gap: 2px 12px;
+ padding: 6px 0 2px 20px;
+ font-size: 12px;
+ line-height: 1.6;
+}
+
+.reference-label {
+ color: var(--t2, rgba(255, 255, 255, 0.6));
+ white-space: nowrap;
+}
+
+.reference-value {
+ color: var(--t1, rgba(255, 255, 255, 0.9));
+ font-family: var(--font-mono, monospace);
+}
+
.audio-blur {
filter: blur(8px);
pointer-events: none;
diff --git a/shared/data/learnModules.ts b/shared/data/learnModules.ts
index 2f34ca8..33dc12d 100644
--- a/shared/data/learnModules.ts
+++ b/shared/data/learnModules.ts
@@ -970,10 +970,21 @@ const readbackLessons: Lesson[] = [
desc: 'Acknowledge the takeoff clearance',
keywords: ['Tower', 'Departure'],
hints: [
- 'Order: runway – cleared for takeoff – call sign.',
- 'Wind information can be omitted if it was not given.'
+ 'Order: wind – runway – cleared for takeoff – call sign.',
+ 'Write the wind as direction/speed, e.g. 030/12.'
],
fields: [
+ {
+ key: 'tko-wind',
+ label: 'Wind',
+ expected: scenario => scenario.wind,
+ alternatives: scenario => [
+ scenario.wind,
+ `${scenario.wind}KT`,
+ scenario.windWords
+ ],
+ width: 'md'
+ },
{
key: 'tko-runway',
label: 'Runway',
@@ -994,7 +1005,9 @@ const readbackLessons: Lesson[] = [
}
],
readback: [
- { type: 'text', text: 'Runway ' },
+ { type: 'text', text: 'Wind ' },
+ { type: 'field', key: 'tko-wind', width: 'md' },
+ { type: 'text', text: ', runway ' },
{ type: 'field', key: 'tko-runway', width: 'sm' },
{ type: 'text', text: ', cleared for takeoff, ' },
{ type: 'field', key: 'tko-callsign', width: 'lg' }
@@ -1500,10 +1513,21 @@ const readbackLessons: Lesson[] = [
desc: 'Read back the landing clearance',
keywords: ['Tower', 'Landing', 'Readback'],
hints: [
- 'Lead with the runway, then "cleared to land".',
- 'Keep the call sign at the end.'
+ 'Order: wind – runway – cleared to land – call sign.',
+ 'Write the wind as direction/speed, e.g. 260/08.'
],
fields: [
+ {
+ key: 'landing-wind',
+ label: 'Wind',
+ expected: scenario => scenario.arrivalWind,
+ alternatives: scenario => [
+ scenario.arrivalWind,
+ `${scenario.arrivalWind}KT`,
+ scenario.arrivalWindWords
+ ],
+ width: 'md'
+ },
{
key: 'landing-runway',
label: 'Runway',
@@ -1527,7 +1551,9 @@ const readbackLessons: Lesson[] = [
}
],
readback: [
- { type: 'text', text: 'Runway ' },
+ { type: 'text', text: 'Wind ' },
+ { type: 'field', key: 'landing-wind', width: 'md' },
+ { type: 'text', text: ', runway ' },
{ type: 'field', key: 'landing-runway', width: 'sm' },
{ type: 'text', text: ', cleared to land, ' },
{ type: 'field', key: 'landing-callsign', width: 'lg' }
@@ -2863,9 +2889,21 @@ const fullFlightLessons: Lesson[] = [
desc: 'Acknowledge the takeoff clearance',
keywords: ['Tower', 'Departure', 'Flow'],
hints: [
- 'Order: runway – cleared for takeoff – call sign.'
+ 'Order: wind – runway – cleared for takeoff – call sign.',
+ 'Write the wind as direction/speed, e.g. 030/12.'
],
fields: [
+ {
+ key: 'full-tko-wind',
+ label: 'Wind',
+ expected: scenario => scenario.wind,
+ alternatives: scenario => [
+ scenario.wind,
+ `${scenario.wind}KT`,
+ scenario.windWords
+ ],
+ width: 'md'
+ },
{
key: 'full-tko-runway',
label: 'Runway',
@@ -2886,7 +2924,9 @@ const fullFlightLessons: Lesson[] = [
}
],
readback: [
- { type: 'text', text: 'Runway ' },
+ { type: 'text', text: 'Wind ' },
+ { type: 'field', key: 'full-tko-wind', width: 'md' },
+ { type: 'text', text: ', runway ' },
{ type: 'field', key: 'full-tko-runway', width: 'sm' },
{ type: 'text', text: ', cleared for takeoff, ' },
{ type: 'field', key: 'full-tko-callsign', width: 'lg' }
@@ -3170,9 +3210,21 @@ const fullFlightLessons: Lesson[] = [
desc: 'Confirm the landing clearance',
keywords: ['Tower', 'Landing', 'Flow'],
hints: [
- 'Lead with the runway, then "cleared to land".'
+ 'Order: wind – runway – cleared to land – call sign.',
+ 'Write the wind as direction/speed, e.g. 260/08.'
],
fields: [
+ {
+ key: 'full-landing-wind',
+ label: 'Wind',
+ expected: scenario => scenario.arrivalWind,
+ alternatives: scenario => [
+ scenario.arrivalWind,
+ `${scenario.arrivalWind}KT`,
+ scenario.arrivalWindWords
+ ],
+ width: 'md'
+ },
{
key: 'full-landing-runway',
label: 'Runway',
@@ -3193,7 +3245,9 @@ const fullFlightLessons: Lesson[] = [
}
],
readback: [
- { type: 'text', text: 'Runway ' },
+ { type: 'text', text: 'Wind ' },
+ { type: 'field', key: 'full-landing-wind', width: 'md' },
+ { type: 'text', text: ', runway ' },
{ type: 'field', key: 'full-landing-runway', width: 'sm' },
{ type: 'text', text: ', cleared to land, ' },
{ type: 'field', key: 'full-landing-callsign', width: 'lg' }
diff --git a/shared/learn/types.ts b/shared/learn/types.ts
index 4cac165..3748178 100644
--- a/shared/learn/types.ts
+++ b/shared/learn/types.ts
@@ -157,6 +157,11 @@ export type ReadbackSegment =
| { type: 'text'; text: string | ((scenario: Scenario) => string) }
| { type: 'field'; key: string; width?: BlankWidth }
+export type ReferenceEntry = {
+ label: string
+ value: string
+}
+
export type Lesson = {
id: string
title: string
@@ -168,6 +173,7 @@ export type Lesson = {
defaultFrequency?: string
phrase: (scenario: Scenario) => string
info: (scenario: Scenario) => string[]
+ reference?: (scenario: Scenario) => ReferenceEntry[]
generate: () => Scenario
}