Files
OpenSquawk/app/pages/learn.vue
2025-09-21 23:20:16 +02:00

6562 lines
183 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="scene">
<!-- APP BAR -->
<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>
<div class="hud-divider" aria-hidden="true"></div>
<span class="brand">OpenSquawk</span>
<span class="sep">|</span>
<span class="mode">Pilot Voice Prep</span>
</div>
<div class="hud-right">
<!-- ATC Einstellungen -->
<button class="btn ghost" @click="showSettings=true" title="ATC settings">
<v-icon size="18">mdi-tune</v-icon>
ATC
</button>
<NuxtLink class="btn ghost" to="/logout" title="Logout">
<v-icon size="18">mdi-logout</v-icon>
Logout
</NuxtLink>
</div>
</nav>
</header>
<!-- HUB -->
<main v-if="panel==='hub'" class="container" role="main">
<div class="hub-head">
<h2 class="h2">Training Mission Hub</h2>
<div class="muted">Start with the ICAO alphabet & numbers, then basics, ground, and more.</div>
</div>
<div class="tiles">
<div
v-for="m in modules"
:key="m.id"
class="tile"
:class="tileClass(m.id)"
@click="isModuleUnlocked(m.id) && handleModulePrimary(m.id)"
>
<div class="tile-media"
:style="{ backgroundImage: `url(${m.art})` }">
<span v-if="isFreshModule(m.id)" class="tile-badge">
<v-icon size="16">mdi-star</v-icon>
New briefing
</span>
</div>
<div class="tile-body">
<div class="tile-top">
<div class="tile-title">
<v-icon size="18">mdi-flag-checkered</v-icon>
{{ m.title }}
</div>
</div>
<div class="muted small">{{ m.subtitle }}</div>
<div class="line">
<div class="line-fill" :style="{ width: pct(m.id)+'%' }"></div>
</div>
<div class="tile-progress-meta">
<span>{{ doneCount(m.id) }}/{{ m.lessons.length }} lessons</span>
<span>{{ moduleHasProgress(m.id) ? avgScore(m.id) + '%' : '—' }} avg</span>
</div>
<div class="tile-actions">
<button
class="btn primary"
:disabled="!isModuleUnlocked(m.id)"
@click="handleModulePrimary(m.id)"
>
<v-icon size="18">{{ modulePrimaryIcon(m.id) }}</v-icon>
{{ modulePrimaryLabel(m.id) }}
</button>
</div>
</div>
<div v-if="!isModuleUnlocked(m.id)" class="tile-overlay">
<div class="tile-overlay-inner">
<v-icon size="26">mdi-lock-alert</v-icon>
<div class="tile-overlay-text">
<div class="tile-overlay-title">Clearance pending</div>
<div class="tile-overlay-sub">Complete earlier missions to unlock this briefing.</div>
</div>
</div>
</div>
</div>
</div>
</main>
<!-- MODULE -->
<section
v-if="panel==='module' && current"
class="container play"
:class="{ 'has-mission-footer': showLessonActions }"
aria-label="Module"
>
<div class="play-head">
<div class="crumbs">
<button class="link py-2 " @click="goToHub">
<v-icon size="16">mdi-arrow-left</v-icon>
Hub
</button>
<span class="muted" v-if="moduleStage==='lessons'">/ {{ current.title }}</span>
<span class="muted" v-else-if="moduleStage==='setup'">/ Mission setup</span>
<span class="muted" v-else>/ Mission briefing</span>
</div>
<div class="play-tools">
<div v-if="requiresFlightPlan" class="plan-status" :class="{ 'is-ready': !!currentPlan }">
<div class="plan-status-icon">
<v-icon :icon="currentPlan ? 'mdi-check-circle-outline' : 'mdi-alert-circle-outline'" size="22" />
</div>
<div class="plan-status-body">
<span class="plan-status-title">
{{ currentPlan ? (currentPlan.scenario.callsign || currentPlan.scenario.radioCall) : 'Flight plan pending' }}
</span>
<span class="plan-status-sub" v-if="currentPlan">{{ currentPlanRoute }}</span>
<span class="plan-status-sub muted" v-else>Select or import a flight to launch.</span>
</div>
<div v-if="currentPlan && moduleStage==='lessons'" class="plan-status-actions">
<button class="btn ghost mini" type="button" @click="restartCurrentMission()">
<v-icon size="16">mdi-refresh</v-icon>
Plan new flight
</button>
<button class="btn ghost mini" type="button" @click="openMissionBriefing()">
<v-icon size="16">mdi-magnify</v-icon>
Briefing
</button>
</div>
</div>
<div v-if="moduleStage==='lessons'" class="stats">
<span class="stat"><v-icon size="18">mdi-check-circle-outline</v-icon> {{ doneCount(current.id) }}/{{ current.lessons.length }}</span>
<span class="stat"><v-icon size="18">mdi-star</v-icon> Ø {{ avgScore(current.id) }}%</span>
</div>
</div>
</div>
<div v-if="moduleStage==='setup'" class="module-stage-panel mission-setup">
<div class="setup-header">
<h2 class="h3">Choose your flight</h2>
<p class="muted small">Lock in a gate-to-gate scenario before the radio work begins.</p>
</div>
<div class="plan-modes">
<button
v-for="mode in flightPlanModes"
:key="mode.id"
type="button"
class="plan-mode-card"
:aria-pressed="flightPlanMode === mode.id"
:class="{ 'is-active': flightPlanMode === mode.id }"
@click="flightPlanMode = mode.id"
>
<div class="plan-mode-icon">
<v-icon size="22">{{ mode.icon }}</v-icon>
</div>
<div class="plan-mode-body">
<div class="plan-mode-title">{{ mode.title }}</div>
<div class="muted small">{{ mode.desc }}</div>
</div>
</button>
</div>
<div v-if="flightPlanMode==='random'" class="plan-panel">
<div class="plan-summary">
<img :src="currentBriefingArt" alt="Mission hero" class="plan-hero" />
<div class="plan-summary-body">
<span class="plan-tag">Auto flight</span>
<div class="plan-callout">{{ draftPlanScenario?.radioCall }}</div>
<div class="plan-route">{{ draftPlanRoute }}</div>
<div class="muted small">{{ draftPlanTagline }}</div>
</div>
</div>
<div class="plan-actions">
<button class="btn soft" type="button" @click="rerollRandomPlan">
<v-icon size="18">mdi-dice-multiple</v-icon>
Roll another flight
</button>
<button class="btn primary" type="button" @click="enterBriefingFromSetup()" :disabled="!draftPlanScenario">
<v-icon size="18">mdi-clipboard-text-outline</v-icon>
Lock &amp; brief
</button>
</div>
</div>
<form v-else-if="flightPlanMode==='manual'" class="plan-panel manual-panel" @submit.prevent="handleManualSubmit">
<div class="manual-grid">
<div class="manual-form">
<div class="manual-card manual-card--intro">
<div class="manual-card-header">
<div class="manual-card-icon">
<v-icon size="20">mdi-pencil</v-icon>
</div>
<div>
<div class="manual-card-title">Design your own mission</div>
<p class="muted small">Fill in the essentials below. Expand the optional sections when you want to brief gates, taxi routes or procedures.</p>
</div>
</div>
</div>
<div class="manual-card manual-card--required">
<div class="manual-card-header">
<div class="manual-card-icon">
<v-icon size="20">mdi-airplane</v-icon>
</div>
<div>
<div class="manual-card-title">Core flight data</div>
<p class="muted small">We only need a callsign plus departure and destination to spin up a training scenario.</p>
</div>
</div>
<div class="field-grid required-grid">
<label class="field">
<span>Airline ICAO<span class="required-dot" aria-hidden="true"></span></span>
<input v-model="manualForm.airlineCode" maxlength="4" placeholder="DLH" />
</label>
<label class="field">
<span>Flight number<span class="required-dot" aria-hidden="true"></span></span>
<input v-model="manualForm.flightNumber" placeholder="400" />
</label>
<label class="field">
<span>Spoken callsign</span>
<input v-model="manualForm.airlineCall" placeholder="Lufthansa" />
</label>
<label class="field">
<span>Departure ICAO<span class="required-dot" aria-hidden="true"></span></span>
<input v-model="manualForm.departureIcao" placeholder="EDDF" maxlength="4" />
</label>
<label class="field">
<span>Destination ICAO<span class="required-dot" aria-hidden="true"></span></span>
<input v-model="manualForm.destinationIcao" placeholder="KJFK" maxlength="4" />
</label>
</div>
</div>
<div class="manual-card manual-card--optional" :class="{ 'is-open': manualSectionsOpen.departure }">
<button
class="optional-toggle"
type="button"
@click="toggleManualSection('departure')"
:aria-expanded="manualSectionsOpen.departure ? 'true' : 'false'"
>
<div class="manual-card-icon">
<v-icon size="20">mdi-airplane-takeoff</v-icon>
</div>
<div class="optional-body">
<div class="manual-card-title">Departure briefing <span class="optional-chip">optional</span></div>
<p class="muted small">Gate, taxi route, SID and transition.</p>
</div>
<v-icon size="18" class="chevron">mdi-chevron-down</v-icon>
</button>
<transition name="collapse">
<div v-if="manualSectionsOpen.departure" class="optional-fields">
<div class="field-grid">
<label class="field">
<span>City</span>
<input v-model="manualForm.departureCity" placeholder="Frankfurt" />
</label>
<label class="field">
<span>Airport name</span>
<input v-model="manualForm.departureName" placeholder="Frankfurt/Main" />
</label>
<label class="field">
<span>Stand</span>
<input v-model="manualForm.stand" placeholder="A12" />
</label>
<label class="field">
<span>Taxi route</span>
<input v-model="manualForm.taxiRoute" placeholder="N3 U4" />
</label>
<label class="field">
<span>Runway</span>
<input v-model="manualForm.departureRunway" placeholder="25C" />
</label>
<label class="field">
<span>SID</span>
<input v-model="manualForm.sid" placeholder="ANEKI 7S" />
</label>
<label class="field">
<span>Transition</span>
<input v-model="manualForm.transition" placeholder="ANEKI" />
</label>
</div>
</div>
</transition>
</div>
<div class="manual-card manual-card--optional" :class="{ 'is-open': manualSectionsOpen.arrival }">
<button
class="optional-toggle"
type="button"
@click="toggleManualSection('arrival')"
:aria-expanded="manualSectionsOpen.arrival ? 'true' : 'false'"
>
<div class="manual-card-icon">
<v-icon size="20">mdi-airplane-landing</v-icon>
</div>
<div class="optional-body">
<div class="manual-card-title">Arrival setup <span class="optional-chip">optional</span></div>
<p class="muted small">STAR, approach, gate and taxi-in.</p>
</div>
<v-icon size="18" class="chevron">mdi-chevron-down</v-icon>
</button>
<transition name="collapse">
<div v-if="manualSectionsOpen.arrival" class="optional-fields">
<div class="field-grid">
<label class="field">
<span>City</span>
<input v-model="manualForm.destinationCity" placeholder="New York" />
</label>
<label class="field">
<span>Airport name</span>
<input v-model="manualForm.destinationName" placeholder="John F. Kennedy" />
</label>
<label class="field">
<span>Runway</span>
<input v-model="manualForm.arrivalRunway" placeholder="22R" />
</label>
<label class="field">
<span>STAR</span>
<input v-model="manualForm.arrivalStar" placeholder="ROBER 3" />
</label>
<label class="field">
<span>Transition</span>
<input v-model="manualForm.arrivalTransition" placeholder="ROBER" />
</label>
<label class="field">
<span>Approach</span>
<input v-model="manualForm.approach" placeholder="ILS 22R" />
</label>
<label class="field">
<span>Arrival stand</span>
<input v-model="manualForm.arrivalStand" placeholder="Gate 5" />
</label>
<label class="field">
<span>Taxi-in</span>
<input v-model="manualForm.arrivalTaxiRoute" placeholder="B K5" />
</label>
</div>
</div>
</transition>
</div>
<div class="manual-card manual-card--optional" :class="{ 'is-open': manualSectionsOpen.procedures }">
<button
class="optional-toggle"
type="button"
@click="toggleManualSection('procedures')"
:aria-expanded="manualSectionsOpen.procedures ? 'true' : 'false'"
>
<div class="manual-card-icon">
<v-icon size="20">mdi-altimeter</v-icon>
</div>
<div class="optional-body">
<div class="manual-card-title">Altitude &amp; codes <span class="optional-chip">optional</span></div>
<p class="muted small">Initial altitudes, squawk, push timing and remarks.</p>
</div>
<v-icon size="18" class="chevron">mdi-chevron-down</v-icon>
</button>
<transition name="collapse">
<div v-if="manualSectionsOpen.procedures" class="optional-fields">
<div class="field-grid">
<label class="field">
<span>Initial altitude (ft)</span>
<input v-model="manualForm.initialAltitude" inputmode="numeric" placeholder="5000" />
</label>
<label class="field">
<span>Climb altitude (ft)</span>
<input v-model="manualForm.climbAltitude" inputmode="numeric" placeholder="7000" />
</label>
<label class="field">
<span>Squawk</span>
<input v-model="manualForm.squawk" placeholder="4213" />
</label>
<label class="field">
<span>Push delay (min)</span>
<input v-model="manualForm.pushDelay" inputmode="numeric" placeholder="5" />
</label>
<label class="field wide">
<span>Briefing notes</span>
<input v-model="manualForm.remarks" placeholder="Optional remarks" />
</label>
</div>
</div>
</transition>
</div>
<div v-if="manualErrors.length" class="form-errors">
<div v-for="error in manualErrors" :key="error" class="form-error">
<v-icon size="16">mdi-alert</v-icon>
{{ error }}
</div>
</div>
<div class="plan-actions manual-actions">
<button class="btn ghost" type="button" @click="resetManualForm">
<v-icon size="18">mdi-restore</v-icon>
Reset
</button>
<button class="btn primary" type="submit">
<v-icon size="18">mdi-clipboard-text-outline</v-icon>
Build mission briefing
</button>
</div>
</div>
<aside class="manual-preview">
<div class="preview-card">
<div class="preview-header">
<div class="manual-card-icon">
<v-icon size="20">mdi-radar</v-icon>
</div>
<div>
<div class="preview-title">Live mission preview</div>
<p class="muted small">See how your ATC readback will sound as you fill in the form.</p>
</div>
</div>
<div class="preview-section">
<span class="preview-label">Callsign</span>
<div class="preview-value">{{ manualCallsignPreview }}</div>
<div class="preview-sub muted small">{{ manualSpokenPreview }}</div>
</div>
<div class="preview-section">
<span class="preview-label">Route</span>
<div class="preview-value">{{ manualRoutePreview }}</div>
<div class="preview-pills">
<span class="preview-pill">
<v-icon size="14">mdi-airplane-takeoff</v-icon>
{{ manualDepartureSummary }}
</span>
<span class="preview-pill">
<v-icon size="14">mdi-airplane-landing</v-icon>
{{ manualArrivalSummary }}
</span>
</div>
</div>
<div class="preview-section">
<span class="preview-label">Codes</span>
<div class="preview-value">{{ manualCodesSummary }}</div>
<div class="preview-sub muted small">{{ manualRemarksSummary }}</div>
</div>
</div>
</aside>
</div>
</form>
<div v-else class="plan-panel simbrief-panel">
<div class="simbrief-hero-card">
<img src="/img/learn/missions/full-flight/briefing-hero.png" alt="SimBrief import preview" class="simbrief-hero-art" />
<div class="simbrief-hero-overlay">
<span class="simbrief-tag">SimBrief import</span>
<h3 class="simbrief-hero-title">Load your airline dispatch</h3>
<p class="simbrief-hero-text">
Sync the exact OFP you're flying with one click. We'll transform it into a mission-ready briefing and readback drill.
</p>
<div class="simbrief-hero-highlights">
<span><v-icon size="16">mdi-airplane-cog</v-icon> Real routes</span>
<span><v-icon size="16">mdi-clock-fast</v-icon> Instant setup</span>
<a
href="https://www.simbrief.com/home/flight_planning.html"
target="_blank"
rel="noopener"
class="simbrief-link"
>
<v-icon size="16">mdi-open-in-new</v-icon>
Open SimBrief dispatch
</a>
</div>
</div>
</div>
<div class="simbrief-body">
<div class="simbrief-step-grid">
<div class="simbrief-step-card">
<div class="simbrief-step-head">
<span class="step-number">1</span>
<span class="simbrief-step-title">Plan in SimBrief</span>
</div>
<p>Generate your flight on SimBrief with the aircraft and payload you want to practice.</p>
</div>
<div class="simbrief-step-card">
<div class="simbrief-step-head">
<span class="step-number">2</span>
<span class="simbrief-step-title">Copy your Pilot ID</span>
</div>
<p>Find the ID on the share link of the finished OFP it's the number we use to fetch your dispatch.</p>
</div>
<div class="simbrief-step-card">
<div class="simbrief-step-head">
<span class="step-number">3</span>
<span class="simbrief-step-title">Paste &amp; import</span>
</div>
<p>Drop the ID below. We'll remember it on this device so future missions load instantly.</p>
</div>
</div>
<form class="simbrief-form" @submit.prevent="loadSimbriefPlan">
<div class="simbrief-form-row">
<label class="field simbrief-field">
<span>SimBrief Pilot ID</span>
<input
v-model="simbriefForm.userId"
inputmode="numeric"
placeholder="11860000"
/>
</label>
<button
class="btn primary simbrief-submit"
type="submit"
:disabled="simbriefForm.loading || !simbriefForm.userId.trim()"
>
<v-icon size="18" :class="{ spin: simbriefForm.loading }">mdi-download</v-icon>
Load latest dispatch
</button>
</div>
</form>
<p class="muted small simbrief-note">
New to SimBrief? It's a free airline-style dispatch planner that pairs perfectly with OpenSquawk missions.
</p>
<div v-if="simbriefForm.loading" class="simbrief-status">
<v-progress-circular indeterminate color="cyan" size="20" />
<span class="muted small">Contacting SimBrief…</span>
</div>
<div v-if="flightPlanError" class="error-banner">
<v-icon size="16">mdi-alert</v-icon>
{{ flightPlanError }}
</div>
<div v-if="simbriefPlanMeta" class="simbrief-meta">
<div class="simbrief-meta-info">
<v-icon size="18">mdi-airplane</v-icon>
<span>{{ simbriefPlanMeta.callsign }} · {{ simbriefPlanMeta.route }}</span>
</div>
<button class="btn soft mini" type="button" @click="enterBriefingFromSetup()">
<v-icon size="16">mdi-clipboard-text-outline</v-icon>
Review briefing
</button>
</div>
</div>
</div>
</div>
<div v-else-if="moduleStage==='briefing' && briefingSnapshot" class="module-stage-panel mission-briefing">
<section class="briefing-hero-banner">
<img :src="currentBriefingArt" alt="Mission hero" class="briefing-hero-bg" />
<div class="briefing-hero-content">
<div class="briefing-tag-row">
<span class="plan-tag">Mission briefing</span>
<span class="briefing-chip">{{ briefingSnapshot.callsign }}</span>
</div>
<h2 class="briefing-hero-title">{{ briefingSnapshot.radioCall }}</h2>
<div class="briefing-hero-route">{{ briefingSnapshot.route }}</div>
<p class="muted small briefing-hero-copy">Take a beat to brief the flight deck before the next call.</p>
<div class="briefing-hero-pills">
<span class="pill">
<v-icon size="16">mdi-airplane-takeoff</v-icon>
{{ briefingSnapshot.departure.icao }} · RWY {{ briefingSnapshot.departure.runway }}
</span>
<span class="pill">
<v-icon size="16">mdi-airplane-landing</v-icon>
{{ briefingSnapshot.arrival.icao }} · RWY {{ briefingSnapshot.arrival.runway }}
</span>
<span class="pill">
<v-icon size="16">mdi-shield-airplane</v-icon>
Squawk {{ briefingSnapshot.codes.squawk }}
</span>
</div>
<div class="briefing-hero-details">
<div class="hero-detail">
<span class="hero-detail-label">ATIS</span>
<span class="hero-detail-value">Information {{ briefingSnapshot.departure.atis }}</span>
</div>
<div class="hero-detail">
<span class="hero-detail-label">Delivery</span>
<span class="hero-detail-value">{{ briefingSnapshot.departure.freq }}</span>
</div>
<div class="hero-detail">
<span class="hero-detail-label">Push</span>
<span class="hero-detail-value">{{ briefingSnapshot.codes.push }}</span>
</div>
<div class="hero-detail">
<span class="hero-detail-label">Initial altitude</span>
<span class="hero-detail-value">{{ briefingSnapshot.altitudes.initial }}</span>
</div>
</div>
</div>
</section>
<div class="briefing-layout">
<div class="briefing-main-grid">
<div class="briefing-card">
<img src="/img/learn/missions/full-flight/briefing-route.png" alt="Route preview" class="briefing-card-art" />
<div class="card-title">
<v-icon size="16">mdi-map-marker-path</v-icon>
Flight deck setup
</div>
<ul class="briefing-list">
<li><strong>Push</strong>: {{ briefingSnapshot.codes.push }}</li>
<li><strong>ATIS</strong>: Information {{ briefingSnapshot.departure.atis }}</li>
<li><strong>Delivery</strong>: {{ briefingSnapshot.departure.freq }} · {{ briefingSnapshot.departure.freqWords }}</li>
</ul>
</div>
<div class="briefing-card">
<img src="/img/learn/missions/full-flight/briefing-departure.png" alt="Departure" class="briefing-card-art" />
<div class="card-title">
<v-icon size="16">mdi-airplane-takeoff</v-icon>
Departure flow
</div>
<ul class="briefing-list">
<li><strong>Stand</strong>: {{ briefingSnapshot.departure.stand || 'As assigned' }}</li>
<li><strong>Taxi</strong>: {{ briefingSnapshot.departure.taxiRoute || 'As assigned' }}</li>
<li><strong>SID</strong>: {{ briefingSnapshot.departure.sid }} · {{ briefingSnapshot.departure.transition }}</li>
<li><strong>Initial altitude</strong>: {{ briefingSnapshot.altitudes.initial }}</li>
</ul>
</div>
<div class="briefing-card">
<img src="/img/learn/missions/full-flight/briefing-arrival.png" alt="Arrival" class="briefing-card-art" />
<div class="card-title">
<v-icon size="16">mdi-airplane-landing</v-icon>
Arrival setup
</div>
<ul class="briefing-list">
<li><strong>STAR</strong>: {{ briefingSnapshot.arrival.star }} · {{ briefingSnapshot.arrival.transition }}</li>
<li><strong>Approach</strong>: {{ briefingSnapshot.arrival.approach }}</li>
<li><strong>Taxi-in</strong>: {{ briefingSnapshot.arrival.taxiRoute || 'As assigned' }}</li>
<li><strong>Arrival stand</strong>: {{ briefingSnapshot.arrival.stand || 'As assigned' }}</li>
</ul>
</div>
<div class="briefing-card">
<img src="/img/learn/missions/full-flight/briefing-weather.png" alt="Weather" class="briefing-card-art" />
<div class="card-title">
<v-icon size="16">mdi-weather-cloudy</v-icon>
Weather snapshot
</div>
<ul class="briefing-list">
<li><strong>Departure wind</strong>: {{ briefingSnapshot.weather.depWind }}</li>
<li><strong>Departure QNH</strong>: {{ briefingSnapshot.weather.depQnh }}</li>
<li><strong>Arrival wind</strong>: {{ briefingSnapshot.weather.arrWind }}</li>
<li><strong>Arrival QNH</strong>: {{ briefingSnapshot.weather.arrQnh }}</li>
</ul>
</div>
</div>
<aside class="briefing-sidebar">
<div class="briefing-card briefing-checklist-card">
<div class="card-title">
<v-icon size="16">mdi-clipboard-text-outline</v-icon>
Mission flow
</div>
<ol class="briefing-checklist">
<li>
<span class="check-number">1</span>
<div>
<div class="check-title">Clearance &amp; push</div>
<p class="muted small">Tune delivery, confirm ATIS {{ briefingSnapshot.departure.atis }} and expect push {{ briefingSnapshot.codes.push }}.</p>
</div>
</li>
<li>
<span class="check-number">2</span>
<div>
<div class="check-title">Taxi &amp; departure</div>
<p class="muted small">Follow taxi {{ briefingSnapshot.departure.taxiRoute || 'as assigned' }} to RWY {{ briefingSnapshot.departure.runway }} and fly the {{ briefingSnapshot.departure.sid }}.</p>
</div>
</li>
<li>
<span class="check-number">3</span>
<div>
<div class="check-title">Arrival briefing</div>
<p class="muted small">Plan for {{ briefingSnapshot.arrival.star }} leading to {{ briefingSnapshot.arrival.approach }} and taxi to {{ briefingSnapshot.arrival.stand || 'assigned stand' }}.</p>
</div>
</li>
</ol>
</div>
</aside>
</div>
<div class="briefing-actions">
<button
v-if="briefingReturnStage==='setup'"
class="btn ghost"
type="button"
@click="backToSetupFromBriefing()"
>
<v-icon size="18">mdi-arrow-left</v-icon>
Adjust plan
</button>
<button class="btn primary" type="button" @click="handleBriefingConfirm()">
<v-icon size="18">{{ briefingReturnStage==='setup' ? 'mdi-airplane' : 'mdi-play-circle' }}</v-icon>
{{ briefingReturnStage==='setup' ? 'Start mission' : 'Return to mission' }}
</button>
</div>
</div>
<div v-else class="module-content">
<div
class="module-overview"
:class="{
'is-expanded': moduleOverviewHoldOpen,
'is-active': moduleOverviewIsOpen
}"
@mouseenter="handleModuleOverviewHover(true)"
@mouseleave="handleModuleOverviewHover(false)"
@focusin="handleModuleOverviewFocusIn"
@focusout="handleModuleOverviewFocusOut"
>
<div v-if="current" class="module-overview-header">
<div class="module-overview-meta">
<div>
<span class="module-overview-chip">Mission overview</span>
</div>
<h3 class="module-overview-title">{{ current.title }}</h3>
<p class="module-overview-sub">{{ current.subtitle }}</p>
</div>
<div class="module-overview-tools">
<button
class="module-overview-toggle"
type="button"
@click="toggleModuleOverview"
:aria-expanded="moduleOverviewIsOpen ? 'true' : 'false'"
:aria-controls="moduleOverviewTrackId"
>
<span class="module-overview-toggle-label">Mission lessons</span>
<v-icon size="16" class="module-overview-toggle-icon">mdi-chevron-down</v-icon>
</button>
<div
class="module-overview-progress"
role="progressbar"
aria-label="Mission progress"
:aria-valuenow="pct(current.id)"
aria-valuemin="0"
aria-valuemax="100"
>
<span class="module-overview-progress-label">Progress</span>
<div class="module-overview-progress-bar">
<div class="module-overview-progress-fill" :style="{ width: pct(current.id) + '%' }"></div>
</div>
<span class="module-overview-progress-meta">{{ doneCount(current.id) }}/{{ current.lessons.length }} lessons</span>
</div>
</div>
</div>
<div class="lesson-track" ref="lessonTrack" :id="moduleOverviewTrackId">
<div class="lesson-grid">
<button
v-for="l in current.lessons"
:key="l.id"
class="lesson"
:data-lesson="l.id"
:class="{ active: activeLesson && activeLesson.id===l.id, ok: bestScore(current.id,l.id)>=80 }"
@click="selectLesson(l)"
>
<div class="lesson-top">
<div class="lesson-title">
<v-icon size="18">mdi-headset</v-icon>
{{ l.title }}
</div>
<span class="lesson-score" :class="lessonScoreClass(current.id, l.id)">
<v-icon size="14">{{ lessonScoreIcon(current.id, l.id) }}</v-icon>
{{ lessonScoreLabel(current.id, l.id) }}
</span>
</div>
<div class="muted small">{{ l.desc }}</div>
<div class="tags">
<span v-for="k in l.keywords" :key="k" class="tag">{{ k }}</span>
</div>
</button>
</div>
</div>
</div>
<div v-if="activeLesson" class="module-detail">
<div class="console">
<div v-if="scenario && requiresFlightPlan" class="scenario-bar">
<div class="scenario-item">
<span class="scenario-label">Callsign</span>
<div class="scenario-value">{{ scenario.callsign }}</div>
<div class="scenario-sub">{{ scenario.radioCall }}</div>
</div>
<div class="scenario-item">
<span class="scenario-label">Airport</span>
<div class="scenario-value">{{ scenario.airport.icao }}</div>
<div class="scenario-sub">{{ scenario.airport.name }}</div>
</div>
<div class="scenario-item wide">
<span class="scenario-label">Frequencies</span>
<div class="freq-chips">
<button
v-for="freq in scenario.frequencies"
:key="freq.type"
type="button"
class="freq-chip"
:class="{ active: activeFrequency && activeFrequency.type === freq.type }"
@click="setActiveFrequency(freq)"
>
{{ freq.label }} {{ freq.value }}
</button>
</div>
<div v-if="activeFrequency && scenario.frequencyWords[activeFrequency.type]"
class="freq-hint muted small">
{{ scenario.frequencyWords[activeFrequency.type] }}
</div>
</div>
</div>
<div class="console-grid">
<div class="col">
<div class="label">Briefing</div>
<div class="panel">
<div class="target-row">
<div class="target-main">
<div class="muted small">{{ activeLesson.desc }}</div>
<div
class="target-text"
:class="{ 'audio-blur': audioContentHidden }"
:aria-hidden="audioContentHidden ? 'true' : 'false'"
>
{{ targetPhrase }}
</div>
<div v-if="audioContentHidden" class="audio-note muted small">
Audio challenge active listen first.
</div>
</div>
<div class="target-actions">
<button
class="btn soft mini"
type="button"
:disabled="!targetPhrase || ttsLoading"
:aria-busy="ttsLoading ? 'true' : 'false'"
@click="speakTarget()"
>
<v-icon size="16" :class="{ spin: ttsLoading }">
{{ ttsLoading ? 'mdi-loading' : 'mdi-volume-high' }}
</v-icon>
{{ sayButtonLabel }}
</button>
<button
v-if="isSpeaking"
class="btn ghost mini"
type="button"
@click="stopAudio"
>
<v-icon size="16">mdi-stop</v-icon>
Stop
</button>
<button
v-if="audioContentHidden"
class="btn ghost mini"
type="button"
@click="revealAudioContent"
>
<v-icon size="16">mdi-eye</v-icon>
Reveal text
</button>
<button class="btn ghost mini" type="button" @click="repeatLesson">
<v-icon size="16">mdi-dice-5</v-icon>
Roll
</button>
</div>
</div>
</div>
<div class="hints">
<div v-for="hint in activeLesson.hints" :key="hint" class="hint">
<v-icon size="16">mdi-lightbulb-on-outline</v-icon>
{{ hint }}
</div>
<div
v-for="info in lessonInfo"
:key="info"
:class="['hint', 'secondary', { 'audio-blur': audioContentHidden }]"
:aria-hidden="audioContentHidden ? 'true' : 'false'"
>
<v-icon size="16">mdi-information-outline</v-icon>
{{ info }}
</div>
</div>
</div>
<div class="col">
<div class="label">Your readback</div>
<div class="panel readback-panel">
<div class="cloze">
<template v-for="(segment, idx) in activeLesson.readback"
:key="segment.type === 'field' ? `f-${segment.key}` : `t-${idx}`">
<span v-if="segment.type === 'text'" class="cloze-chunk cloze-text">
{{ typeof segment.text === 'function' && scenario ? segment.text(scenario) : segment.text }}
</span>
<label
v-else
class="blank cloze-chunk"
:class="[blankSizeClass(segment.key, segment.width), blankStateClass(segment.key)]"
>
<span class="sr-only">{{ fieldLabel(segment.key) }}</span>
<input
v-model="userAnswers[segment.key]"
:aria-label="fieldLabel(segment.key)"
:placeholder="fieldPlaceholder(segment.key)"
:inputmode="fieldInputmode(segment.key)"
/>
<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>
<small v-if="result" class="blank-feedback">
Soll: {{ fieldExpectedValue(segment.key) }}
</small>
</label>
</template>
</div>
</div>
<div class="row wrap controls">
<button class="btn primary" type="button" :disabled="evaluating" @click="evaluate">
<v-icon size="18">mdi-check</v-icon>
Check
</button>
<button class="btn soft" type="button" @click="clearAnswers">
<v-icon size="18">mdi-eraser</v-icon>
Reset
</button>
<button class="btn ghost" type="button" @click="fillSolution">
<v-icon size="18">mdi-auto-fix</v-icon>
Auto-fill
</button>
</div>
<div v-if="result" class="score">
<div class="score-num">{{ result.score }}%</div>
<div class="muted small">
Fields correct: {{ result.hits }}/{{ activeLesson.fields.length }} · Similarity:
{{ Math.round(result.sim * 100) }}%
</div>
</div>
<div v-if="result" class="field-checks">
<div v-for="field in result.fields" :key="field.key" class="field-check" :class="{ ok: field.pass }">
<div class="field-name">{{ field.label }}</div>
<div class="field-answer">{{ field.answer || '' }}</div>
<div class="field-expected">Expected: {{ field.expected }}</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- NEXT OBJECTIVE -->
<div class="container" v-if="panel==='hub'">
<h2 class="h2">Your progress</h2>
<p class="muted">
Level up by completing lessons and missions. Earn badges as you improve your pilot readbacks.
</p>
<div class="hero-highlight" role="region" aria-label="Next objective">
<div class="hero-orb" :style="{ '--progress': levelProgress + '%' }">
<div class="hero-orb-core">
<span class="hero-orb-level">Lvl {{ level }}</span>
<span class="hero-orb-progress">{{ levelProgress }}%</span>
</div>
</div>
<div class="hero-highlight-meta">
<span class="hero-tag">Next objective</span>
<div class="hero-highlight-title">
{{ primaryObjective ? primaryObjective.title : 'Choose a mission to begin' }}
</div>
<p class="muted small">
{{
primaryObjective
? primaryObjective.description
: 'Open any mission below to start your first readback drills.'
}}
</p>
<div
v-if="primaryObjective"
class="hero-highlight-bar"
role="progressbar"
:aria-valuenow="primaryObjectiveProgress"
aria-valuemin="0"
aria-valuemax="100"
>
<div class="hero-highlight-fill" :style="{ width: primaryObjectiveProgress + '%' }"></div>
</div>
<p v-if="primaryObjective" class="muted small">{{ primaryObjective.status }}</p>
</div>
</div>
</div>
<!-- FOOTER -->
<footer
class="footer footer-fixed"
role="contentinfo"
:class="{ 'is-mission': showLessonActions }"
>
<div v-if="showLessonActions" class="mission-footer-bar">
<div class="container mission-footer">
<div class="mission-footer-section mission-footer-left">
<button
class="btn ghost mission-footer-prev"
type="button"
:disabled="!hasPreviousAction"
@click="goToPreviousLesson"
>
<v-icon size="18">mdi-arrow-left</v-icon>
{{ previousActionLabel }}
</button>
</div>
<div class="mission-footer-center">
<div v-if="nextLessonMeta" class="mission-footer-hint">
Next: {{ nextLessonMeta.lesson.title }} · Lesson {{ nextLessonMeta.position }} of
{{ nextLessonMeta.total }}
</div>
<div v-else-if="nextMissionMeta" class="mission-footer-hint">
Next mission: {{ nextMissionMeta.module.title }} · Mission {{ nextMissionMeta.position }} of
{{ nextMissionMeta.total }}
</div>
<div v-else class="mission-footer-hint">
Last lesson in this mission.
</div>
</div>
<div class="mission-footer-section mission-footer-right">
<button class="btn soft" type="button" @click="repeatLesson">
<v-icon size="18">mdi-dice-5</v-icon>
New scenario
</button>
<button
class="btn primary"
type="button"
:disabled="!nextLessonMeta && !nextMissionMeta"
@click="goToNextLesson"
>
<v-icon size="18">mdi-arrow-right</v-icon>
{{ nextActionLabel }}
</button>
</div>
</div>
</div>
<div v-else class="container footer-container">
<div class="footer-meta">
<span class="muted small">&copy; 2025 OpenSquawk. All rights reserved.</span>
</div>
</div>
</footer>
<!-- SETTINGS DIALOG -->
<v-dialog v-model="showSettings" max-width="720">
<div class="panel dialog">
<h3 class="h3">ATC settings</h3>
<div class="settings">
<div class="set-row">
<div class="set-info">
<span>Browser TTS (Web Speech)</span>
<small class="muted">Runs offline, starts faster, and saves our API calls.</small>
</div>
<v-switch v-model="cfg.tts" color="cyan" hide-details inset/>
</div>
<div class="set-row">
<div class="set-info">
<span>Audio challenge</span>
<small class="muted">Hides target text & info until you reveal it manually.</small>
</div>
<v-switch v-model="cfg.audioChallenge" color="cyan" hide-details inset/>
</div>
<div class="set-row">
<div class="set-info">
<span>Radio level (1..5)</span>
<small class="muted">Simulate readability from barely readable to loud and clear.</small>
</div>
<v-slider
hide-details
v-model="cfg.radioLevel"
:min="1"
:max="5"
:step="1"
color="cyan"
thumb-label
/>
</div>
<div class="set-row">
<div class="set-info">
<span>ATC speaking speed</span>
<small class="muted">{{ audioSpeedDisplay }}× — slow practice to rapid live traffic.</small>
</div>
<v-slider
hide-details
v-model="cfg.audioSpeed"
:min="0.7"
:max="1.3"
:step="0.05"
color="cyan"
thumb-label
/>
</div>
<div class="set-row">
<span>Test TTS</span>
<div class="row">
<button class="btn soft" @click="say('Frankfurt Ground, Lufthansa one two three, request taxi.')">
<v-icon>mdi-volume-high</v-icon>
Test
</button>
<button class="btn ghost" @click="stopAudio">
<v-icon>mdi-stop</v-icon>
Stop
</button>
</div>
</div>
<div class="set-row reset-row">
<div class="set-info">
<span>Reset training data</span>
<small class="muted">Clears XP, progress, and local settings on this device.</small>
</div>
<button class="btn ghost" type="button" @click="resetAll">
<v-icon size="18">mdi-refresh</v-icon>
Reset
</button>
</div>
</div>
<div class="row end">
<button class="btn soft" @click="showSettings=false">Close</button>
</div>
</div>
</v-dialog>
<!-- TOAST -->
<v-snackbar v-model="toast.show" timeout="2200" location="top" color="#22d3ee">
<v-icon class="mr-2">mdi-trophy</v-icon>
{{ toast.text }}
</v-snackbar>
</div>
</template>
<script setup lang="ts">
import {computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, shallowRef, watch} from 'vue'
import {useRoute, useRouter} from '#imports'
import {useApi} from '~/composables/useApi'
import {createDefaultLearnConfig} from '~~/shared/learn/config'
import type {LearnConfig, LearnProgress, LearnState} from '~~/shared/learn/config'
import {learnModules, seedFullFlightScenario} from '~~/shared/data/learnModules'
import {createBaseScenario, digitsToWords, lettersToNato, runwayToWords, altitudeToWords, minutesToWords} from '~~/shared/learn/scenario'
import type {BlankWidth, Frequency, Lesson, LessonField, ModuleDef, Scenario} from '~~/shared/learn/types'
import {loadPizzicatoLite} from '~~/shared/utils/pizzicatoLite'
import type {PizzicatoLite} from '~~/shared/utils/pizzicatoLite'
import {createNoiseGenerators, getReadabilityProfile} from '~~/shared/utils/radioEffects'
definePageMeta({middleware: 'require-auth'})
type Objective = {
id: string
title: string
description: string
progress: number
goal: number
status: string
icon: string
complete: boolean
moduleId?: string
}
type FieldState = {
key: string
label: string
expected: string
answer: string
similarity: number
pass: boolean
}
type ScoreResult = {
score: number
hits: number
sim: number
passed: boolean
fields: FieldState[]
}
function norm(value: string): string {
return value
.toLowerCase()
.normalize('NFD')
.replace(/[^a-z0-9 ]+/g, ' ')
.replace(/\s+/g, ' ')
.trim()
}
function lev(a: string, b: string): number {
const m = a.length
const n = b.length
if (m === 0) return n
if (n === 0) return m
const dp = new Array(n + 1).fill(0)
for (let j = 0; j <= n; j++) dp[j] = j
for (let i = 1; i <= m; i++) {
let prev = dp[0]
dp[0] = i
for (let j = 1; j <= n; j++) {
const temp = dp[j]
const cost = a[i - 1] === b[j - 1] ? 0 : 1
dp[j] = Math.min(dp[j] + 1, dp[j - 1] + 1, prev + cost)
prev = temp
}
}
return dp[n]
}
function similarity(a: string, b: string): number {
if (!a && !b) return 1
const normA = norm(a)
const normB = norm(b)
if (!normA && !normB) return 1
const distance = lev(normA, normB)
const maxLen = Math.max(normA.length, normB.length, 1)
return 1 - distance / maxLen
}
const modules = shallowRef<ModuleDef[]>(learnModules)
type FlightPlanMode = 'random' | 'manual' | 'simbrief'
type MissionPlanState = { scenario: Scenario; mode: FlightPlanMode; updatedAt: number }
type MissionPlanInput = {
callsign?: string
airlineCode?: string
airlineCall?: string
flightNumber?: string
radioCall?: string
departure?: {
icao?: string
name?: string
city?: string
runway?: string
stand?: string
taxi?: string
sid?: string
transition?: string
}
destination?: {
icao?: string
name?: string
city?: string
runway?: string
stand?: string
taxi?: string
star?: string
transition?: string
approach?: string
}
altitudes?: {
initial?: number
climb?: number
}
squawk?: string
pushDelayMinutes?: number
remarks?: string
}
type ManualForm = {
airlineCode: string
airlineCall: string
flightNumber: string
departureIcao: string
departureName: string
departureCity: string
departureRunway: string
stand: string
taxiRoute: string
sid: string
transition: string
destinationIcao: string
destinationName: string
destinationCity: string
arrivalRunway: string
arrivalStand: string
arrivalTaxiRoute: string
arrivalStar: string
arrivalTransition: string
approach: string
initialAltitude: string
climbAltitude: string
squawk: string
pushDelay: string
remarks: string
}
const panel = ref<'hub' | 'module'>('hub')
const current = ref<ModuleDef | null>(null)
const activeLesson = ref<Lesson | null>(null)
const scenario = ref<Scenario | null>(null)
const moduleStage = ref<'lessons' | 'setup' | 'briefing'>('lessons')
const showLessonActions = computed(
() => panel.value === 'module' && moduleStage.value === 'lessons' && !!current.value && !!activeLesson.value
)
const flightPlanMode = ref<FlightPlanMode>('random')
const missionPlans = reactive<Record<string, MissionPlanState>>({})
const draftPlanScenario = ref<Scenario | null>(null)
const manualBaseScenario = ref<Scenario | null>(null)
const briefingReturnStage = ref<'setup' | 'lessons'>('setup')
const pendingAutoStart = ref(false)
const SIMBRIEF_STORAGE_KEY = 'opensquawk:last-simbrief-id'
const manualForm = reactive<ManualForm>({
airlineCode: '',
airlineCall: '',
flightNumber: '',
departureIcao: '',
departureName: '',
departureCity: '',
departureRunway: '',
stand: '',
taxiRoute: '',
sid: '',
transition: '',
destinationIcao: '',
destinationName: '',
destinationCity: '',
arrivalRunway: '',
arrivalStand: '',
arrivalTaxiRoute: '',
arrivalStar: '',
arrivalTransition: '',
approach: '',
initialAltitude: '',
climbAltitude: '',
squawk: '',
pushDelay: '',
remarks: ''
})
const manualErrors = ref<string[]>([])
const flightPlanError = ref<string | null>(null)
const simbriefForm = reactive({ userId: '', loading: false })
const simbriefPlanMeta = ref<{ callsign: string; route: string } | null>(null)
const lessonTrack = ref<HTMLElement | null>(null)
const moduleOverviewExpanded = ref(false)
const moduleOverviewHovering = ref(false)
const moduleOverviewFocusWithin = ref(false)
const isHoverCapable = ref(false)
const moduleOverviewTrackId = computed(() => current.value ? `module-${current.value.id}-lesson-track` : 'module-lesson-track')
const moduleOverviewHoldOpen = computed(() => !isHoverCapable.value || moduleOverviewExpanded.value)
const moduleOverviewIsOpen = computed(() => moduleOverviewHoldOpen.value || moduleOverviewHovering.value || moduleOverviewFocusWithin.value)
type ManualSection = 'departure' | 'arrival' | 'procedures'
const manualSectionsOpen = reactive<Record<ManualSection, boolean>>({
departure: false,
arrival: false,
procedures: false
})
const router = useRouter()
const route = useRoute()
const ROUTE_STATE_KEYS = ['panel', 'module', 'stage', 'lesson', 'plan'] as const
type RouteStateKey = typeof ROUTE_STATE_KEYS[number]
type RouteQueryLike = Record<string, string | string[] | null | undefined>
const ROUTE_STATE_KEY_SET = new Set<RouteStateKey>(ROUTE_STATE_KEYS)
let isHydratingFromRoute = false
let isSyncingRoute = false
let lastSyncedQuerySignature: string | null = null
const normalizeQueryValue = (value: unknown): string | null => {
if (Array.isArray(value)) {
const [first] = value
if (typeof first === 'string') return first
if (first == null) return null
return String(first)
}
if (typeof value === 'string') return value
if (value == null) return null
return String(value)
}
function normalizeQueryRecord(query: Record<string, unknown>): Record<string, string> {
const normalized: Record<string, string> = {}
for (const [key, raw] of Object.entries(query)) {
const value = normalizeQueryValue(raw)
if (value !== null) {
normalized[key] = value
}
}
return normalized
}
function computeQuerySignature(query: Record<string, unknown>): string {
const normalized = normalizeQueryRecord(query)
const keys = Object.keys(normalized).sort()
return keys.map(key => `${key}=${normalized[key]}`).join('&')
}
const isValidStage = (value: string | null): value is 'lessons' | 'setup' | 'briefing' =>
value === 'lessons' || value === 'setup' || value === 'briefing'
const isValidPlanMode = (value: string | null): value is FlightPlanMode =>
value === 'random' || value === 'manual' || value === 'simbrief'
function buildStateRouteQuery(): Record<string, string> {
const state: Record<string, string> = {}
if (panel.value !== 'module' || !current.value) {
return state
}
state.panel = 'module'
state.module = current.value.id
if (moduleStage.value !== 'lessons') {
state.stage = moduleStage.value
}
if (moduleStage.value === 'lessons' && activeLesson.value) {
state.lesson = activeLesson.value.id
}
if (moduleStage.value === 'setup' && flightPlanMode.value !== 'random') {
state.plan = flightPlanMode.value
}
return state
}
function createNextRouteQuery(): Record<string, string> {
const next: Record<string, string> = {}
const currentQuery = route.query as RouteQueryLike
for (const [key, raw] of Object.entries(currentQuery)) {
if (ROUTE_STATE_KEY_SET.has(key as RouteStateKey)) continue
const value = normalizeQueryValue(raw)
if (value !== null) {
next[key] = value
}
}
return {...next, ...buildStateRouteQuery()}
}
function queriesEqual(a: Record<string, unknown>, b: Record<string, unknown>): boolean {
const normalizedA = normalizeQueryRecord(a)
const normalizedB = normalizeQueryRecord(b)
const keys = new Set([...Object.keys(normalizedA), ...Object.keys(normalizedB)])
for (const key of keys) {
if (normalizedA[key] !== normalizedB[key]) {
return false
}
}
return true
}
async function syncRouteFromState(): Promise<void> {
if (typeof window === 'undefined' || isHydratingFromRoute) {
return
}
const nextQuery = createNextRouteQuery()
if (queriesEqual(route.query as Record<string, unknown>, nextQuery)) {
lastSyncedQuerySignature = computeQuerySignature(route.query as Record<string, unknown>)
return
}
const signature = computeQuerySignature(nextQuery)
isSyncingRoute = true
try {
await router.replace({query: nextQuery})
lastSyncedQuerySignature = signature
} catch (error) {
console.error('Failed to sync learn route state', error)
} finally {
isSyncingRoute = false
}
}
function applyRouteStateFromQuery(query: RouteQueryLike) {
isHydratingFromRoute = true
try {
const panelParam = normalizeQueryValue(query.panel)
const moduleParam = normalizeQueryValue(query.module)
const stageParam = normalizeQueryValue(query.stage)
const lessonParam = normalizeQueryValue(query.lesson)
const planParam = normalizeQueryValue(query.plan)
const targetModuleId = panelParam === 'hub' ? null : moduleParam
if (targetModuleId) {
const module = modules.value.find(item => item.id === targetModuleId) || null
if (!module) {
goToHub()
} else {
panel.value = 'module'
if (current.value?.id !== module.id) {
openModule(module.id, {autoStart: false})
}
let desiredStage: 'lessons' | 'setup' | 'briefing' = moduleStage.value
if (stageParam && isValidStage(stageParam)) {
desiredStage = stageParam
} else if (!module.meta?.flightPlan) {
desiredStage = 'lessons'
}
const needsPlan = Boolean(module.meta?.flightPlan)
const storedPlan = missionPlans[module.id]
if (!needsPlan) {
desiredStage = 'lessons'
} else {
if (desiredStage === 'lessons' && !storedPlan?.scenario) {
desiredStage = 'setup'
}
if (desiredStage === 'briefing') {
if (storedPlan?.scenario) {
draftPlanScenario.value = cloneScenarioData(storedPlan.scenario)
flightPlanMode.value = storedPlan.mode
briefingReturnStage.value = 'lessons'
} else if (!draftPlanScenario.value) {
desiredStage = 'setup'
}
}
}
const prevStage = moduleStage.value
if (moduleStage.value !== desiredStage) {
moduleStage.value = desiredStage
}
if (moduleStage.value === 'setup') {
briefingReturnStage.value = 'setup'
activeLesson.value = null
scenario.value = null
if (planParam && isValidPlanMode(planParam)) {
flightPlanMode.value = planParam
}
if (prevStage !== 'setup' && draftPlanScenario.value) {
initSetupState(draftPlanScenario.value)
} else if (!draftPlanScenario.value) {
initSetupState(null)
}
} else if (moduleStage.value === 'lessons') {
if (lessonParam) {
const lesson = module.lessons.find(item => item.id === lessonParam)
if (lesson) {
activeLesson.value = lesson
}
}
if (!activeLesson.value) {
startLessonsForCurrent()
}
}
}
} else {
if (panel.value !== 'hub' || current.value) {
goToHub()
} else {
panel.value = 'hub'
}
}
} finally {
isHydratingFromRoute = false
}
void syncRouteFromState()
}
if (typeof window !== 'undefined') {
watch(
() => [panel.value, current.value?.id ?? null, moduleStage.value, activeLesson.value?.id ?? null, flightPlanMode.value],
() => {
void syncRouteFromState()
}
)
watch(
() => route.query,
newQuery => {
if (isSyncingRoute) {
lastSyncedQuerySignature = computeQuerySignature(newQuery as Record<string, unknown>)
return
}
const signature = computeQuerySignature(newQuery as Record<string, unknown>)
if (signature === lastSyncedQuerySignature) {
return
}
applyRouteStateFromQuery(newQuery as RouteQueryLike)
}
)
}
onMounted(() => {
applyRouteStateFromQuery(route.query as RouteQueryLike)
})
const manualCallsignPreview = computed(() => {
const code = sanitizeUpper(manualForm.airlineCode)
const rawNumber = (manualForm.flightNumber || '').replace(/\s+/g, '')
if (!code && !rawNumber) return 'Awaiting callsign'
return `${code}${rawNumber}`.trim() || 'Awaiting callsign'
})
const manualSpokenPreview = computed(() => {
const call = manualForm.airlineCall?.trim() || sanitizeUpper(manualForm.airlineCode)
const digits = (manualForm.flightNumber || '').replace(/\s+/g, '')
if (!call && !digits) {
return 'We\'ll announce your ICAO code and flight number automatically if you leave this blank.'
}
const words = digits ? digitsToWords(digits) : ''
const speech = [call, words].filter(Boolean).join(' ').trim()
return speech || 'Radio call will mirror your ICAO code and number.'
})
const manualRoutePreview = computed(() => {
const dep = sanitizeUpper(manualForm.departureIcao)
const arr = sanitizeUpper(manualForm.destinationIcao)
if (!dep && !arr) return 'Add departure and destination'
return `${dep || '----'}${arr || '----'}`
})
const manualDepartureSummary = computed(() => {
const icao = sanitizeUpper(manualForm.departureIcao)
const runway = sanitizeUpper(manualForm.departureRunway)
const city = manualForm.departureCity?.trim()
if (!icao && !runway && !city) return 'Set departure details'
const parts = [icao || '----']
if (runway) parts.push(`RWY ${runway}`)
if (city) parts.push(city)
return parts.join(' · ')
})
const manualArrivalSummary = computed(() => {
const icao = sanitizeUpper(manualForm.destinationIcao)
const runway = sanitizeUpper(manualForm.arrivalRunway)
const city = manualForm.destinationCity?.trim()
if (!icao && !runway && !city) return 'Set arrival details'
const parts = [icao || '----']
if (runway) parts.push(`RWY ${runway}`)
if (city) parts.push(city)
return parts.join(' · ')
})
const manualCodesSummary = computed(() => {
const parts: string[] = []
if (manualForm.initialAltitude) parts.push(`Initial ${manualForm.initialAltitude} ft`)
if (manualForm.climbAltitude) parts.push(`Climb ${manualForm.climbAltitude} ft`)
if (manualForm.squawk) parts.push(`Squawk ${sanitizeUpper(manualForm.squawk)}`)
if (manualForm.pushDelay) parts.push(`Push +${manualForm.pushDelay} min`)
return parts.join(' • ') || 'Using default training altitudes and codes.'
})
const manualRemarksSummary = computed(() => manualForm.remarks?.trim() || 'Add notes for controllers or leave blank.')
const flightPlanModes: Array<{ id: FlightPlanMode; title: string; icon: string; desc: string }> = [
{
id: 'random',
title: 'Auto-generate',
icon: 'mdi-dice-multiple',
desc: 'Spin up a surprise full flight with matching ATC flow.'
},
{
id: 'manual',
title: 'Manual setup',
icon: 'mdi-pencil-outline',
desc: 'Enter your own callsign, airports and procedures.'
},
{
id: 'simbrief',
title: 'SimBrief import',
icon: 'mdi-clipboard-text-outline',
desc: 'Pull your latest SimBrief dispatch and brief it in-game.'
}
]
watch(
() => simbriefForm.userId,
value => {
if (typeof window === 'undefined') return
const trimmed = value.trim()
if (trimmed) {
window.localStorage.setItem(SIMBRIEF_STORAGE_KEY, trimmed)
} else {
window.localStorage.removeItem(SIMBRIEF_STORAGE_KEY)
}
}
)
function toggleManualSection(section: ManualSection) {
manualSectionsOpen[section] = !manualSectionsOpen[section]
}
function cloneScenarioData(source: Scenario | null): Scenario | null {
if (!source) return null
if (typeof structuredClone === 'function') {
try {
return structuredClone(source)
} catch (error) {
console.warn('structuredClone failed for scenario clone', error)
}
}
return JSON.parse(JSON.stringify(source)) as Scenario
}
const isNonEmpty = (value?: string | null): value is string => Boolean(value && value.trim().length)
function sanitizeUpper(value?: string): string {
return (value || '').trim().toUpperCase()
}
function ensureList(list: string[] | undefined, value?: string): string[] {
if (!list && !value) return []
const next = Array.isArray(list) ? [...list] : []
if (value) {
const trimmed = value.trim()
if (trimmed && !next.includes(trimmed)) next.push(trimmed)
}
return next
}
function scenarioRoute(s: Scenario | null): string {
if (!s) return ''
return `${s.airport.icao}${s.destination.icao}`
}
function scenarioTagline(s: Scenario | null): string {
if (!s) return ''
return `${s.airport.city}${s.destination.city}`
}
type BriefingSnapshot = {
callsign: string
radioCall: string
route: string
tagline: string
departure: {
city: string
icao: string
runway: string
stand: string
sid: string
transition: string
atis: string
freq: string
freqWords: string
taxiRoute: string
}
arrival: {
city: string
icao: string
runway: string
stand: string
star: string
transition: string
approach: string
taxiRoute: string
}
altitudes: {
initial: string
initialWords: string
climb: string
climbWords: string
}
codes: {
squawk: string
push: string
pushWords: string
}
weather: {
depWind: string
depQnh: string
arrWind: string
arrQnh: string
}
}
function buildBriefingSnapshot(s: Scenario | null): BriefingSnapshot | null {
if (!s) return null
return {
callsign: s.callsign,
radioCall: s.radioCall,
route: scenarioRoute(s),
tagline: scenarioTagline(s),
departure: {
city: s.airport.city,
icao: s.airport.icao,
runway: s.runway,
stand: s.stand,
sid: s.sid,
transition: s.transition,
atis: s.atisCode,
freq: s.deliveryFreq,
freqWords: s.frequencyWords?.DEL || s.deliveryFreq,
taxiRoute: s.taxiRoute
},
arrival: {
city: s.destination.city,
icao: s.destination.icao,
runway: s.arrivalRunway,
stand: s.arrivalStand,
star: s.arrivalStar,
transition: s.arrivalTransition,
approach: s.approach,
taxiRoute: s.arrivalTaxiRoute
},
altitudes: {
initial: formatAltitude(s.altitudes.initial),
initialWords: s.altitudes.initialWords,
climb: formatAltitude(s.altitudes.climb),
climbWords: s.altitudes.climbWords
},
codes: {
squawk: s.squawk,
push: formatPushDelay(s.pushDelayMinutes),
pushWords: s.pushDelayWords
},
weather: {
depWind: s.windWords || s.wind,
depQnh: s.qnhWords,
arrWind: s.arrivalWindWords,
arrQnh: s.arrivalQnhWords
}
}
}
function formatAltitude(value?: number | null): string {
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return '—'
return `${Math.round(value)}ft`
}
function formatPushDelay(minutes?: number | null): string {
if (typeof minutes !== 'number' || !Number.isFinite(minutes) || minutes < 0) return '00:00'
const totalMinutes = Math.round(minutes)
const hours = Math.floor(totalMinutes / 60)
const mins = totalMinutes % 60
return `${hours.toString().padStart(2, '0')}:${mins.toString().padStart(2, '0')}`
}
const parseOptionalNumber = (input: string): number | undefined => {
if (!input) return undefined
const normalized = input.toString().replace(/[^0-9.-]/g, '')
const value = Number.parseFloat(normalized)
return Number.isFinite(value) ? value : undefined
}
function applyPlanToScenario(base: Scenario, plan: MissionPlanInput): Scenario {
const scenario = cloneScenarioData(base)
if (!scenario) return base
const airlineCode = sanitizeUpper(plan.airlineCode) || scenario.airlineCode
const airlineCall = plan.airlineCall?.trim() || scenario.airlineCall || airlineCode
const rawFlightNumber = plan.flightNumber?.toString().trim() || scenario.flightNumber
const normalizedFlightNumber = rawFlightNumber.replace(/\s+/g, '')
const digitsOnly = normalizedFlightNumber.replace(/[^0-9]/g, '') || normalizedFlightNumber
const callsign = plan.callsign?.trim().toUpperCase() || `${airlineCode}${normalizedFlightNumber}`.toUpperCase()
scenario.airlineCode = airlineCode
scenario.airlineCall = airlineCall || airlineCode
scenario.flightNumber = digitsOnly
scenario.flightNumberWords = digitsToWords(digitsOnly)
scenario.callsign = callsign
scenario.callsignNato = lettersToNato(airlineCode)
const numberWords = digitsOnly ? digitsToWords(digitsOnly) : ''
const computedRadio = [scenario.airlineCall, numberWords].filter(Boolean).join(' ').trim()
const radioOverride = plan.radioCall?.trim()
if (radioOverride) {
scenario.radioCall = radioOverride
} else if (computedRadio) {
scenario.radioCall = computedRadio
}
const dep = plan.departure || {}
if (isNonEmpty(dep.icao)) scenario.airport.icao = sanitizeUpper(dep.icao)
if (isNonEmpty(dep.name)) scenario.airport.name = dep.name.trim()
if (isNonEmpty(dep.city)) scenario.airport.city = dep.city.trim()
if (isNonEmpty(dep.runway)) {
const runway = sanitizeUpper(dep.runway)
scenario.runway = runway
scenario.runwayWords = runwayToWords(runway)
scenario.airport.runways = ensureList(scenario.airport.runways, runway)
}
if (isNonEmpty(dep.stand)) {
scenario.stand = sanitizeUpper(dep.stand)
scenario.airport.stands = ensureList(scenario.airport.stands, scenario.stand)
}
if (isNonEmpty(dep.taxi)) scenario.taxiRoute = dep.taxi.trim()
if (isNonEmpty(dep.sid)) {
scenario.sid = sanitizeUpper(dep.sid)
scenario.airport.sids = ensureList(scenario.airport.sids, scenario.sid)
}
if (isNonEmpty(dep.transition)) {
scenario.transition = sanitizeUpper(dep.transition)
scenario.airport.transitions = ensureList(scenario.airport.transitions, scenario.transition)
}
scenario.airport.taxi = ensureList(scenario.airport.taxi, dep.taxi)
const dest = plan.destination || {}
if (isNonEmpty(dest.icao)) scenario.destination.icao = sanitizeUpper(dest.icao)
if (isNonEmpty(dest.name)) scenario.destination.name = dest.name.trim()
if (isNonEmpty(dest.city)) scenario.destination.city = dest.city.trim()
if (isNonEmpty(dest.runway)) {
const runway = sanitizeUpper(dest.runway)
scenario.arrivalRunway = runway
scenario.arrivalRunwayWords = runwayToWords(runway)
scenario.destination.runways = ensureList(scenario.destination.runways, runway)
}
if (isNonEmpty(dest.stand)) {
scenario.arrivalStand = sanitizeUpper(dest.stand)
scenario.destination.stands = ensureList(scenario.destination.stands, scenario.arrivalStand)
}
if (isNonEmpty(dest.taxi)) scenario.arrivalTaxiRoute = dest.taxi.trim()
if (isNonEmpty(dest.star)) {
scenario.arrivalStar = sanitizeUpper(dest.star)
scenario.destination.stars = ensureList(scenario.destination.stars, scenario.arrivalStar)
}
if (isNonEmpty(dest.transition)) {
scenario.arrivalTransition = sanitizeUpper(dest.transition)
scenario.destination.arrivalTransitions = ensureList(scenario.destination.arrivalTransitions, scenario.arrivalTransition)
}
if (isNonEmpty(dest.approach)) {
scenario.approach = dest.approach.trim()
scenario.destination.approaches = ensureList(scenario.destination.approaches, scenario.approach)
}
scenario.destination.taxi = ensureList(scenario.destination.taxi, dest.taxi)
const initialAlt = plan.altitudes?.initial
if (typeof initialAlt === 'number' && Number.isFinite(initialAlt) && initialAlt > 0) {
scenario.altitudes.initial = Math.round(initialAlt)
scenario.altitudes.initialWords = altitudeToWords(scenario.altitudes.initial)
}
const climbAlt = plan.altitudes?.climb
if (typeof climbAlt === 'number' && Number.isFinite(climbAlt) && climbAlt > 0) {
const rounded = Math.max(Math.round(climbAlt), scenario.altitudes.initial + 100)
scenario.altitudes.climb = rounded
scenario.altitudes.climbWords = altitudeToWords(rounded)
}
if (isNonEmpty(plan.squawk)) {
const squawk = plan.squawk.replace(/[^0-7]/g, '')
if (squawk) {
scenario.squawk = squawk
scenario.squawkWords = digitsToWords(squawk)
}
}
if (typeof plan.pushDelayMinutes === 'number' && Number.isFinite(plan.pushDelayMinutes) && plan.pushDelayMinutes > 0) {
const minutes = Math.max(1, Math.round(plan.pushDelayMinutes))
scenario.pushDelayMinutes = minutes
scenario.pushDelayWords = minutesToWords(minutes)
}
if (isNonEmpty(plan.remarks)) {
scenario.remarks = plan.remarks.trim()
}
return scenario
}
function hydrateManualForm(base: Scenario) {
manualForm.airlineCode = base.airlineCode || ''
manualForm.airlineCall = base.airlineCall || ''
manualForm.flightNumber = base.flightNumber || ''
manualForm.departureIcao = base.airport.icao || ''
manualForm.departureName = base.airport.name || ''
manualForm.departureCity = base.airport.city || ''
manualForm.departureRunway = base.runway || ''
manualForm.stand = base.stand || ''
manualForm.taxiRoute = base.taxiRoute || ''
manualForm.sid = base.sid || ''
manualForm.transition = base.transition || ''
manualForm.destinationIcao = base.destination.icao || ''
manualForm.destinationName = base.destination.name || ''
manualForm.destinationCity = base.destination.city || ''
manualForm.arrivalRunway = base.arrivalRunway || ''
manualForm.arrivalStand = base.arrivalStand || ''
manualForm.arrivalTaxiRoute = base.arrivalTaxiRoute || ''
manualForm.arrivalStar = base.arrivalStar || ''
manualForm.arrivalTransition = base.arrivalTransition || ''
manualForm.approach = base.approach || ''
manualForm.initialAltitude = base.altitudes.initial.toString()
manualForm.climbAltitude = base.altitudes.climb.toString()
manualForm.squawk = base.squawk || ''
manualForm.pushDelay = base.pushDelayMinutes?.toString() || ''
manualForm.remarks = base.remarks || ''
}
function resetManualForm() {
if (manualBaseScenario.value) {
hydrateManualForm(manualBaseScenario.value)
} else {
const base = createBaseScenario()
manualBaseScenario.value = cloneScenarioData(base)
hydrateManualForm(base)
}
manualErrors.value = []
}
function initSetupState(seed: Scenario | null) {
const base = cloneScenarioData(seed) || createBaseScenario()
if (!base) return
draftPlanScenario.value = cloneScenarioData(base)
manualBaseScenario.value = cloneScenarioData(base)
hydrateManualForm(base)
manualErrors.value = []
flightPlanError.value = null
simbriefPlanMeta.value = null
}
function rerollRandomPlan() {
const rolled = createBaseScenario()
draftPlanScenario.value = cloneScenarioData(rolled)
manualBaseScenario.value = cloneScenarioData(rolled)
hydrateManualForm(rolled)
flightPlanMode.value = 'random'
}
function enterBriefingFromSetup() {
if (!draftPlanScenario.value) return
briefingReturnStage.value = 'setup'
moduleStage.value = 'briefing'
}
function buildManualScenario(): Scenario | null {
const errors: string[] = []
if (!isNonEmpty(manualForm.airlineCode)) errors.push('Airline ICAO required')
if (!isNonEmpty(manualForm.flightNumber)) errors.push('Flight number required')
if (!isNonEmpty(manualForm.departureIcao)) errors.push('Departure ICAO required')
if (!isNonEmpty(manualForm.destinationIcao)) errors.push('Destination ICAO required')
manualErrors.value = errors
if (errors.length) return null
const base = cloneScenarioData(manualBaseScenario.value) || createBaseScenario()
if (!base) return null
const plan: MissionPlanInput = {
airlineCode: manualForm.airlineCode,
airlineCall: manualForm.airlineCall,
flightNumber: manualForm.flightNumber,
departure: {
icao: manualForm.departureIcao,
name: manualForm.departureName,
city: manualForm.departureCity,
runway: manualForm.departureRunway,
stand: manualForm.stand,
taxi: manualForm.taxiRoute,
sid: manualForm.sid,
transition: manualForm.transition
},
destination: {
icao: manualForm.destinationIcao,
name: manualForm.destinationName,
city: manualForm.destinationCity,
runway: manualForm.arrivalRunway,
stand: manualForm.arrivalStand,
taxi: manualForm.arrivalTaxiRoute,
star: manualForm.arrivalStar,
transition: manualForm.arrivalTransition,
approach: manualForm.approach
},
altitudes: {
initial: parseOptionalNumber(manualForm.initialAltitude),
climb: parseOptionalNumber(manualForm.climbAltitude)
},
squawk: manualForm.squawk,
pushDelayMinutes: parseOptionalNumber(manualForm.pushDelay),
remarks: manualForm.remarks
}
return applyPlanToScenario(base, plan)
}
async function loadSimbriefPlan() {
if (!current.value) return
if (simbriefForm.loading) return
const userId = simbriefForm.userId.trim()
if (!userId) {
flightPlanError.value = 'Please enter your SimBrief ID.'
return
}
flightPlanError.value = null
manualErrors.value = []
simbriefForm.loading = true
try {
const response = await api.get<{ data: any }>('/api/learn/simbrief', { query: { userId }, auth: true })
const payload = response?.data
if (!payload) {
flightPlanError.value = 'No SimBrief dispatch found.'
return
}
const base = createBaseScenario()
const plan = normalizeSimbriefPlan(payload)
const scenario = applyPlanToScenario(base, plan)
draftPlanScenario.value = cloneScenarioData(scenario)
manualBaseScenario.value = cloneScenarioData(scenario)
hydrateManualForm(scenario)
simbriefPlanMeta.value = {
callsign: scenario.radioCall,
route: scenarioRoute(scenario)
}
flightPlanMode.value = 'simbrief'
enterBriefingFromSetup()
} catch (error: any) {
flightPlanError.value = error?.data?.message || error?.message || 'Failed to load SimBrief plan.'
} finally {
simbriefForm.loading = false
}
}
function handleManualSubmit() {
const scenario = buildManualScenario()
if (!scenario) return
draftPlanScenario.value = cloneScenarioData(scenario)
simbriefPlanMeta.value = {
callsign: scenario.radioCall,
route: scenarioRoute(scenario)
}
enterBriefingFromSetup()
}
function confirmPlanAndEnterMission() {
if (!current.value || !draftPlanScenario.value) return
const snapshot = cloneScenarioData(draftPlanScenario.value)
if (!snapshot) return
missionPlans[current.value.id] = {
scenario: snapshot,
mode: flightPlanMode.value,
updatedAt: Date.now()
}
seedFullFlightScenario(cloneScenarioData(snapshot))
briefingReturnStage.value = 'lessons'
moduleStage.value = 'lessons'
pendingAutoStart.value = false
startLessonsForCurrent()
}
function handleBriefingConfirm() {
if (briefingReturnStage.value === 'setup') {
confirmPlanAndEnterMission()
} else {
moduleStage.value = 'lessons'
}
}
function backToSetupFromBriefing() {
if (briefingReturnStage.value !== 'setup') {
moduleStage.value = 'lessons'
return
}
moduleStage.value = 'setup'
if (draftPlanScenario.value) {
initSetupState(draftPlanScenario.value)
}
}
function openMissionBriefing() {
if (!current.value) return
const stored = missionPlans[current.value.id]
if (!stored?.scenario) return
draftPlanScenario.value = cloneScenarioData(stored.scenario)
flightPlanMode.value = stored.mode
briefingReturnStage.value = 'lessons'
moduleStage.value = 'briefing'
}
function restartCurrentMission() {
if (!current.value) return
const moduleId = current.value.id
if (moduleId && missionPlans[moduleId]) delete missionPlans[moduleId]
draftPlanScenario.value = null
manualBaseScenario.value = null
simbriefPlanMeta.value = null
scenario.value = null
flightPlanError.value = null
manualErrors.value = []
flightPlanMode.value = 'manual'
moduleStage.value = 'setup'
briefingReturnStage.value = 'setup'
pendingAutoStart.value = false
initSetupState(null)
}
function startLessonsForCurrent() {
if (!current.value) return
const mod = current.value
const prog = progress.value[mod.id] || {}
const next = mod.lessons.find(lesson => !(prog[lesson.id]?.done)) || mod.lessons[0]
activeLesson.value = next || null
}
function goToHub() {
panel.value = 'hub'
moduleStage.value = 'lessons'
current.value = null
activeLesson.value = null
scenario.value = null
seedFullFlightScenario(null)
}
function readProp(source: any, key: string): any {
if (!source || typeof source !== 'object') return undefined
if (key in source) return source[key]
const lower = key.toLowerCase()
const foundKey = Object.keys(source).find(prop => prop.toLowerCase() === lower)
return foundKey ? source[foundKey] : undefined
}
function extractPath(source: any, path: string): any {
return path.split('.').reduce((acc, segment) => readProp(acc, segment), source)
}
function pickString(source: any, ...paths: string[]): string | undefined {
for (const path of paths) {
const value = extractPath(source, path)
if (typeof value === 'string' && value.trim()) {
return value.trim()
}
}
return undefined
}
function pickNumber(source: any, ...paths: string[]): number | undefined {
for (const path of paths) {
const raw = extractPath(source, path)
if (typeof raw === 'number' && Number.isFinite(raw)) return raw
if (typeof raw === 'string') {
const numeric = Number.parseFloat(raw.replace(/[^0-9.-]/g, ''))
if (Number.isFinite(numeric)) return numeric
}
}
return undefined
}
function normalizeSimbriefPlan(raw: any): MissionPlanInput {
const plan: MissionPlanInput = {}
plan.airlineCode = pickString(raw, 'general.icao_airline', 'general.airline_icao', 'general.icao') || undefined
plan.airlineCall = pickString(raw, 'general.callsign_name', 'general.airline_name', 'general.airline', 'general.callsign') || undefined
plan.flightNumber = pickString(raw, 'general.flight_number', 'general.flt_num', 'general.flightnum') || undefined
plan.callsign = pickString(raw, 'general.callsign') || undefined
plan.radioCall = pickString(raw, 'general.callsign', 'general.callsign_name') || undefined
plan.departure = {
icao: pickString(raw, 'origin.icao_code', 'general.origin', 'plan.origin'),
name: pickString(raw, 'origin.name', 'origin.airport_name', 'origin.station_name'),
city: pickString(raw, 'origin.pos_city', 'origin.city'),
runway: pickString(raw, 'origin.plan_rwy', 'plan.plan_rwy_dept', 'plan.rwy_dep'),
stand: pickString(raw, 'origin.plan_gate', 'plan.gate'),
taxi: pickString(raw, 'origin.plan_taxi_route', 'origin.plan_taxiroute'),
sid: pickString(raw, 'origin.plan_sid', 'plan.plan_sid'),
transition: pickString(raw, 'origin.plan_sid_trans', 'origin.plan_sid_transition', 'plan.sid_transition')
}
plan.destination = {
icao: pickString(raw, 'destination.icao_code', 'general.destination', 'plan.destination'),
name: pickString(raw, 'destination.name', 'destination.airport_name', 'destination.station_name'),
city: pickString(raw, 'destination.pos_city', 'destination.city'),
runway: pickString(raw, 'destination.plan_rwy', 'plan.plan_rwy_arr', 'plan.rwy_arr'),
stand: pickString(raw, 'destination.plan_gate', 'plan.arrival_gate'),
taxi: pickString(raw, 'destination.plan_taxi_route', 'destination.plan_taxiroute'),
star: pickString(raw, 'destination.plan_star', 'plan.plan_star'),
transition: pickString(raw, 'destination.plan_star_trans', 'destination.plan_star_transition', 'plan.star_transition'),
approach: pickString(raw, 'destination.plan_approach', 'plan.plan_approach')
}
const initialAlt = pickNumber(raw, 'atc.initial_altitude', 'plan.initial_altitude', 'general.initial_altitude')
const climbAlt = pickNumber(raw, 'plan.cruise_altitude', 'general.cruise_altitude')
if (initialAlt || climbAlt) {
plan.altitudes = {
initial: initialAlt,
climb: climbAlt
}
}
plan.squawk = pickString(raw, 'atc.squawk', 'general.squawk', 'plan.squawk') || undefined
plan.pushDelayMinutes = pickNumber(raw, 'times.taxi_out', 'general.taxi_out')
plan.remarks = pickString(raw, 'general.remarks', 'plan.remarks') || undefined
return plan
}
const hasSpokenTarget = ref(false)
const pendingAutoSay = ref(false)
const activeFrequency = ref<Frequency | null>(null)
const userAnswers = reactive<Record<string, string>>({})
const result = ref<ScoreResult | null>(null)
const evaluating = ref(false)
const ttsLoading = ref(false)
const isSpeaking = ref(false)
const audioElement = ref<HTMLAudioElement | null>(null)
let speechContext: AudioContext | null = null
let pizzicatoLiteInstance: PizzicatoLite | null = null
type RadioSoundInstance = Awaited<ReturnType<PizzicatoLite['createSoundFromBase64']>>
let activeRadioSound: RadioSoundInstance | null = null
let activeRadioCleanup: Array<() => void> = []
let radioNoiseContext: AudioContext | null = null
let radioNoiseSource: AudioBufferSourceNode | null = null
type CachedAudio = { base64: string; mime?: string; model?: string | null; speed?: number }
const sayCache = new Map<string, CachedAudio>()
const pendingSayRequests = new Map<string, Promise<CachedAudio>>()
const audioReveal = ref(true)
const toast = ref({show: false, text: ''})
const showSettings = ref(false)
const api = useApi()
const isClient = typeof window !== 'undefined'
let hoverCapabilityCleanup: (() => void) | null = null
function resetModuleOverviewExpansion() {
moduleOverviewHovering.value = false
moduleOverviewFocusWithin.value = false
moduleOverviewExpanded.value = !isHoverCapable.value
}
if (import.meta.client) {
const mediaQuery = window.matchMedia('(hover: hover)')
const applyHoverCapability = (matches: boolean) => {
isHoverCapable.value = matches
resetModuleOverviewExpansion()
}
applyHoverCapability(mediaQuery.matches)
const handleHoverCapabilityChange = (event: MediaQueryListEvent) => {
applyHoverCapability(event.matches)
}
if (typeof mediaQuery.addEventListener === 'function') {
mediaQuery.addEventListener('change', handleHoverCapabilityChange)
hoverCapabilityCleanup = () => {
mediaQuery.removeEventListener('change', handleHoverCapabilityChange)
}
} else if (typeof mediaQuery.addListener === 'function') {
mediaQuery.addListener(handleHoverCapabilityChange)
hoverCapabilityCleanup = () => {
mediaQuery.removeListener(handleHoverCapabilityChange)
}
}
watch([panel, moduleStage], ([panelValue, stage]) => {
if (panelValue !== 'module' || stage !== 'lessons') {
resetModuleOverviewExpansion()
}
})
watch(() => current.value?.id, () => {
resetModuleOverviewExpansion()
})
}
const defaultCfg = createDefaultLearnConfig()
const cfg = ref<LearnConfig>({...defaultCfg})
audioReveal.value = !cfg.value.audioChallenge
const XP_PER_LEVEL = 300
const xp = ref(0)
const progress = ref<LearnProgress>({})
const level = computed(() => 1 + Math.floor(xp.value / XP_PER_LEVEL))
const nextLevel = computed(() => level.value + 1)
const levelProgress = computed(() => Math.min(100, Math.round(((xp.value % XP_PER_LEVEL) / XP_PER_LEVEL) * 100)))
const xpToNextLevel = computed(() => Math.max(0, level.value * XP_PER_LEVEL - xp.value))
type BadgeTier = { id: string; name: string; xp: number; description: string }
const badgeTrack: BadgeTier[] = [
{id: 'rookie', name: 'Runway Rookie', xp: 0, description: 'Complete your first guided mission.'},
{id: 'cadet', name: 'Clearance Cadet', xp: 120, description: 'Score three missions with ≥80%.'},
{id: 'navigator', name: 'Taxi Navigator', xp: 360, description: 'Keep the flow going with solid readbacks.'},
{id: 'tower', name: 'Tower Pro', xp: 720, description: 'Master departures with confident phraseology.'}
]
const currentBadge = computed(() => {
let unlocked = badgeTrack[0]
for (const badge of badgeTrack) {
if (xp.value >= badge.xp) {
unlocked = badge
}
}
return unlocked
})
const nextBadge = computed(() => badgeTrack.find(badge => badge.xp > xp.value) || null)
const nextBadgeProgress = computed(() => {
const upcoming = nextBadge.value
if (!upcoming) return 100
const previous = badgeTrack.slice().reverse().find(badge => badge.xp <= xp.value) || badgeTrack[0]
const span = Math.max(1, upcoming.xp - previous.xp)
const gained = xp.value - previous.xp
return Math.min(100, Math.round((gained / span) * 100))
})
const nextBadgeXpRemaining = computed(() => {
const upcoming = nextBadge.value
if (!upcoming) return 0
return Math.max(0, upcoming.xp - xp.value)
})
const totalLessons = computed(() => modules.value.reduce((sum, module) => sum + module.lessons.length, 0))
const finishedLessons = computed(() => modules.value.reduce((sum, module) => sum + doneCount(module.id), 0))
const missionCompletionPct = computed(() => (totalLessons.value ? Math.round((finishedLessons.value / totalLessons.value) * 100) : 0))
const globalAccuracy = computed(() => {
const scores: number[] = []
Object.values(progress.value || {}).forEach(module => {
Object.values(module || {}).forEach(entry => {
if (entry && typeof entry.best === 'number' && entry.best > 0) {
scores.push(entry.best)
}
})
})
if (!scores.length) return 0
const avg = scores.reduce((acc, value) => acc + value, 0) / scores.length
return Math.round(avg)
})
const audioContentHidden = computed(() => cfg.value.audioChallenge && !audioReveal.value)
const audioSpeedDisplay = computed(() => (cfg.value.audioSpeed ?? 1).toFixed(2))
const requiresFlightPlan = computed(() => Boolean(current.value?.meta?.flightPlan))
const currentPlan = computed(() => (current.value ? missionPlans[current.value.id] ?? null : null))
const currentPlanRoute = computed(() => scenarioRoute(currentPlan.value?.scenario || null))
const currentPlanTagline = computed(() => scenarioTagline(currentPlan.value?.scenario || null))
const draftPlanRoute = computed(() => scenarioRoute(draftPlanScenario.value))
const draftPlanTagline = computed(() => scenarioTagline(draftPlanScenario.value))
const briefingSnapshot = computed(() => buildBriefingSnapshot(draftPlanScenario.value))
const currentBriefingArt = computed(() => current.value?.meta?.briefingArt || '/img/learn/missions/full-flight/briefing-hero.png')
type LearnStateResponse = LearnState
let persistTimer: ReturnType<typeof setTimeout> | null = null
const dirtyState = reactive({xp: false, progress: false, config: false})
const savingState = ref(false)
const pendingSave = ref(false)
async function loadLearnState() {
if (!isClient) return
try {
const response = await api.get<LearnStateResponse>('/api/learn/state')
if (response) {
xp.value = Number.isFinite(response.xp) ? Math.max(0, Math.round(response.xp)) : 0
progress.value = (response.progress ?? {}) as LearnProgress
cfg.value = {...defaultCfg, ...(response.config || {})}
} else {
xp.value = 0
progress.value = {} as LearnProgress
cfg.value = {...defaultCfg}
}
dirtyState.xp = false
dirtyState.progress = false
dirtyState.config = false
} catch (err) {
console.error('Failed to load learn state', err)
xp.value = 0
progress.value = {} as LearnProgress
cfg.value = {...defaultCfg}
} finally {
audioReveal.value = !cfg.value.audioChallenge
}
}
function schedulePersist(immediate = false) {
if (!isClient) return
if (persistTimer) {
clearTimeout(persistTimer)
persistTimer = null
}
if (immediate) {
void persistLearnState(true)
return
}
persistTimer = setTimeout(() => {
persistTimer = null
void persistLearnState()
}, 800)
}
async function persistLearnState(force = false) {
if (!isClient) return
if (!force && !dirtyState.xp && !dirtyState.progress && !dirtyState.config) {
return
}
if (savingState.value) {
pendingSave.value = true
return
}
savingState.value = true
pendingSave.value = false
const payload = {
xp: Math.max(0, Math.round(xp.value)),
progress: JSON.parse(JSON.stringify(progress.value)),
config: {...cfg.value},
}
try {
await api.put('/api/learn/state', payload)
dirtyState.xp = false
dirtyState.progress = false
dirtyState.config = false
} catch (err) {
console.error('Failed to persist learn state', err)
} finally {
savingState.value = false
if (pendingSave.value) {
pendingSave.value = false
void persistLearnState()
}
}
}
if (isClient) {
await loadLearnState()
}
const markConfigDirty = () => {
dirtyState.config = true
schedulePersist()
}
if (isClient) {
watch(progress, () => {
dirtyState.progress = true
schedulePersist()
}, {deep: true})
watch(xp, () => {
dirtyState.xp = true
schedulePersist()
})
watch(() => cfg.value.tts, markConfigDirty)
watch(() => cfg.value.audioChallenge, () => {
markConfigDirty()
resetAudioReveal()
})
watch(() => cfg.value.radioLevel, (level, previous) => {
markConfigDirty()
if (level !== previous) {
void playRadioNoise(level)
}
})
watch(() => cfg.value.audioSpeed, markConfigDirty)
watch(() => cfg.value.voice, markConfigDirty)
}
onBeforeUnmount(() => {
if (hoverCapabilityCleanup) {
hoverCapabilityCleanup()
hoverCapabilityCleanup = null
}
if (persistTimer) {
clearTimeout(persistTimer)
persistTimer = null
}
if (dirtyState.xp || dirtyState.progress || dirtyState.config) {
void persistLearnState(true)
}
stopAudio()
stopRadioNoise()
if (radioNoiseContext) {
const ctx = radioNoiseContext
radioNoiseContext = null
void ctx.close().catch(() => {
})
}
if (speechContext) {
const ctx = speechContext
speechContext = null
void ctx.close().catch(() => {
})
}
})
const fieldMap = computed<Record<string, LessonField>>(() => {
const map: Record<string, LessonField> = {}
if (activeLesson.value) {
for (const field of activeLesson.value.fields) {
map[field.key] = field
}
}
return map
})
const fieldStates = computed<Record<string, FieldState>>(() => {
const map: Record<string, FieldState> = {}
if (!activeLesson.value || !scenario.value) return map
for (const field of activeLesson.value.fields) {
const expected = field.expected(scenario.value).trim()
const answer = (userAnswers[field.key] ?? '').trim()
const alternatives = field.alternatives?.(scenario.value) ?? []
const options = [expected, ...alternatives].map(norm).filter(Boolean)
const normalizedAnswer = norm(answer)
const best = options.length ? Math.max(...options.map(option => similarity(normalizedAnswer, option))) : 0
const pass = answer ? best >= (field.threshold ?? 0.82) : false
map[field.key] = {
key: field.key,
label: field.label,
expected,
answer,
similarity: answer ? best : 0,
pass
}
}
return map
})
function resetAudioReveal() {
audioReveal.value = !cfg.value.audioChallenge
}
function revealAudioContent() {
audioReveal.value = true
}
function blankSizeClass(key: string, override?: BlankWidth): string {
const field = fieldMap.value[key]
const size = (override || field?.width || 'md') as BlankWidth
return `size-${size}`
}
function blankStateClass(key: string): string {
const state = fieldStates.value[key]
if (!state) return ''
if (state.pass) return 'ok'
if (state.answer) return 'warn'
return ''
}
function fieldLabel(key: string): string {
return fieldMap.value[key]?.label ?? 'Field'
}
function fieldPlaceholder(key: string): string {
return fieldMap.value[key]?.placeholder ?? '…'
}
function fieldInputmode(key: string): 'text' | 'numeric' {
return fieldMap.value[key]?.inputmode ?? 'text'
}
function fieldPass(key: string): boolean {
return Boolean(fieldStates.value[key]?.pass)
}
function fieldHasAnswer(key: string): boolean {
const state = fieldStates.value[key]
return Boolean(state && state.answer)
}
function fieldExpectedValue(key: string): string {
return fieldStates.value[key]?.expected ?? ''
}
const targetPhrase = computed(() => (activeLesson.value && scenario.value ? activeLesson.value.phrase(scenario.value) : ''))
const lessonInfo = computed(() => (activeLesson.value && scenario.value ? activeLesson.value.info(scenario.value) : []))
const sayButtonLabel = computed(() => (hasSpokenTarget.value ? 'Say again' : 'Say'))
const nextLessonMeta = computed(() => {
if (!current.value || !activeLesson.value) return null
const lessons = current.value.lessons
const activeId = activeLesson.value.id
const idx = lessons.findIndex(lesson => lesson.id === activeId)
if (idx === -1 || idx >= lessons.length - 1) return null
const nextLesson = lessons[idx + 1]
return {
lesson: nextLesson,
position: idx + 2,
total: lessons.length
}
})
const prevLessonMeta = computed(() => {
if (!current.value || !activeLesson.value) return null
const lessons = current.value.lessons
const activeId = activeLesson.value.id
const idx = lessons.findIndex(lesson => lesson.id === activeId)
if (idx <= 0) return null
const previousLesson = lessons[idx - 1]
return {
lesson: previousLesson,
position: idx,
total: lessons.length
}
})
const nextMissionMeta = computed(() => {
if (!current.value) return null
const currentIndex = modules.value.findIndex(module => module.id === current.value?.id)
if (currentIndex === -1) return null
const total = modules.value.length
for (let idx = currentIndex + 1; idx < modules.value.length; idx++) {
const module = modules.value[idx]
if (isModuleUnlocked(module.id)) {
return {
module,
position: idx + 1,
total
}
}
}
return null
})
const prevMissionMeta = computed(() => {
if (!current.value) return null
const currentIndex = modules.value.findIndex(module => module.id === current.value?.id)
if (currentIndex <= 0) return null
for (let idx = currentIndex - 1; idx >= 0; idx--) {
const module = modules.value[idx]
if (isModuleUnlocked(module.id)) {
return {
module,
position: idx + 1,
total: modules.value.length
}
}
}
return null
})
const nextActionLabel = computed(() => {
if (nextLessonMeta.value) return 'Next lesson'
if (nextMissionMeta.value) return 'Next mission'
return 'Next lesson'
})
const previousActionLabel = computed(() => {
if (prevLessonMeta.value) return 'Previous lesson'
if (prevMissionMeta.value) return 'Previous module'
return 'Previous lesson'
})
const hasPreviousAction = computed(() => Boolean(prevLessonMeta.value || prevMissionMeta.value))
watch(activeLesson, lesson => {
if (lesson) {
if (moduleStage.value === 'lessons') {
rollScenario(true)
}
if (lessonTrack.value) {
void nextTick(() => {
const el = lessonTrack.value?.querySelector<HTMLElement>(`[data-lesson='${lesson.id}']`)
el?.scrollIntoView({behavior: 'smooth', block: 'nearest', inline: 'center'})
})
}
} else {
scenario.value = null
}
})
watch(scenario, newScenario => {
hasSpokenTarget.value = false
if (!newScenario) {
pendingAutoSay.value = false
}
})
function queueAutoSay() {
pendingAutoSay.value = true
void nextTick(() => {
attemptAutoSay()
})
}
function attemptAutoSay() {
if (!pendingAutoSay.value) return
const phrase = targetPhrase.value?.trim()
if (!phrase) {
pendingAutoSay.value = false
return
}
if (ttsLoading.value) return
pendingAutoSay.value = false
void speakTarget(true)
}
async function speakTarget(auto = false) {
const phrase = targetPhrase.value?.trim()
if (!phrase) return
if (ttsLoading.value) return
if (!auto) {
pendingAutoSay.value = false
}
hasSpokenTarget.value = true
await say(phrase)
}
watch(ttsLoading, loading => {
if (!loading) {
attemptAutoSay()
}
})
function rollScenario(clear = false) {
if (!activeLesson.value) return
pendingAutoSay.value = false
stopAudio()
const generated = activeLesson.value.generate()
scenario.value = generated
const defaultType = activeLesson.value.defaultFrequency
activeFrequency.value = generated.frequencies.find(freq => freq.type === (defaultType || 'DEL')) || generated.frequencies[0] || null
resetAnswers(true)
resetAudioReveal()
if (clear) {
result.value = null
}
queueAutoSay()
}
function toggleModuleOverview() {
if (!isHoverCapable.value) return
moduleOverviewExpanded.value = !moduleOverviewExpanded.value
if (moduleOverviewExpanded.value) {
moduleOverviewHovering.value = false
void nextTick(() => {
const activeId = activeLesson.value?.id
const selector = activeId ? `[data-lesson='${activeId}']` : 'button.lesson'
const target = lessonTrack.value?.querySelector<HTMLElement>(selector)
target?.focus()
})
} else {
moduleOverviewHovering.value = false
moduleOverviewFocusWithin.value = false
}
}
function handleModuleOverviewHover(state: boolean) {
if (!isHoverCapable.value) return
moduleOverviewHovering.value = state
}
function handleModuleOverviewFocusIn(event: FocusEvent) {
if (!isHoverCapable.value) return
const target = event.target as HTMLElement | null
if (target?.classList?.contains('module-overview-toggle') && !moduleOverviewExpanded.value) {
moduleOverviewFocusWithin.value = false
return
}
moduleOverviewFocusWithin.value = true
}
function handleModuleOverviewFocusOut(event: FocusEvent) {
if (!isHoverCapable.value) return
const currentTarget = event.currentTarget as HTMLElement | null
const related = event.relatedTarget as Node | null
if (currentTarget && related && currentTarget.contains(related)) return
moduleOverviewFocusWithin.value = false
}
function repeatLesson() {
rollScenario(true)
}
function goToPreviousLesson() {
const meta = prevLessonMeta.value
if (meta) {
activeLesson.value = meta.lesson
return
}
const previousMission = prevMissionMeta.value
if (previousMission) {
quickContinue(previousMission.module.id)
}
}
function goToNextLesson() {
const meta = nextLessonMeta.value
if (meta) {
activeLesson.value = meta.lesson
return
}
const nextMission = nextMissionMeta.value
if (nextMission) {
quickContinue(nextMission.module.id)
}
}
function setActiveFrequency(freq: Frequency) {
activeFrequency.value = freq
}
function resetAnswers(clearResult = false) {
if (!activeLesson.value) return
const keys = activeLesson.value.fields.map(field => field.key)
Object.keys(userAnswers).forEach(key => {
if (!keys.includes(key)) {
delete userAnswers[key]
}
})
for (const key of keys) {
userAnswers[key] = ''
}
if (clearResult) {
result.value = null
}
}
function clearAnswers() {
resetAnswers(true)
}
function fillSolution() {
if (!activeLesson.value || !scenario.value) return
for (const field of activeLesson.value.fields) {
userAnswers[field.key] = field.expected(scenario.value)
}
}
function computeScore(): ScoreResult | null {
if (!activeLesson.value) return null
const details = activeLesson.value.fields
.map(field => fieldStates.value[field.key])
.filter(Boolean) as FieldState[]
if (!details.length) return null
const total = details.reduce((sum, item) => sum + item.similarity, 0)
const hits = details.filter(item => item.pass).length
const avg = total / details.length
return {
score: Math.round(avg * 100),
hits,
sim: avg,
passed: hits === details.length && hits > 0,
fields: details
}
}
function evaluate() {
if (!activeLesson.value || !current.value) return
evaluating.value = true
try {
const summary = computeScore()
if (!summary) return
result.value = summary
const modId = current.value.id
const lesId = activeLesson.value.id
if (!progress.value[modId]) progress.value[modId] = {}
const previous = progress.value[modId][lesId] || {best: 0, done: false}
const best = Math.max(previous.best || 0, summary.score)
const passed = summary.passed || summary.score >= 80
const wasDone = previous.done
progress.value[modId][lesId] = {best, done: passed}
let gained = 0
if (passed && !wasDone) gained += 40
if (summary.score >= 95 && summary.score > (previous.best || 0)) gained += 15
if (summary.score >= 80 && summary.score > (previous.best || 0)) gained += 10
if (gained) {
xp.value += gained
toastNow(`+${gained} XP · ${activeLesson.value.title}`)
}
} finally {
evaluating.value = false
}
}
function isModuleUnlocked(id: string) {
if (id === 'normalize') return true
const order = modules.value.findIndex(module => module.id === id)
const previous = modules.value[order - 1]
return previous ? pct(previous.id) >= 80 : true
}
function openModule(id: string, options: { autoStart?: boolean } = {}) {
const module = modules.value.find(item => item.id === id) || null
current.value = module
manualErrors.value = []
flightPlanError.value = null
panel.value = module ? 'module' : 'hub'
pendingAutoStart.value = options.autoStart ?? false
activeLesson.value = null
if (!module) {
activeLesson.value = null
return
}
if (!module.meta?.flightPlan) {
moduleStage.value = 'lessons'
seedFullFlightScenario(null)
startLessonsForCurrent()
pendingAutoStart.value = false
return
}
const stored = missionPlans[module.id]
if (stored?.scenario) {
seedFullFlightScenario(cloneScenarioData(stored.scenario))
moduleStage.value = 'lessons'
if (pendingAutoStart.value || !activeLesson.value) {
startLessonsForCurrent()
}
pendingAutoStart.value = false
return
}
moduleStage.value = 'setup'
activeLesson.value = null
scenario.value = null
flightPlanMode.value = stored?.mode ?? 'random'
initSetupState(null)
}
function quickContinue(id: string) {
openModule(id, {autoStart: true})
}
function resumeMissionObjective() {
const objective = missionObjective.value
if (objective?.moduleId) {
quickContinue(objective.moduleId)
return
}
panel.value = 'hub'
}
function goToPrimaryObjective() {
const objective = primaryObjective.value
if (!objective) return
if (objective.moduleId) {
quickContinue(objective.moduleId)
return
}
panel.value = 'hub'
}
function selectLesson(lesson: Lesson) {
activeLesson.value = lesson
}
function bestScore(modId: string, lesId: string) {
return progress.value[modId]?.[lesId]?.best || 0
}
function moduleHasProgress(modId: string) {
const moduleProgress = progress.value[modId]
if (!moduleProgress) return false
return Object.values(moduleProgress).some(entry => {
if (!entry) return false
return Boolean(entry.done) || (typeof entry.best === 'number' && entry.best > 0)
})
}
function isFreshModule(modId: string) {
return isModuleUnlocked(modId) && !moduleHasProgress(modId) && !moduleCompleted(modId)
}
function tileClass(modId: string) {
return {
'is-locked': !isModuleUnlocked(modId),
'is-complete': moduleCompleted(modId),
'is-active squash': moduleHasProgress(modId) && !moduleCompleted(modId),
'is-fresh squash': isFreshModule(modId),
}
}
function moduleCompleted(modId: string) {
const module = modules.value.find(item => item.id === modId)
if (!module) return false
if (!module.lessons.length) return false
return doneCount(modId) >= module.lessons.length
}
function doneCount(modId: string) {
const module = modules.value.find(item => item.id === modId)
if (!module) return 0
const moduleProgress = progress.value[modId] || {}
return module.lessons.filter(lesson => moduleProgress[lesson.id]?.done).length
}
function pct(modId: string) {
const module = modules.value.find(item => item.id === modId)
if (!module) return 0
return Math.round((doneCount(modId) / module.lessons.length) * 100)
}
function avgScore(modId: string) {
const module = modules.value.find(item => item.id === modId)
if (!module) return 0
const moduleProgress = progress.value[modId] || {}
const values = module.lessons.map(lesson => moduleProgress[lesson.id]?.best || 0)
const sum = values.reduce((acc, value) => acc + value, 0)
return Math.round(sum / (values.length || 1))
}
function handleModulePrimary(modId: string) {
quickContinue(modId)
}
function modulePrimaryLabel(modId: string) {
if (moduleCompleted(modId)) return 'Replay mission'
if (!moduleHasProgress(modId)) return 'Launch mission'
return 'Resume mission'
}
function modulePrimaryIcon(modId: string) {
if (moduleCompleted(modId)) return 'mdi-history'
if (!moduleHasProgress(modId)) return 'mdi-rocket'
return 'mdi-clock-outline'
}
function moduleSecondaryLabel(modId: string) {
return moduleHasProgress(modId) ? 'Overview' : 'Preview'
}
function moduleSecondaryIcon(modId: string) {
return moduleHasProgress(modId) ? 'mdi-view-grid-outline' : 'mdi-information-outline'
}
function moduleStatusText(modId: string) {
if (!isModuleUnlocked(modId)) return 'Clearance pending'
if (moduleCompleted(modId)) return 'Mission complete'
if (moduleHasProgress(modId)) return 'In progress'
return 'Ready to launch'
}
function moduleStatusIcon(modId: string) {
if (!isModuleUnlocked(modId)) return 'mdi-lock-alert'
if (moduleCompleted(modId)) return 'mdi-medal-outline'
if (moduleHasProgress(modId)) return 'mdi-timer-sand'
return 'mdi-rocket-outline'
}
function tileStatusClass(modId: string) {
if (!isModuleUnlocked(modId)) return 'is-locked'
if (moduleCompleted(modId)) return 'is-complete'
if (moduleHasProgress(modId)) return 'is-active'
return 'is-fresh'
}
function lessonScoreLabel(modId: string, lesId: string) {
const score = bestScore(modId, lesId)
return score ? `${score}%` : 'New'
}
function lessonScoreIcon(modId: string, lesId: string) {
const score = bestScore(modId, lesId)
if (!score) return 'mdi-star-outline'
if (score >= 80) return 'mdi-star-circle-outline'
return 'mdi-check-circle-outline'
}
function lessonScoreClass(modId: string, lesId: string) {
const score = bestScore(modId, lesId)
if (!score) return 'is-new'
if (score >= 80) return 'is-great'
return 'is-progress'
}
const lessonsWithGreatScore = computed(() => {
let count = 0
Object.values(progress.value || {}).forEach(module => {
Object.values(module || {}).forEach(entry => {
if (entry && typeof entry.best === 'number' && entry.best >= 80) {
count += 1
}
})
})
return count
})
const modulesStartedCount = computed(() => {
let count = 0
modules.value.forEach(module => {
const moduleProgress = progress.value[module.id]
if (!moduleProgress) return
const active = Object.values(moduleProgress).some(entry => {
if (!entry) return false
return Boolean(entry.done) || (typeof entry.best === 'number' && entry.best > 0)
})
if (active) count += 1
})
return count
})
const dailyObjectives = computed<Objective[]>(() => {
const readbacks = lessonsWithGreatScore.value
const started = modulesStartedCount.value > 0
const audioPlayed = hasSpokenTarget.value
return [
{
id: 'daily-readbacks',
title: '3 readbacks ≥80%',
description: 'Score at least 80% in three different lessons.',
progress: readbacks,
goal: 3,
status: readbacks >= 3 ? 'Goal reached' : `${Math.max(0, 3 - Math.min(readbacks, 3))} more ≥80%`,
icon: 'mdi-target',
complete: readbacks >= 3
},
{
id: 'daily-module',
title: 'Start 1 module',
description: 'Open a mission set and clear the first lesson.',
progress: started ? 1 : 0,
goal: 1,
status: started ? 'Module started' : 'Pick a module to open',
icon: 'mdi-flag-variant',
complete: started
},
{
id: 'daily-audio',
title: 'Play the target phrase',
description: 'Listen to the controller audio before responding.',
progress: audioPlayed ? 1 : 0,
goal: 1,
status: audioPlayed ? 'Played once today' : 'Play the controller audio',
icon: 'mdi-volume-high',
complete: audioPlayed
}
]
})
const missionObjective = computed<Objective | null>(() => {
const nextModule = modules.value.find(module => !moduleCompleted(module.id))
if (!nextModule) return null
const total = nextModule.lessons.length || 1
const completed = Math.min(total, Math.max(0, doneCount(nextModule.id)))
const status = completed >= total ? 'Module complete' : `Lesson ${Math.min(completed + 1, total)} of ${total}`
return {
id: `module-${nextModule.id}`,
title: `Continue ${nextModule.title}`,
description: nextModule.subtitle,
progress: completed,
goal: total,
status,
icon: 'mdi-flag-checkered',
complete: completed >= total,
moduleId: nextModule.id
}
})
const badgeObjective = computed<Objective | null>(() => {
const upcoming = nextBadge.value
if (!upcoming) return null
const previous = badgeTrack.slice().reverse().find(badge => badge.xp <= xp.value) || badgeTrack[0]
const span = Math.max(1, upcoming.xp - previous.xp)
const gained = Math.max(0, xp.value - previous.xp)
return {
id: `badge-${upcoming.id}`,
title: `${upcoming.name} badge`,
description: upcoming.description,
progress: gained,
goal: span,
status: `${nextBadgeXpRemaining.value} XP to unlock`,
icon: 'mdi-shield-star',
complete: false
}
})
const objectiveList = computed(() => {
const list: Objective[] = []
if (missionObjective.value) list.push(missionObjective.value)
list.push(...dailyObjectives.value)
if (badgeObjective.value) list.push(badgeObjective.value)
return list
})
const primaryObjective = computed(() => objectiveList.value.find(objective => !objective.complete) || objectiveList.value[0] || null)
const primaryObjectiveProgress = computed(() => (primaryObjective.value ? objectiveProgressPct(primaryObjective.value) : 0))
function objectiveProgressPct(objective: Objective): number {
if (!objective.goal || objective.goal <= 0) {
return objective.complete ? 100 : 0
}
const clamped = Math.min(objective.goal, Math.max(0, objective.progress))
return Math.min(100, Math.round((clamped / objective.goal) * 100))
}
function objectiveBadgeLabel(objective: Objective): string {
if (objective.complete) return 'Completed'
if (!objective.goal || objective.goal <= 0) return 'Progress'
const clamped = Math.min(objective.goal, Math.max(0, Math.round(objective.progress)))
return `${clamped}/${objective.goal}`
}
function toastNow(text: string) {
toast.value.text = text
toast.value.show = true
}
function resetAll() {
if (!isClient) return
progress.value = {} as LearnProgress
xp.value = 0
cfg.value = {...defaultCfg}
audioReveal.value = !cfg.value.audioChallenge
dirtyState.progress = true
dirtyState.xp = true
dirtyState.config = true
schedulePersist(true)
}
const worldTiltStyle = ref<any>({})
function tilt(event: MouseEvent) {
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect()
const dx = (event.clientX - (rect.left + rect.width / 2)) / rect.width
const dy = (event.clientY - (rect.top + rect.height / 2)) / rect.height
worldTiltStyle.value = {transform: `perspective(1200px) rotateX(${dy * -3}deg) rotateY(${dx * 3}deg)`}
}
async function ensureSpeechAudioContext(): Promise<AudioContext | null> {
if (!isClient) return null
const audioWindow = window as typeof window & { webkitAudioContext?: typeof AudioContext }
const AudioContextCtor = audioWindow.AudioContext || audioWindow.webkitAudioContext
if (!AudioContextCtor) return null
if (!speechContext || speechContext.state === 'closed') {
speechContext = new AudioContextCtor()
}
if (speechContext.state === 'suspended') {
try {
await speechContext.resume()
} catch (err) {
console.warn('Failed to resume speech audio context', err)
}
}
return speechContext
}
async function ensurePizzicato(ctx: AudioContext | null): Promise<PizzicatoLite | null> {
if (!ctx) return null
if (!pizzicatoLiteInstance) {
pizzicatoLiteInstance = await loadPizzicatoLite()
}
return pizzicatoLiteInstance
}
function buildSayCacheKey(text: string, rate: number): string {
const voice = cfg.value.voice?.trim().toLowerCase() || 'default'
const radioLevel = cfg.value.radioLevel
return `${voice}|${radioLevel}|${rate.toFixed(2)}|${text}`
}
async function requestSayAudio(cacheKey: string, payload: Record<string, unknown>): Promise<CachedAudio> {
const pending = pendingSayRequests.get(cacheKey)
if (pending) {
return pending
}
const request = (async () => {
const response: any = await api.post('/api/atc/say', payload)
const audioData = response?.audio
if (!audioData?.base64) {
throw new Error('Missing audio data')
}
const model = typeof response?.meta?.model === 'string' ? response.meta.model : null
const speed = typeof response?.speed === 'number' ? response.speed : undefined
return {
base64: audioData.base64 as string,
mime: audioData.mime || 'audio/wav',
model,
speed
}
})()
pendingSayRequests.set(cacheKey, request)
try {
const audioPayload = await request
sayCache.set(cacheKey, audioPayload)
return audioPayload
} finally {
pendingSayRequests.delete(cacheKey)
}
}
const clampRate = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value))
const providerSupportsNativeSpeed = (model?: string | null) => {
if (!model) return false
const normalized = model.toLowerCase()
if (normalized.includes('piper') || normalized.includes('speaches')) {
return false
}
return normalized.includes('tts')
}
async function playAudioSource(source: CachedAudio, targetRate: number) {
if (!source?.base64) return
audioElement.value = null
const readability = Math.max(1, Math.min(5, cfg.value.radioLevel || 3))
const mime = source.mime || 'audio/wav'
const dataUrl = `data:${mime};base64,${source.base64}`
const desiredRate = clampRate(Number.isFinite(targetRate) ? targetRate : 1, 0.5, 2)
const supportsNativeSpeed = providerSupportsNativeSpeed(source.model)
const nativeRate = supportsNativeSpeed
? clampRate(typeof source.speed === 'number' && Number.isFinite(source.speed) ? source.speed : desiredRate, 0.5, 2)
: 1
const playbackRate = supportsNativeSpeed
? clampRate(desiredRate / (nativeRate || 1), 0.5, 2)
: desiredRate
const playWithoutEffects = async () => {
const audio = new Audio(dataUrl)
audio.playbackRate = playbackRate
audioElement.value = audio
audio.onended = () => {
if (audioElement.value === audio) {
audioElement.value = null
}
}
audio.onerror = () => {
if (audioElement.value === audio) {
audioElement.value = null
}
}
try {
await audio.play()
} catch (err) {
console.error('Audio playback failed', err)
}
}
try {
const ctx = await ensureSpeechAudioContext()
const pizzicato = await ensurePizzicato(ctx)
if (!ctx || !pizzicato) {
throw new Error('Audio engine unavailable')
}
const sound = await pizzicato.createSoundFromBase64(ctx, source.base64)
sound.setPlaybackRate(playbackRate)
const profile = getReadabilityProfile(readability)
const {Effects} = pizzicato
const highpass = new Effects.HighPassFilter(ctx, {
frequency: profile.eq.highpass,
q: profile.eq.highpassQ
})
const lowpass = new Effects.LowPassFilter(ctx, {
frequency: profile.eq.lowpass,
q: profile.eq.lowpassQ
})
sound.addEffect(highpass)
sound.addEffect(lowpass)
if (profile.eq.bandpass) {
sound.addEffect(
new Effects.BandPassFilter(ctx, {
frequency: profile.eq.bandpass.frequency,
q: profile.eq.bandpass.q
})
)
}
if (profile.presence) {
sound.addEffect(new Effects.PeakingFilter(ctx, profile.presence))
}
profile.distortions.forEach(amount => {
sound.addEffect(new Effects.Distortion(ctx, {amount}))
})
sound.addEffect(new Effects.Compressor(ctx, profile.compressor))
if (profile.tremolos) {
profile.tremolos.forEach(tremolo => {
sound.addEffect(new Effects.Tremolo(ctx, tremolo))
})
}
sound.setVolume(profile.gain)
const playbackDuration = Math.max(0.1, sound.duration / Math.max(playbackRate, 0.01))
const noiseStops = createNoiseGenerators(ctx, playbackDuration, profile, readability)
activeRadioSound = sound
activeRadioCleanup = noiseStops
try {
await sound.play()
} finally {
if (activeRadioSound === sound) {
activeRadioSound = null
}
if (activeRadioCleanup === noiseStops) {
activeRadioCleanup = []
}
noiseStops.forEach(stop => {
try {
stop()
} catch {
// ignore
}
})
sound.clearEffects()
}
} catch (err) {
console.error('Failed to apply radio effect', err)
await playWithoutEffects()
}
}
function stopRadioNoise() {
if (!radioNoiseSource) return
try {
radioNoiseSource.stop()
} catch (err) {
// ignore stop errors
}
try {
radioNoiseSource.disconnect()
} catch (err) {
// ignore disconnect errors
}
radioNoiseSource = null
}
async function playRadioNoise(level: number) {
if (!isClient) return
const audioWindow = window as typeof window & { webkitAudioContext?: typeof AudioContext }
const AudioContextCtor = audioWindow.AudioContext || audioWindow.webkitAudioContext
if (!AudioContextCtor) return
if (!radioNoiseContext || radioNoiseContext.state === 'closed') {
radioNoiseContext = new AudioContextCtor()
}
const ctx = radioNoiseContext
if (!ctx) return
try {
if (ctx.state === 'suspended') {
await ctx.resume()
}
} catch (err) {
console.warn('Failed to resume radio noise context', err)
}
stopRadioNoise()
const duration = 0.4
const length = Math.max(1, Math.floor(ctx.sampleRate * duration))
const buffer = ctx.createBuffer(1, length, ctx.sampleRate)
const data = buffer.getChannelData(0)
const strength = Math.max(1, Math.min(5, level))
const intensity = (6 - strength) / 5
const amplitude = 0.025 + intensity * 0.15
for (let i = 0; i < length; i++) {
data[i] = (Math.random() * 2 - 1) * amplitude
}
const source = ctx.createBufferSource()
source.buffer = buffer
const highpass = ctx.createBiquadFilter()
highpass.type = 'highpass'
highpass.frequency.value = 320
const bandpass = ctx.createBiquadFilter()
bandpass.type = 'bandpass'
bandpass.frequency.value = 1800
bandpass.Q.value = 1.2
const lowpass = ctx.createBiquadFilter()
lowpass.type = 'lowpass'
lowpass.frequency.value = 3200
const gain = ctx.createGain()
const now = ctx.currentTime
const endTime = now + duration
gain.gain.setValueAtTime(amplitude, now)
gain.gain.exponentialRampToValueAtTime(0.0001, endTime)
source.connect(highpass)
highpass.connect(bandpass)
bandpass.connect(lowpass)
lowpass.connect(gain)
gain.connect(ctx.destination)
source.start(now)
source.stop(endTime)
source.onended = () => {
if (radioNoiseSource === source) {
radioNoiseSource = null
}
}
radioNoiseSource = source
}
function computeSpeechRate(): number {
const base =
typeof cfg.value.audioSpeed === 'number' && Number.isFinite(cfg.value.audioSpeed)
? cfg.value.audioSpeed
: 1
const adjustment = (cfg.value.radioLevel - 3) * 0.05
const rate = base + adjustment
return Math.min(1.4, Math.max(0.7, Math.round(rate * 100) / 100))
}
async function say(text: string) {
const trimmed = text?.trim()
if (!trimmed) return
const normalizedRate = computeSpeechRate()
const hasBrowserTts = cfg.value.tts && typeof window !== 'undefined' && 'speechSynthesis' in window
stopAudio()
if (hasBrowserTts) {
const synth = window.speechSynthesis
const utterance = new SpeechSynthesisUtterance(trimmed)
utterance.rate = normalizedRate
if (cfg.value.voice) {
const voiceName = cfg.value.voice.toLowerCase()
const voice = synth.getVoices().find(item => item.name.toLowerCase().includes(voiceName))
if (voice) utterance.voice = voice
}
utterance.onend = () => {
isSpeaking.value = false
}
utterance.onerror = () => {
isSpeaking.value = false
}
synth.cancel()
isSpeaking.value = true
synth.speak(utterance)
return
}
if (ttsLoading.value) return
const payload: Record<string, unknown> = {
text: trimmed,
level: cfg.value.radioLevel,
speed: normalizedRate,
moduleId: current.value?.id || 'learn',
lessonId: activeLesson.value?.id || null,
tag: 'learn-target'
}
if (cfg.value.voice) {
payload.voice = cfg.value.voice
}
const cacheKey = buildSayCacheKey(trimmed, normalizedRate)
isSpeaking.value = true
ttsLoading.value = true
try {
let audioData = sayCache.get(cacheKey)
if (!audioData) {
audioData = await requestSayAudio(cacheKey, payload)
}
await playAudioSource(audioData, normalizedRate)
} catch (err) {
console.error('TTS request failed', err)
} finally {
ttsLoading.value = false
isSpeaking.value = false
}
}
function stopAudio() {
isSpeaking.value = false
ttsLoading.value = false
if (typeof window !== 'undefined' && 'speechSynthesis' in window) {
window.speechSynthesis.cancel()
}
if (activeRadioSound) {
const sound = activeRadioSound
activeRadioSound = null
try {
sound.stop()
} catch {
// ignore stop errors
}
}
if (activeRadioCleanup.length) {
const stops = activeRadioCleanup
activeRadioCleanup = []
stops.forEach(stop => {
try {
stop()
} catch {
// ignore cleanup errors
}
})
}
if (audioElement.value) {
try {
audioElement.value.pause()
audioElement.value.currentTime = 0
} catch (err) {
// ignore pause errors
}
audioElement.value = null
}
}
onMounted(() => {
if (typeof window !== 'undefined' && 'speechSynthesis' in window) {
window.speechSynthesis.getVoices()
}
if (typeof window !== 'undefined') {
const storedId = window.localStorage.getItem(SIMBRIEF_STORAGE_KEY)
if (storedId) {
simbriefForm.userId = storedId
}
}
})
</script>
<style scoped>
:root {
--bg: #0b1020;
--bg2: #0a0f1c;
--accent: #22d3ee;
--accent2: #0ea5e9;
--text: #ffffff;
--t2: rgba(255, 255, 255, .80);
--t3: rgba(255, 255, 255, .60);
--border: rgba(255, 255, 255, .10);
}
.scene {
min-height: 100vh;
background: var(--bg);
color: var(--text)
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px
}
.h1 {
font-size: clamp(32px, 5vw, 48px);
font-weight: 600;
line-height: 1.2
}
.h2 {
font-size: clamp(24px, 3.5vw, 32px);
font-weight: 600;
line-height: 1.25
}
.h3 {
font-size: 20px;
font-weight: 600
}
.muted {
color: var(--t3)
}
.small {
font-size: 13px
}
.strong {
font-weight: 600
}
.text-accent {
color: var(--accent)
}
/* App Bar */
.hud {
position: sticky;
top: 0;
z-index: 40;
border-bottom: 1px solid var(--border);
background: color-mix(in srgb, var(--bg) 85%, transparent)
}
.hud-inner {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px
}
.hud-left {
display: flex;
align-items: center;
gap: 10px
}
.hud-logo {
display: inline-flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
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);
transition: border-color .2s ease, background .2s ease, color .2s ease;
}
.hud-logo:hover {
border-color: color-mix(in srgb, var(--accent) 54%, transparent);
background: color-mix(in srgb, var(--accent) 20%, transparent);
}
.hud-logo:focus-visible {
outline: 2px solid color-mix(in srgb, var(--accent) 60%, transparent);
outline-offset: 2px;
}
.hud-logo-icon {
color: currentColor;
}
.hud-divider {
width: 1px;
height: 44px;
background: var(--border);
border-radius: 999px;
}
.brand {
font-weight: 600
}
.mode {
color: var(--t2)
}
.sep {
color: var(--t3)
}
.hud-right {
display: flex;
gap: 8px;
align-items: center
}
.next-objective {
display: inline-flex;
flex-direction: column;
align-items: flex-end;
gap: 2px;
padding: 5px 12px;
border-radius: 12px;
border: 1px solid var(--border);
background: color-mix(in srgb, var(--text) 6%, transparent);
min-width: 180px;
max-width: 240px;
text-align: right;
color: inherit;
font: inherit;
appearance: none;
cursor: pointer;
transition: background .2s ease, border-color .2s ease, transform .2s ease;
}
.next-objective:hover,
.next-objective:focus-visible {
background: color-mix(in srgb, var(--text) 10%, transparent);
border-color: color-mix(in srgb, var(--text) 18%, transparent);
outline: none;
}
.next-header {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 6px;
width: 100%;
}
.next-label {
font-size: 10px;
letter-spacing: .18em;
text-transform: uppercase;
color: var(--t3);
}
.next-value {
font-weight: 600;
color: var(--t2);
font-size: 14px;
line-height: 1.1;
}
.next-status {
font-size: 10px;
color: var(--t3);
letter-spacing: .06em;
text-transform: uppercase;
}
/* Chips/Controls */
.chip {
display: inline-flex;
align-items: center;
padding: 6px 10px;
border: 1px solid var(--border);
background: color-mix(in srgb, var(--text) 6%, transparent);
font-size: 12px;
white-space: nowrap
}
.chip.inline {
gap: 8px
}
.chip.inline input[type="range"] {
width: 120px;
accent-color: var(--accent);
background: transparent
}
/* Hero */
.hero {
background: radial-gradient(800px 400px at 80% -10%, color-mix(in srgb, var(--accent) 18%, transparent), transparent 60%),
radial-gradient(600px 300px at 10% -5%, color-mix(in srgb, var(--accent2) 15%, transparent), transparent 60%),
linear-gradient(180deg, var(--bg) 0%, var(--bg) 60%, var(--bg2) 100%);
margin-top: -3em;
padding: clamp(14px, 4vw, 24px) 0 clamp(6px, 2.5vw, 16px);
padding-top: clamp(2em, 6vw, 3.25em);
}
.hero-panel {
display: grid;
grid-template-columns: minmax(0, 1.1fr) minmax(0, .9fr);
gap: clamp(14px, 2.5vw, 20px);
border: 1px solid var(--border);
background: color-mix(in srgb, var(--text) 6%, transparent);
padding: clamp(14px, 2.8vw, 20px);
transform-style: preserve-3d;
border-radius: 24px;
box-shadow: 0 30px 80px rgba(0, 0, 0, .35)
}
.hero-sub {
max-width: 520px;
margin-top: 4px
}
.hero-highlight {
margin-top: clamp(10px, 2vw, 16px);
border: 1px solid var(--border);
background: linear-gradient(135deg, color-mix(in srgb, var(--accent) 18%, transparent), color-mix(in srgb, var(--accent2) 10%, transparent));
padding: clamp(12px, 2.5vw, 16px);
border-radius: 20px;
display: grid;
grid-template-columns: auto 1fr;
gap: clamp(12px, 2vw, 16px);
position: relative;
overflow: hidden
}
.hero-highlight::after {
content: '';
position: absolute;
inset: 0;
background: radial-gradient(160% 160% at 0% 100%, color-mix(in srgb, var(--accent) 18%, transparent), transparent 60%);
opacity: .7;
pointer-events: none
}
.hero-orb {
--progress: 0%;
width: 96px;
height: 96px;
border-radius: 50%;
background: conic-gradient(var(--accent) var(--progress), color-mix(in srgb, var(--text) 10%, transparent) var(--progress));
position: relative;
display: grid;
place-items: center;
animation: orbFloat 9s ease-in-out infinite
}
.hero-orb::after {
content: '';
position: absolute;
inset: 8px;
border-radius: 50%;
background: color-mix(in srgb, var(--bg) 82%, transparent)
}
.hero-orb-core {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
gap: 4px;
align-items: center
}
.hero-orb-level {
font-weight: 700;
font-size: 16px;
text-transform: uppercase
}
.hero-orb-progress {
font-size: 11px;
letter-spacing: .18em;
color: var(--t3)
}
.hero-highlight-meta {
display: flex;
flex-direction: column;
gap: 8px;
justify-content: center
}
.hero-tag {
font-size: 11px;
letter-spacing: .18em;
text-transform: uppercase;
color: var(--t3)
}
.hero-highlight-title {
font-size: 22px;
font-weight: 600;
color: var(--t2)
}
.hero-highlight-bar {
height: 8px;
border-radius: 999px;
border: 1px solid color-mix(in srgb, var(--text) 20%, transparent);
background: color-mix(in srgb, var(--text) 6%, transparent);
overflow: hidden
}
.hero-highlight-fill {
height: 100%;
background: linear-gradient(90deg, var(--accent), var(--accent2));
transition: width .4s ease
}
.objective-callout {
margin-top: 16px;
border: 1px solid var(--border);
border-radius: 18px;
padding: 16px;
display: flex;
align-items: center;
gap: 16px;
background: color-mix(in srgb, var(--text) 6%, transparent);
}
.objective-body {
display: flex;
flex-direction: column;
gap: 8px;
flex: 1;
}
.objective-title-row {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
color: var(--t2);
}
.objective-title {
font-size: 16px;
}
.objective-progress {
display: flex;
align-items: center;
gap: 10px;
}
.objective-progress-bar {
flex: 1;
height: 6px;
border-radius: 999px;
background: color-mix(in srgb, var(--text) 8%, transparent);
border: 1px solid var(--border);
overflow: hidden;
}
.objective-progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--accent), var(--accent2));
transition: width .3s ease;
}
.objective-progress-meta {
font-size: 12px;
color: var(--t3);
white-space: nowrap;
}
.hero-metrics {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
gap: 12px;
margin-top: clamp(12px, 2vw, 16px)
}
.metric-card {
border: 1px solid var(--border);
background: color-mix(in srgb, var(--text) 4%, transparent);
padding: 14px;
border-radius: 16px;
display: flex;
gap: 12px;
align-items: center;
box-shadow: 0 16px 32px rgba(0, 0, 0, .22);
animation: float 12s ease-in-out infinite
}
.metric-card:nth-child(2) {
animation-delay: 2s
}
.metric-card:nth-child(3) {
animation-delay: 4s
}
.metric-icon {
width: 40px;
height: 40px;
border-radius: 14px;
display: inline-flex;
align-items: center;
justify-content: center;
background: color-mix(in srgb, var(--accent) 18%, transparent);
color: var(--accent)
}
.metric-main {
display: flex;
flex-direction: column;
gap: 2px
}
.metric-value {
font-weight: 700;
font-size: 18px
}
.metric-label {
font-size: 12px;
color: var(--t3);
letter-spacing: .12em;
text-transform: uppercase
}
.actions {
display: flex;
gap: 10px;
margin-top: 16px
}
.hero-right {
display: flex;
flex-direction: column;
gap: clamp(12px, 2vw, 16px)
}
.hero-stats {
border: 1px solid var(--border);
border-radius: 20px;
padding: clamp(14px, 3vw, 20px);
background: color-mix(in srgb, var(--text) 6%, transparent);
box-shadow: 0 18px 40px rgba(0, 0, 0, .26);
display: flex;
flex-direction: column;
gap: 12px;
animation: float 16s ease-in-out infinite
}
.hero-stats-title {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 12px;
letter-spacing: .16em;
text-transform: uppercase;
color: var(--t3)
}
.hero-right .hero-metrics {
margin-top: 0
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
border: 1px solid var(--border);
background: color-mix(in srgb, var(--text) 6%, transparent);
color: var(--text);
font-weight: 600;
text-decoration: none
}
.btn:hover {
background: color-mix(in srgb, var(--text) 10%, transparent)
}
.btn.primary {
background: linear-gradient(180deg, color-mix(in srgb, var(--accent) 90%, transparent), color-mix(in srgb, var(--accent) 70%, transparent));
color: #061318;
border-color: color-mix(in srgb, var(--accent) 60%, transparent)
}
.btn.soft {
background: color-mix(in srgb, var(--text) 8%, transparent)
}
.btn.ghost {
background: transparent
}
.btn.mini {
padding: 6px 10px;
font-size: 12px
}
/* HUB tiles */
.hub-head {
margin: 6px 0 10px
}
.tiles {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 12px;
align-items: stretch
}
.tile {
border: 1px solid var(--border);
background: color-mix(in srgb, var(--text) 6%, transparent);
display: flex;
flex-direction: column;
border-radius: 18px;
overflow: hidden;
box-shadow: 0 12px 26px rgba(0, 0, 0, .25);
transition: transform .25s ease, box-shadow .25s ease;
position: relative;
min-height: 360px
}
.tile.is-fresh {
border-color: color-mix(in srgb, var(--accent) 32%, transparent)
}
.tile.is-active {
border-color: color-mix(in srgb, #fbbf24 28%, transparent)
}
.tile.is-complete {
border-color: color-mix(in srgb, #22c55e 32%, transparent)
}
.tile.is-locked {
filter: saturate(.7) brightness(.9)
}
.tile:hover {
box-shadow: 0 18px 40px rgba(0, 0, 0, .3)
}
.tile-media {
height: 140px;
background-size: cover;
background-position: center;
position: relative
}
.tile-badge {
position: absolute;
top: 12px;
left: 12px;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: 999px;
background: linear-gradient(120deg, color-mix(in srgb, var(--accent) 65%, transparent), color-mix(in srgb, var(--accent2) 65%, transparent));
color: #f8fafc;
font-size: 12px;
letter-spacing: .08em;
text-transform: uppercase;
box-shadow: 0 6px 18px rgba(2, 6, 23, .4)
}
.tile-body {
padding: 14px;
display: flex;
flex-direction: column;
gap: 12px;
flex: 1
}
.tile-top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px
}
.tile-title {
font-weight: 600;
display: flex;
align-items: center;
gap: 6px
}
.tile-status {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 999px;
font-size: 12px;
letter-spacing: .08em;
text-transform: uppercase;
border: 1px solid color-mix(in srgb, var(--text) 18%, transparent);
color: var(--t3)
}
.tile-status.is-fresh {
color: var(--accent);
border-color: color-mix(in srgb, var(--accent) 35%, transparent);
background: color-mix(in srgb, var(--accent) 12%, transparent)
}
.tile-status.is-active {
color: #fbbf24;
border-color: color-mix(in srgb, #fbbf24 40%, transparent);
background: color-mix(in srgb, #fbbf24 12%, transparent)
}
.tile-status.is-complete {
color: #22c55e;
border-color: color-mix(in srgb, #22c55e 40%, transparent);
background: color-mix(in srgb, #22c55e 14%, transparent)
}
.tile-status.is-locked {
color: var(--t3);
border-color: color-mix(in srgb, var(--text) 14%, transparent);
background: color-mix(in srgb, var(--text) 4%, transparent)
}
.tile-overlay {
position: absolute;
inset: 0;
background: rgba(6, 12, 34, .82);
color: #f8fafc;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
padding: 28px;
backdrop-filter: blur(4px);
pointer-events: none
}
.tile-overlay-inner {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
max-width: 240px
}
.tile-overlay-title {
font-weight: 600;
letter-spacing: .08em;
text-transform: uppercase
}
.tile-overlay-sub {
font-size: 14px;
color: rgba(248, 250, 252, .75)
}
.line {
height: 8px;
border: 1px solid var(--border);
background: color-mix(in srgb, var(--text) 8%, transparent);
border-radius: 999px;
overflow: hidden
}
.line-fill {
height: 100%;
background: linear-gradient(90deg, var(--accent), var(--accent2))
}
.tile-progress-meta {
display: flex;
justify-content: space-between;
color: var(--t3);
font-size: 12px;
letter-spacing: .08em;
text-transform: uppercase
}
.tile-actions {
display: flex;
flex-direction: column;
gap: 10px;
align-items: stretch;
margin-top: auto
}
.tile-actions .btn {
width: 100%;
justify-content: center;
}
@media (min-width: 720px) {
.tile-actions {
flex-direction: row;
flex-wrap: wrap;
}
.tile-actions .btn {
flex: 1 1 50%;
}
}
/* Play */
.play.has-mission-footer {
padding-bottom: 160px;
}
.play .play-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px
}
.play-tools {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
justify-content: flex-end;
min-height: 44px;
}
.crumbs {
display: flex;
align-items: center;
gap: 8px
}
.link {
background: transparent;
border: 0;
color: var(--text);
display: inline-flex;
align-items: center;
gap: 6px
}
.stats {
display: flex;
gap: 8px
}
.stat {
border: 1px solid var(--border);
padding: 6px 10px;
background: color-mix(in srgb, var(--text) 6%, transparent)
}
.module-content {
display: flex;
flex-direction: column;
gap: 24px;
margin-top: 20px;
}
.module-overview,
.module-detail {
position: relative;
padding: 24px;
border-radius: 24px;
border: 1px solid color-mix(in srgb, var(--text) 14%, transparent);
background: color-mix(in srgb, var(--text) 4%, transparent);
box-shadow: 0 24px 48px rgba(2, 6, 23, .5);
backdrop-filter: blur(18px);
}
.module-overview {
position: relative;
overflow: visible;
border-color: color-mix(in srgb, var(--accent) 38%, transparent);
background:
radial-gradient(420px 260px at -10% -20%, color-mix(in srgb, var(--accent) 22%, transparent), transparent 70%),
linear-gradient(150deg, color-mix(in srgb, var(--bg2) 82%, transparent), color-mix(in srgb, var(--text) 6%, transparent));
--module-overview-gap: 28px;
--lesson-track-max-height: 400px;
--lesson-track-opacity: 1;
--lesson-track-pointer-events: auto;
--lesson-track-padding-bottom: 12px;
--lesson-track-offset: 0px;
display: flex;
flex-direction: column;
gap: var(--module-overview-gap);
border-radius: 28px;
isolation: isolate;
}
.module-overview::before {
content: "";
position: absolute;
inset: -40% 30% auto 10%;
height: 320px;
width: 320px;
background: radial-gradient(180px 180px at center, color-mix(in srgb, var(--accent2) 24%, transparent), transparent 70%);
opacity: .4;
pointer-events: none;
transform: rotate(-6deg);
z-index: 0;
}
.module-overview::after {
content: "";
position: absolute;
inset: -20% 0 -60% 40%;
background: radial-gradient(520px 360px at 110% 100%, color-mix(in srgb, var(--accent2) 28%, transparent), transparent 75%);
opacity: .35;
pointer-events: none;
filter: blur(12px);
z-index: 0;
}
.module-overview-header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 24px;
flex-wrap: wrap;
position: relative;
z-index: 1;
}
.module-overview-meta {
display: flex;
flex-direction: column;
gap: 10px;
max-width: 520px;
}
.module-overview-tools {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 14px;
min-width: 220px;
}
.module-overview-toggle {
display: none;
align-items: center;
gap: 8px;
padding: 6px 16px;
border-radius: 999px;
border: 1px solid color-mix(in srgb, var(--accent) 30%, transparent);
background: color-mix(in srgb, var(--bg2) 82%, transparent);
color: color-mix(in srgb, var(--t3) 30%, white 5%);
font-size: 12px;
letter-spacing: .08em;
text-transform: uppercase;
cursor: pointer;
transition: background .2s ease, color .2s ease, border-color .2s ease, box-shadow .2s ease;
}
.module-overview-toggle:hover {
background: color-mix(in srgb, var(--accent) 18%, transparent);
color: color-mix(in srgb, var(--accent) 85%, white 10%);
border-color: color-mix(in srgb, var(--accent) 42%, transparent);
box-shadow: 0 10px 24px rgba(2, 6, 23, .35);
}
.module-overview-toggle:focus-visible {
outline: 2px solid color-mix(in srgb, var(--accent) 72%, white 12%);
outline-offset: 2px;
}
.module-overview-toggle-label {
font-weight: 600;
}
.module-overview-toggle-icon {
transition: transform .25s ease;
}
.module-overview-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 12px;
border-radius: 999px;
font-size: 11px;
letter-spacing: .1em;
text-transform: uppercase;
background: color-mix(in srgb, var(--accent) 22%, transparent);
color: color-mix(in srgb, var(--accent) 80%, white 10%);
}
.module-overview-title {
font-size: 26px;
font-weight: 700;
letter-spacing: .02em;
}
.module-overview-sub {
margin: 0;
font-size: 15px;
color: color-mix(in srgb, var(--t3) 82%, transparent);
line-height: 1.5;
}
.module-overview-progress {
display: flex;
flex-direction: column;
gap: 10px;
align-items: flex-end;
min-width: 220px;
position: relative;
z-index: 1;
}
.module-overview-progress-label {
font-size: 12px;
letter-spacing: .08em;
text-transform: uppercase;
color: color-mix(in srgb, var(--t3) 80%, transparent);
}
.module-overview-progress-bar {
width: 220px;
height: 6px;
border-radius: 999px;
border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent);
background: color-mix(in srgb, var(--bg2) 82%, transparent);
overflow: hidden;
}
.module-overview-progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--accent), var(--accent2));
border-radius: inherit;
}
.module-overview-progress-meta {
font-size: 12px;
letter-spacing: .04em;
text-transform: uppercase;
color: color-mix(in srgb, var(--t3) 70%, transparent);
}
.module-overview.is-active .module-overview-toggle {
background: color-mix(in srgb, var(--accent) 18%, transparent);
color: color-mix(in srgb, var(--accent) 85%, white 10%);
border-color: color-mix(in srgb, var(--accent) 42%, transparent);
box-shadow: 0 10px 24px rgba(2, 6, 23, .35);
}
.module-overview.is-active .module-overview-toggle-icon {
transform: rotate(180deg);
}
.module-detail {
border-color: color-mix(in srgb, var(--text) 22%, transparent);
background: linear-gradient(160deg, color-mix(in srgb, var(--text) 6%, transparent) 0%, color-mix(in srgb, var(--bg2) 70%, transparent) 100%);
overflow: visible;
display: flex;
flex-direction: column;
}
.module-detail .console {
margin-top: 0;
}
.lesson-track {
overflow-x: auto;
overflow-y: hidden;
padding-bottom: var(--lesson-track-padding-bottom, 12px);
margin: 0;
padding-left: 6px;
padding-right: 6px;
scroll-snap-type: x proximity;
mask-image: linear-gradient(to right, transparent, rgba(0, 0, 0, .9) 20%, rgba(0, 0, 0, .9) 80%, transparent);
position: relative;
z-index: 1;
max-height: var(--lesson-track-max-height, 400px);
opacity: var(--lesson-track-opacity, 1);
pointer-events: var(--lesson-track-pointer-events, auto);
transform: translateY(var(--lesson-track-offset, 0px));
transition: max-height .3s ease, opacity .3s ease, padding-bottom .3s ease, transform .3s ease;
}
@media (hover: hover) {
.module-overview {
--module-overview-gap: 18px;
--lesson-track-max-height: 0px;
--lesson-track-opacity: 0;
--lesson-track-pointer-events: none;
--lesson-track-padding-bottom: 0px;
--lesson-track-offset: -12px;
}
.module-overview-toggle {
display: inline-flex;
}
.module-overview:hover,
.module-overview:focus-within,
.module-overview.is-expanded {
--module-overview-gap: 28px;
--lesson-track-max-height: 400px;
--lesson-track-opacity: 1;
--lesson-track-pointer-events: auto;
--lesson-track-padding-bottom: 12px;
--lesson-track-offset: 0px;
}
}
.lesson-grid {
display: flex;
gap: 12px;
min-width: max-content;
}
.lesson {
scroll-snap-align: center;
}
.lesson {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
gap: 12px;
text-align: left;
border: 1px solid var(--border);
padding: 20px;
margin-top: 5px;
background: color-mix(in srgb, var(--text) 6%, transparent);
cursor: pointer;
border-radius: 6px;
transition: transform .2s ease, box-shadow .2s ease, border-color .2s ease;
box-shadow: 0 10px 20px rgba(0, 0, 0, .18)
}
.lesson:hover {
box-shadow: 0 14px 26px rgba(0, 0, 0, .22)
}
.lesson.active {
outline: 1px solid color-mix(in srgb, var(--accent) 50%, transparent);
box-shadow: 0 1px 12px rgba(34, 211, 238, .22)
}
.lesson.ok {
border-color: color-mix(in srgb, #22c55e 50%, transparent)
}
.lesson-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.lesson-title {
display: inline-flex;
align-items: center;
gap: 8px;
font-weight: 600;
line-height: 1.3;
flex: 1;
min-width: 0;
}
.lesson-score {
position: static;
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
border-radius: 999px;
font-size: 12px;
letter-spacing: .08em;
text-transform: uppercase;
border: 1px solid color-mix(in srgb, var(--text) 18%, transparent);
color: var(--t3);
background: color-mix(in srgb, var(--text) 10%, transparent);
animation: badgePulse 8s ease-in-out infinite;
flex-shrink: 0;
}
.lesson-score.is-new {
color: var(--accent);
border-color: color-mix(in srgb, var(--accent) 40%, transparent);
background: color-mix(in srgb, var(--accent) 16%, transparent)
}
.lesson-score.is-progress {
color: #fbbf24;
border-color: color-mix(in srgb, #fbbf24 40%, transparent);
background: color-mix(in srgb, #fbbf24 14%, transparent)
}
.lesson-score.is-great {
color: #22c55e;
border-color: color-mix(in srgb, #22c55e 40%, transparent);
background: color-mix(in srgb, #22c55e 14%, transparent)
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px
}
.tag {
border: 1px dashed color-mix(in srgb, var(--text) 25%, transparent);
color: var(--t3);
font-size: 11px;
padding: 2px 6px
}
.console {
display: flex;
flex-direction: column;
gap: 16px;
margin-top: 16px;
flex: 1 1 auto
}
.console-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 16px
}
.col {
display: flex;
flex-direction: column;
gap: 12px
}
.col .label {
font-size: 12px;
letter-spacing: .12em;
color: var(--t3);
margin-bottom: 6px
}
.panel {
border: 1px solid var(--border);
background: color-mix(in srgb, var(--text) 6%, transparent);
padding: 12px;
backdrop-filter: blur(10px);
border-radius: 12px
}
.target-row {
display: flex;
flex-direction: column;
gap: 12px;
align-items: flex-start
}
.target-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 4px
}
.row {
display: flex;
gap: 8px;
margin-top: 8px
}
.row.between {
justify-content: space-between
}
.row.end {
justify-content: flex-end
}
.input :deep(textarea) {
background: color-mix(in srgb, var(--text) 6%, transparent);
border: 1px solid var(--border);
color: var(--text)
}
.score {
margin-top: 8px
}
.score-num {
font-size: 28px;
font-weight: 700
}
/* Responsive */
@media (max-width: 980px) {
.hero {
padding-top: clamp(1.75em, 9vw, 2.75em);
}
.hero-panel {
grid-template-columns: 1fr;
padding: clamp(14px, 5vw, 18px);
gap: clamp(12px, 4vw, 18px);
}
.hero-highlight {
grid-template-columns: 1fr;
}
.module-content {
gap: 18px;
}
.module-overview,
.module-detail {
padding: 18px;
}
.module-overview {
gap: 20px;
}
.module-overview-progress {
align-items: flex-start;
min-width: 0;
width: 100%;
}
.module-overview-progress-bar {
width: 100%;
}
.console-grid {
grid-template-columns: 1fr
}
.stats {
display: none
}
.objective-callout {
flex-direction: column;
align-items: flex-start;
}
.objective-callout .btn {
width: 100%;
justify-content: center;
}
}
@media (max-width: 640px) {
.hero {
padding-top: clamp(1.5em, 10vw, 2.25em);
}
.hero-panel {
padding: 14px;
}
.hero-right {
gap: 12px;
}
.hero-highlight {
gap: 12px;
}
.hero-highlight-meta {
align-items: flex-start;
}
.objective-progress {
flex-direction: column;
align-items: flex-start;
gap: 6px;
}
.objective-progress-meta {
white-space: normal;
}
.next-objective {
align-items: flex-start;
text-align: left;
}
.next-header {
justify-content: flex-start;
}
.next-value,
.next-status {
text-align: left;
}
}
/* Scenario bar & cloze */
.scenario-bar {
grid-column: 1 / -1;
display: flex;
flex-wrap: wrap;
gap: 12px;
padding: 12px;
border: 1px solid var(--border);
background: color-mix(in srgb, var(--text) 6%, transparent);
border-radius: 12px;
}
.scenario-item {
min-width: 180px;
border: 1px solid var(--border);
background: color-mix(in srgb, var(--text) 4%, transparent);
padding: 10px 12px;
border-radius: 10px;
display: flex;
flex-direction: column;
gap: 4px;
}
.scenario-item.wide {
flex: 1 1 260px;
}
.scenario-label {
font-size: 11px;
letter-spacing: .08em;
color: var(--t3);
text-transform: uppercase;
}
.scenario-value {
font-size: 18px;
font-weight: 600;
}
.scenario-sub {
color: var(--t3);
font-size: 13px;
}
.freq-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 6px;
}
.freq-chip {
border: 1px solid var(--border);
background: color-mix(in srgb, var(--text) 4%, transparent);
padding: 6px 10px;
font-size: 12px;
letter-spacing: .02em;
}
.freq-chip.active {
border-color: color-mix(in srgb, var(--accent) 40%, transparent);
background: color-mix(in srgb, var(--accent) 12%, transparent);
color: var(--accent);
}
.freq-hint {
margin-top: 4px;
}
.target-main {
display: flex;
flex-direction: column;
gap: 12px;
}
.target-text {
font-size: 18px;
font-weight: 600;
line-height: 1.4;
}
.mission-footer-bar {
position: sticky;
bottom: 0;
z-index: 45;
border-top: 1px solid var(--border);
background: color-mix(in srgb, var(--bg) 85%, transparent);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
box-shadow: 0 -24px 44px rgba(2, 6, 23, .45);
}
.mission-footer {
display: grid;
grid-template-columns: minmax(0, auto) 1fr minmax(0, auto);
align-items: center;
gap: 18px;
padding: 10px;
}
.mission-footer-section {
display: flex;
align-items: center;
gap: 12px;
}
.mission-footer-left {
justify-content: flex-start;
}
.mission-footer-right {
justify-content: flex-end;
flex-wrap: wrap;
}
.mission-footer-right .btn {
min-width: 150px;
justify-content: center;
}
.mission-footer-prev {
min-width: 150px;
justify-content: center;
}
.mission-footer-center {
text-align: center;
}
.mission-footer-hint {
font-size: 13px;
letter-spacing: .06em;
text-transform: uppercase;
color: color-mix(in srgb, var(--t3) 80%, transparent);
}
.row.wrap {
flex-wrap: wrap;
}
.controls {
margin-top: 12px;
}
.readback-panel {
padding: 20px;
border-radius: 20px;
border: 1px solid color-mix(in srgb, var(--accent) 22%, transparent);
background: linear-gradient(145deg, color-mix(in srgb, var(--accent) 12%, transparent), color-mix(in srgb, var(--bg2) 70%, transparent));
box-shadow: 0 24px 44px rgba(2, 6, 23, .45);
display: flex;
flex-direction: column;
gap: 18px;
}
.cloze {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: stretch;
line-height: 1.4;
text-transform: uppercase;
letter-spacing: .08em;
}
.cloze-chunk {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 10px 14px;
border-radius: 12px;
border: 1px solid color-mix(in srgb, var(--text) 18%, transparent);
background: color-mix(in srgb, var(--text) 6%, transparent);
min-height: 46px;
position: relative;
transition: border-color .2s ease, background .2s ease;
}
.cloze-text {
font-size: 14px;
font-weight: 600;
color: color-mix(in srgb, var(--text) 88%, transparent);
letter-spacing: .06em;
white-space: pre-wrap;
}
.blank {
flex-direction: column;
align-items: stretch;
gap: 6px;
min-width: 120px;
padding: 10px 14px 12px;
background: color-mix(in srgb, var(--text) 8%, transparent);
border: 1px solid color-mix(in srgb, var(--text) 20%, transparent);
}
.blank input {
background: transparent;
border: 0;
color: var(--text);
min-width: 80px;
font-size: 16px;
font-weight: 600;
outline: none;
letter-spacing: .12em;
text-transform: uppercase;
}
.blank input::placeholder {
text-transform: uppercase;
color: color-mix(in srgb, var(--t3) 80%, transparent);
}
.blank.size-xs {
min-width: 90px;
}
.blank.size-sm {
min-width: 120px;
}
.blank.size-md {
min-width: 160px;
}
.blank.size-lg {
min-width: 210px;
}
.blank.size-xl {
min-width: 260px;
}
.blank.ok {
border-color: color-mix(in srgb, var(--accent) 50%, transparent);
background: color-mix(in srgb, var(--accent) 12%, transparent);
}
.blank.warn {
border-color: color-mix(in srgb, #f97316 45%, transparent);
}
.blank-status {
position: absolute;
top: 8px;
right: 8px;
}
.blank-status.ok {
color: var(--accent);
}
.blank-status.warn {
color: #f97316;
}
.blank-feedback {
margin: 0;
font-size: 11px;
color: var(--t3);
text-transform: none;
letter-spacing: normal;
}
.field-checks {
margin-top: 12px;
display: grid;
gap: 10px;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
}
.field-check {
border: 1px solid color-mix(in srgb, var(--text) 14%, transparent);
padding: 12px;
border-radius: 14px;
background: color-mix(in srgb, var(--text) 4%, transparent);
box-shadow: 0 12px 26px rgba(2, 6, 23, .28);
}
.field-check.ok {
border-color: color-mix(in srgb, var(--accent) 40%, transparent);
background: color-mix(in srgb, var(--accent) 10%, transparent);
}
.field-name {
font-size: 12px;
letter-spacing: .08em;
color: var(--t3);
text-transform: uppercase;
margin-bottom: 4px;
}
.field-answer {
font-weight: 600;
}
.field-expected {
font-size: 12px;
color: var(--t3);
}
.hint.secondary {
opacity: 0.8;
}
.audio-blur {
filter: blur(8px);
pointer-events: none;
user-select: none;
}
.audio-note {
margin-top: 6px;
}
.spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@keyframes orbFloat {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-8px);
}
}
@keyframes float {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-10px);
}
}
@keyframes badgePulse {
0%, 100% {
box-shadow: 0 0 0 0 rgba(34, 211, 238, .25);
}
50% {
box-shadow: 0 0 18px 0 rgba(34, 211, 238, .35);
}
}
.settings {
display: flex;
flex-direction: column;
gap: 12px;
}
.set-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
}
.set-info {
display: flex;
flex-direction: column;
gap: 4px;
max-width: 70%;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
@media (max-width: 720px) {
.scenario-bar {
flex-direction: column;
}
.scenario-item {
width: 100%;
}
.target-text {
font-size: 16px;
}
.mission-footer {
grid-template-columns: 1fr;
gap: 18px;
}
.mission-footer-section {
justify-content: center;
}
.mission-footer-right {
justify-content: center;
}
.mission-footer-right .btn,
.mission-footer-prev {
flex: 1 1 100%;
min-width: 0;
}
.play.has-mission-footer {
padding-bottom: 260px;
}
.plan-status {
width: 100%;
justify-content: flex-start;
}
.plan-status-actions {
width: 100%;
justify-content: flex-start;
}
.plan-status-actions .btn {
flex: 1 1 100%;
justify-content: center;
}
}
@media (prefers-reduced-motion: reduce) {
.hero-orb,
.metric-card,
.badge-card,
.lesson-score {
animation: none !important;
}
.tile,
.lesson,
.challenge-card,
.hero-orb,
.metric-card,
.badge-card,
.hero-highlight-fill,
.badge-progress-fill {
transition: none !important;
}
}
.footer {
font-size: 13px;
color: var(--t3);
margin-top: 40px;
}
.footer.is-mission {
margin-top: 0;
padding: 0;
}
.footer-container {
display: flex;
flex-direction: column;
gap: 24px;
}
.footer-meta {
text-align: center;
}
.footer a {
color: var(--accent);
text-decoration: none;
}
.footer a:hover {
text-decoration: underline;
}
.plan-status {
display: inline-flex;
align-items: center;
gap: 16px;
padding: 10px 16px;
border: 1px solid color-mix(in srgb, var(--text) 12%, transparent);
border-radius: 14px;
background: color-mix(in srgb, var(--text) 5%, transparent);
box-shadow: 0 12px 24px rgba(2, 6, 23, .32);
flex-wrap: wrap;
}
.plan-status-icon {
display: grid;
place-items: center;
width: 28px;
height: 28px;
border-radius: 50%;
background: color-mix(in srgb, var(--accent) 20%, transparent);
color: var(--accent);
}
.plan-status-body {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 160px;
}
.plan-status-title {
font-weight: 600;
letter-spacing: .08em;
text-transform: uppercase;
font-size: 12px;
}
.plan-status-sub {
font-size: 12px;
color: var(--t3);
}
.plan-status-actions {
display: inline-flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.plan-status-actions .btn {
flex: 0 0 auto;
}
.plan-status.is-ready {
border-color: color-mix(in srgb, var(--accent) 30%, transparent);
background: linear-gradient(135deg, color-mix(in srgb, var(--accent) 12%, transparent), color-mix(in srgb, var(--bg2) 80%, transparent));
}
.module-stage-panel {
margin-top: 24px;
border-radius: 24px;
border: 1px solid color-mix(in srgb, var(--text) 12%, transparent);
background: color-mix(in srgb, var(--text) 4%, transparent);
padding: 28px;
box-shadow: 0 28px 54px rgba(2, 6, 23, .5);
backdrop-filter: blur(18px);
}
.mission-setup {
display: flex;
flex-direction: column;
gap: 24px;
}
.setup-header {
display: flex;
flex-direction: column;
gap: 6px;
}
.plan-modes {
display: flex;
flex-wrap: wrap;
gap: 14px;
}
.plan-mode-card {
flex: 1 1 200px;
min-width: 220px;
border-radius: 18px;
border: 1px solid color-mix(in srgb, var(--text) 12%, transparent);
background: color-mix(in srgb, var(--text) 4%, transparent);
padding: 16px;
display: flex;
gap: 12px;
align-items: center;
transition: transform .2s ease, border-color .2s ease, box-shadow .2s ease;
box-shadow: 0 12px 32px rgba(2, 6, 23, .4);
}
.plan-mode-card:hover,
.plan-mode-card:focus-visible {
transform: translateY(-2px);
border-color: color-mix(in srgb, var(--accent) 40%, transparent);
box-shadow: 0 16px 38px rgba(34, 211, 238, .25);
outline: none;
}
.plan-mode-card.is-active {
border-color: color-mix(in srgb, var(--accent) 55%, transparent);
background: linear-gradient(135deg, color-mix(in srgb, var(--accent) 16%, transparent), color-mix(in srgb, var(--bg2) 70%, transparent));
}
.plan-mode-icon {
width: 44px;
height: 44px;
border-radius: 14px;
display: grid;
place-items: center;
background: color-mix(in srgb, var(--accent) 20%, transparent);
color: var(--accent);
}
.plan-mode-body {
display: flex;
flex-direction: column;
gap: 4px;
}
.plan-mode-title {
font-weight: 600;
letter-spacing: .06em;
text-transform: uppercase;
font-size: 13px;
}
.plan-panel {
border-radius: 20px;
border: 1px solid color-mix(in srgb, var(--text) 12%, transparent);
background: color-mix(in srgb, var(--text) 3%, transparent);
padding: 20px;
display: flex;
flex-direction: column;
gap: 18px;
box-shadow: 0 18px 42px rgba(2, 6, 23, .45);
}
.plan-summary {
display: flex;
gap: 16px;
align-items: center;
}
.plan-hero {
width: 128px;
height: 96px;
object-fit: cover;
border-radius: 16px;
border: 1px solid color-mix(in srgb, var(--text) 14%, transparent);
background: color-mix(in srgb, var(--text) 8%, transparent);
}
.plan-summary-body {
display: flex;
flex-direction: column;
gap: 6px;
}
.plan-callout {
font-size: 20px;
font-weight: 600;
}
.plan-route {
font-size: 14px;
letter-spacing: .08em;
text-transform: uppercase;
color: var(--t3);
}
.plan-tag {
font-size: 11px;
letter-spacing: .18em;
text-transform: uppercase;
color: var(--t3);
}
.plan-actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.plan-actions .btn {
flex: 1 1 200px;
justify-content: center;
}
.manual-grid {
display: grid;
gap: 20px;
grid-template-columns: minmax(0, 1fr) minmax(260px, 320px);
align-items: start;
}
.manual-form {
display: flex;
flex-direction: column;
gap: 16px;
}
.manual-card {
border-radius: 18px;
border: 1px solid color-mix(in srgb, var(--text) 12%, transparent);
background: color-mix(in srgb, var(--text) 4%, transparent);
padding: 18px;
display: flex;
flex-direction: column;
gap: 14px;
box-shadow: 0 16px 34px rgba(2, 6, 23, .4);
}
.manual-card--intro {
background: linear-gradient(135deg, color-mix(in srgb, var(--accent) 10%, transparent), color-mix(in srgb, var(--bg2) 70%, transparent));
border-color: color-mix(in srgb, var(--accent) 40%, transparent);
}
.manual-card-header {
display: flex;
gap: 12px;
align-items: flex-start;
}
.manual-card-icon {
width: 36px;
height: 36px;
border-radius: 12px;
display: grid;
place-items: center;
background: color-mix(in srgb, var(--accent) 22%, transparent);
color: var(--accent);
}
.manual-card-title {
font-weight: 600;
letter-spacing: .08em;
text-transform: uppercase;
font-size: 13px;
}
.required-grid {
display: grid;
gap: 12px;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
}
.required-dot {
margin-left: 4px;
color: var(--accent);
font-size: 14px;
}
.manual-card--optional .manual-card-title {
display: flex;
align-items: center;
gap: 8px;
}
.optional-chip {
font-size: 11px;
letter-spacing: .08em;
text-transform: uppercase;
color: var(--t3);
border: 1px solid color-mix(in srgb, var(--text) 22%, transparent);
border-radius: 999px;
padding: 2px 8px;
}
.optional-toggle {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
background: transparent;
border: 0;
color: inherit;
text-align: left;
padding: 0;
cursor: pointer;
}
.optional-body {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
}
.manual-card--optional .chevron {
transition: transform .2s ease;
}
.manual-card--optional.is-open .chevron {
transform: rotate(-180deg);
}
.optional-fields {
padding-top: 8px;
}
.manual-panel .field-grid {
display: grid;
gap: 12px;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
}
.field-grid.compact {
display: grid;
gap: 12px;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
}
.manual-panel .field {
display: flex;
flex-direction: column;
gap: 6px;
background: color-mix(in srgb, var(--text) 4%, transparent);
border: 1px solid color-mix(in srgb, var(--text) 12%, transparent);
padding: 10px 12px;
border-radius: 12px;
}
.manual-panel .field input {
background: transparent;
border: 0;
color: var(--text);
font-size: 14px;
font-weight: 500;
}
.manual-panel .field span {
font-size: 12px;
letter-spacing: .08em;
text-transform: uppercase;
color: var(--t3);
}
.manual-preview {
position: sticky;
top: 90px;
}
.preview-card {
border-radius: 18px;
border: 1px solid color-mix(in srgb, var(--accent) 28%, transparent);
background: linear-gradient(160deg, color-mix(in srgb, var(--accent) 12%, transparent), color-mix(in srgb, var(--bg2) 70%, transparent));
padding: 20px;
display: flex;
flex-direction: column;
gap: 18px;
box-shadow: 0 22px 40px rgba(2, 6, 23, .45);
}
.preview-header {
display: flex;
gap: 12px;
align-items: flex-start;
}
.preview-title {
font-weight: 600;
letter-spacing: .06em;
text-transform: uppercase;
font-size: 12px;
}
.preview-section {
display: flex;
flex-direction: column;
gap: 6px;
}
.preview-label {
font-size: 12px;
letter-spacing: .08em;
text-transform: uppercase;
color: var(--t3);
}
.preview-value {
font-size: 18px;
font-weight: 600;
}
.preview-sub {
line-height: 1.3;
}
.preview-pills {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.preview-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
border-radius: 999px;
background: color-mix(in srgb, var(--bg2) 80%, transparent);
font-size: 12px;
letter-spacing: .04em;
text-transform: uppercase;
}
.manual-actions {
margin-top: 4px;
justify-content: flex-end;
}
.collapse-enter-active,
.collapse-leave-active {
transition: all .2s ease;
}
.collapse-enter-from,
.collapse-leave-to {
opacity: 0;
transform: translateY(-6px);
}
.form-errors {
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px 12px;
border-radius: 12px;
background: color-mix(in srgb, #f87171 16%, transparent);
border: 1px solid color-mix(in srgb, #f87171 40%, transparent);
}
.form-error {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
}
.simbrief-panel {
display: flex;
flex-direction: column;
gap: 24px;
}
.simbrief-hero-card {
position: relative;
border-radius: 20px;
overflow: hidden;
min-height: 220px;
border: 1px solid color-mix(in srgb, var(--text) 12%, transparent);
background: linear-gradient(135deg, color-mix(in srgb, var(--bg2) 80%, transparent), transparent);
box-shadow: 0 20px 44px rgba(2, 6, 23, .52);
}
.simbrief-hero-art {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
filter: saturate(1.1) brightness(.75);
}
.simbrief-hero-overlay {
position: relative;
display: flex;
flex-direction: column;
gap: 14px;
padding: 28px;
min-height: 220px;
background: linear-gradient(160deg, rgba(15, 23, 42, .88), rgba(15, 23, 42, .4) 55%, rgba(15, 23, 42, .18));
color: var(--text);
}
.simbrief-tag {
align-self: flex-start;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 12px;
border-radius: 999px;
border: 1px solid color-mix(in srgb, var(--accent) 46%, transparent);
color: var(--accent);
font-size: 12px;
letter-spacing: .08em;
text-transform: uppercase;
}
.simbrief-hero-title {
font-size: 28px;
letter-spacing: .02em;
font-weight: 700;
}
.simbrief-hero-text {
max-width: 540px;
color: color-mix(in srgb, var(--text) 82%, transparent);
font-size: 15px;
line-height: 1.5;
}
.simbrief-hero-highlights {
display: flex;
flex-wrap: wrap;
gap: 8px 12px;
}
.simbrief-hero-highlights span,
.simbrief-link {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: 999px;
background: color-mix(in srgb, var(--text) 6%, transparent);
font-size: 12px;
letter-spacing: .06em;
text-transform: uppercase;
color: color-mix(in srgb, var(--text) 88%, transparent);
}
.simbrief-link {
border: 1px solid color-mix(in srgb, var(--accent) 40%, transparent);
color: var(--accent);
text-decoration: none;
}
.simbrief-body {
display: flex;
flex-direction: column;
gap: 20px;
}
.simbrief-step-grid {
display: grid;
gap: 16px;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
}
.simbrief-step-card {
border-radius: 16px;
border: 1px solid color-mix(in srgb, var(--text) 14%, transparent);
background: color-mix(in srgb, var(--text) 4%, transparent);
padding: 16px;
display: flex;
flex-direction: column;
gap: 10px;
color: var(--t3);
box-shadow: 0 12px 30px rgba(2, 6, 23, .35);
}
.simbrief-step-head {
display: flex;
align-items: center;
gap: 10px;
}
.step-number {
width: 32px;
height: 32px;
border-radius: 999px;
display: grid;
place-items: center;
font-weight: 600;
background: color-mix(in srgb, var(--accent) 24%, transparent);
color: var(--accent);
}
.simbrief-step-title {
font-weight: 600;
letter-spacing: .06em;
text-transform: uppercase;
font-size: 12px;
color: color-mix(in srgb, var(--text) 92%, transparent);
}
.simbrief-form {
display: flex;
flex-direction: column;
gap: 12px;
}
.simbrief-form-row {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: flex-end;
}
.simbrief-field {
flex: 1 1 220px;
}
.simbrief-field span {
font-size: 12px;
letter-spacing: .08em;
text-transform: uppercase;
color: var(--t3);
}
.simbrief-field input {
width: 100%;
border-radius: 12px;
border: 1px solid color-mix(in srgb, var(--text) 16%, transparent);
padding: 12px 14px;
background: color-mix(in srgb, var(--text) 6%, transparent);
color: var(--text);
font-size: 16px;
letter-spacing: .12em;
text-transform: uppercase;
}
.simbrief-submit {
flex: 0 0 auto;
min-width: 200px;
justify-content: center;
}
.simbrief-note {
margin: 0;
color: var(--t3);
}
.simbrief-status {
display: inline-flex;
align-items: center;
gap: 10px;
color: var(--t3);
font-size: 13px;
}
.simbrief-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px;
border-radius: 16px;
padding: 12px 16px;
border: 1px solid color-mix(in srgb, var(--accent) 28%, transparent);
background: color-mix(in srgb, var(--accent) 10%, transparent);
color: color-mix(in srgb, var(--text) 90%, transparent);
}
.simbrief-meta-info {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 14px;
}
.error-banner {
display: flex;
gap: 8px;
align-items: center;
padding: 10px 12px;
border-radius: 14px;
background: color-mix(in srgb, #f87171 16%, transparent);
border: 1px solid color-mix(in srgb, #f87171 35%, transparent);
font-size: 13px;
}
.mission-briefing {
display: flex;
flex-direction: column;
gap: 28px;
}
.briefing-hero-banner {
position: relative;
border-radius: 24px;
overflow: hidden;
min-height: 260px;
border: 1px solid color-mix(in srgb, var(--text) 14%, transparent);
box-shadow: 0 26px 48px rgba(2, 6, 23, .55);
}
.briefing-hero-bg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
filter: saturate(1.1) brightness(.7);
}
.briefing-hero-content {
position: relative;
display: flex;
flex-direction: column;
gap: 16px;
padding: 32px;
min-height: 260px;
background: linear-gradient(120deg, rgba(15, 23, 42, .92), rgba(15, 23, 42, .45) 50%, rgba(15, 23, 42, .2));
color: #f8fafc;
}
.briefing-tag-row {
display: flex;
align-items: center;
gap: 12px;
}
.briefing-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 12px;
border-radius: 999px;
background: color-mix(in srgb, var(--accent) 18%, transparent);
color: var(--accent);
font-size: 12px;
letter-spacing: .08em;
text-transform: uppercase;
}
.briefing-hero-title {
font-size: 32px;
font-weight: 700;
letter-spacing: .02em;
}
.briefing-hero-route {
font-size: 15px;
text-transform: uppercase;
letter-spacing: .12em;
color: color-mix(in srgb, #f8fafc 78%, transparent);
}
.briefing-hero-copy {
max-width: 540px;
color: color-mix(in srgb, #f8fafc 72%, transparent);
}
.briefing-hero-pills {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
border-radius: 999px;
background: color-mix(in srgb, var(--text) 6%, transparent);
border: 1px solid color-mix(in srgb, var(--accent) 30%, transparent);
font-size: 12px;
letter-spacing: .06em;
text-transform: uppercase;
color: color-mix(in srgb, var(--text) 92%, transparent);
backdrop-filter: blur(4px);
}
.briefing-hero-details {
display: grid;
gap: 12px;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
}
.hero-detail {
display: flex;
flex-direction: column;
gap: 4px;
padding: 12px;
border-radius: 16px;
background: rgba(15, 23, 42, .55);
border: 1px solid rgba(148, 163, 184, .35);
color: #e2e8f0;
}
.hero-detail-label {
font-size: 11px;
letter-spacing: .08em;
text-transform: uppercase;
color: rgba(226, 232, 240, .78);
}
.hero-detail-value {
font-weight: 600;
font-size: 14px;
letter-spacing: .05em;
text-transform: uppercase;
}
.hero-detail-sub {
font-size: 12px;
color: rgba(226, 232, 240, .7);
}
.briefing-summary {
display: grid;
gap: 20px;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
}
.briefing-summary-card {
position: relative;
display: grid;
grid-template-columns: auto 1fr;
align-items: center;
gap: 18px;
padding: 22px;
border-radius: 24px;
border: 1px solid color-mix(in srgb, var(--accent) 24%, transparent);
background: linear-gradient(140deg, color-mix(in srgb, var(--bg2) 82%, transparent), color-mix(in srgb, var(--text) 6%, transparent));
box-shadow: 0 24px 48px rgba(2, 6, 23, .48);
overflow: hidden;
}
.briefing-summary-card::after {
content: "";
position: absolute;
inset: 18px 18px auto auto;
width: 120px;
height: 120px;
background: radial-gradient(60px 60px at center, color-mix(in srgb, var(--accent) 26%, transparent), transparent 70%);
opacity: .25;
pointer-events: none;
}
.summary-icon {
width: 52px;
height: 52px;
border-radius: 18px;
display: grid;
place-items: center;
font-size: 20px;
color: white;
box-shadow: 0 16px 28px rgba(2, 6, 23, .45);
position: relative;
z-index: 1;
}
.summary-icon--primary {
background: linear-gradient(135deg, color-mix(in srgb, var(--accent) 60%, transparent), color-mix(in srgb, var(--accent2) 50%, transparent));
}
.summary-icon--departure {
background: linear-gradient(135deg, color-mix(in srgb, #60a5fa 60%, transparent), color-mix(in srgb, #2563eb 45%, transparent));
}
.summary-icon--arrival {
background: linear-gradient(135deg, color-mix(in srgb, #f472b6 60%, transparent), color-mix(in srgb, #db2777 45%, transparent));
}
.summary-icon--codes {
background: linear-gradient(135deg, color-mix(in srgb, #fbbf24 60%, transparent), color-mix(in srgb, #d97706 45%, transparent));
color: #1f2937;
}
.summary-body {
display: flex;
flex-direction: column;
gap: 6px;
position: relative;
z-index: 1;
}
.summary-label {
font-size: 12px;
letter-spacing: .08em;
text-transform: uppercase;
color: color-mix(in srgb, var(--t3) 80%, transparent);
}
.summary-value {
font-weight: 700;
font-size: 20px;
letter-spacing: .04em;
text-transform: uppercase;
}
.summary-sub {
font-size: 13px;
color: color-mix(in srgb, var(--t3) 78%, transparent);
letter-spacing: .02em;
}
.briefing-layout {
display: grid;
gap: 20px;
grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
align-items: start;
}
.briefing-main-grid {
display: grid;
gap: 20px;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
}
.briefing-sidebar {
display: flex;
flex-direction: column;
gap: 20px;
position: sticky;
top: 90px;
}
.briefing-card {
border-radius: 20px;
border: 1px solid color-mix(in srgb, var(--text) 14%, transparent);
background: linear-gradient(160deg, color-mix(in srgb, var(--bg2) 78%, transparent), color-mix(in srgb, var(--text) 4%, transparent));
padding: 20px;
display: flex;
flex-direction: column;
gap: 14px;
box-shadow: 0 24px 44px rgba(2, 6, 23, .5);
}
.briefing-card-art {
width: 100%;
height: 120px;
object-fit: cover;
border-radius: 14px;
border: 1px solid color-mix(in srgb, var(--accent) 30%, transparent);
}
.card-title {
display: flex;
align-items: center;
gap: 6px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: .08em;
font-size: 12px;
color: var(--t2);
}
.briefing-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 8px;
font-size: 14px;
color: var(--t3);
line-height: 1.5;
}
.briefing-checklist-card {
height: 100%;
}
.briefing-checklist {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 16px;
}
.briefing-checklist li {
display: flex;
gap: 12px;
align-items: flex-start;
}
.check-number {
width: 30px;
height: 30px;
border-radius: 999px;
display: grid;
place-items: center;
font-weight: 600;
background: color-mix(in srgb, var(--accent) 24%, transparent);
color: var(--accent);
box-shadow: 0 6px 18px rgba(34, 211, 238, .25);
}
.check-title {
font-weight: 600;
letter-spacing: .04em;
text-transform: uppercase;
font-size: 12px;
}
.briefing-actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
justify-content: flex-end;
}
.briefing-actions .btn {
flex: 1 1 220px;
justify-content: center;
}
@media (max-width: 960px) {
.plan-mode-card {
min-width: 180px;
}
.plan-summary {
flex-direction: column;
align-items: flex-start;
}
.plan-hero {
width: 100%;
height: 140px;
}
.plan-actions .btn,
.briefing-actions .btn {
flex: 1 1 100%;
}
.manual-grid {
grid-template-columns: 1fr;
}
.manual-preview {
position: static;
}
.briefing-hero-content {
padding: 24px;
}
.briefing-hero-title {
font-size: 28px;
}
.briefing-layout {
grid-template-columns: 1fr;
}
.briefing-sidebar {
position: static;
}
}
.footer-fixed {
position: fixed;
bottom: 0;
left: 0;
width: 100%;
background: var(--bg);
border-top: 1px solid color-mix(in srgb, var(--text) 12%, transparent);
box-shadow: 0 -4px 12px rgba(2, 6, 23, .1);
z-index: 50;
}
</style>