diff --git a/app/pages/classroom.vue b/app/pages/classroom.vue index ecf68a3..13c0a28 100644 --- a/app/pages/classroom.vue +++ b/app/pages/classroom.vue @@ -1196,16 +1196,22 @@ mdi-dice-5 New scenario - + @@ -2012,6 +2018,11 @@ if (typeof window !== 'undefined') { onMounted(() => { applyRouteStateFromQuery(route.query as RouteQueryLike) + try { + nextHintSeen.value = window.localStorage.getItem(NEXT_HINT_STORAGE_KEY) === '1' + } catch { + nextHintSeen.value = true + } }) const manualCallsignPreview = computed(() => { @@ -2714,6 +2725,23 @@ const userAnswers = reactive>({}) const readbackFieldRefs = new Map() const result = ref(null) const evaluating = ref(false) + +// First-run nudge: after the very first readback is checked, highlight the +// footer's primary button so learners realise they continue from there. Shown +// once, then dismissed permanently. +const NEXT_HINT_STORAGE_KEY = 'os_classroom_next_hint_seen' +const nextHintSeen = ref(true) +const nextHintArmed = ref(false) + +function dismissNextHint() { + nextHintArmed.value = false + if (nextHintSeen.value) return + nextHintSeen.value = true + if (typeof window === 'undefined') return + try { + window.localStorage.setItem(NEXT_HINT_STORAGE_KEY, '1') + } catch { /* ignore */ } +} const ttsLoading = ref(false) const isSpeaking = ref(false) const audioElement = ref(null) @@ -3793,6 +3821,15 @@ const missionFooterPrimary = computed(() => { } }) +// Only nudge when the primary button actually advances (post-readback) and the +// learner hasn't seen the hint before. +const showNextHint = computed(() => + nextHintArmed.value + && !nextHintSeen.value + && missionFooterPrimary.value.mode === 'is-next' + && !missionFooterPrimary.value.disabled +) + const lessonAnswerSignature = computed(() => { if (!activeLesson.value) return '' return activeLesson.value.fields @@ -3972,6 +4009,7 @@ function goToPreviousLesson() { } function goToNextLesson() { + dismissNextHint() const meta = nextLessonMeta.value if (meta) { activeLesson.value = meta.lesson @@ -4046,6 +4084,9 @@ function evaluate() { if (!summary) return result.value = summary + // Arm the first-run "continue from here" nudge on the footer button. + if (!nextHintSeen.value) nextHintArmed.value = true + const modId = current.value.id const lesId = activeLesson.value.id if (!progress.value[modId]) progress.value[modId] = {} @@ -6870,6 +6911,56 @@ onBeforeUnmount(() => { justify-content: center; } +/* First-run nudge toward the "continue" button after a readback. */ +.mission-footer-primary-wrap { + position: relative; + display: inline-flex; +} + +.mission-footer-primary.is-coaching { + animation: next-coach-pulse 1.5s ease-in-out infinite; +} + +@keyframes next-coach-pulse { + 0%, 100% { + box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 55%, transparent); + } + 50% { + box-shadow: 0 0 0 8px color-mix(in srgb, var(--accent) 0%, transparent); + } +} + +.next-coach { + position: absolute; + bottom: calc(100% + 10px); + right: 0; + display: inline-flex; + align-items: center; + gap: 6px; + white-space: nowrap; + padding: 8px 12px; + border-radius: 12px; + border: 1px solid color-mix(in srgb, var(--accent) 45%, transparent); + background: color-mix(in srgb, var(--accent) 16%, var(--bg)); + color: var(--accent); + font-size: 12px; + font-weight: 600; + box-shadow: 0 12px 30px rgba(2, 6, 23, .35); + animation: next-coach-in .25s ease both; +} + +@keyframes next-coach-in { + from { opacity: 0; transform: translateY(6px); } + to { opacity: 1; transform: translateY(0); } +} + +@media (prefers-reduced-motion: reduce) { + .mission-footer-primary.is-coaching { + animation: none; + box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 45%, transparent); + } +} + .mission-footer-primary.is-skip { background: color-mix(in srgb, var(--text) 8%, transparent); color: var(--t2);