mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-11 20:15:32 +08:00
feat(classroom): first-run nudge to continue after a readback
After the very first readback is checked, highlight the footer's primary button (pulse + "Readback checked — continue here" bubble) so learners realise they advance from there. Shown once, then dismissed permanently via localStorage on the first advance. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1196,16 +1196,22 @@
|
|||||||
<v-icon size="18">mdi-dice-5</v-icon>
|
<v-icon size="18">mdi-dice-5</v-icon>
|
||||||
New scenario
|
New scenario
|
||||||
</button>
|
</button>
|
||||||
<button
|
<div class="mission-footer-primary-wrap">
|
||||||
class="btn primary mission-footer-primary"
|
<div v-if="showNextHint" class="next-coach" role="note">
|
||||||
type="button"
|
<v-icon size="16">mdi-arrow-down</v-icon>
|
||||||
:class="missionFooterPrimary.mode"
|
Readback checked — continue here
|
||||||
:disabled="missionFooterPrimary.disabled"
|
</div>
|
||||||
@click="missionFooterPrimary.action()"
|
<button
|
||||||
>
|
class="btn primary mission-footer-primary"
|
||||||
<v-icon size="18">{{ missionFooterPrimary.icon }}</v-icon>
|
type="button"
|
||||||
{{ missionFooterPrimary.label }}
|
:class="[missionFooterPrimary.mode, { 'is-coaching': showNextHint }]"
|
||||||
</button>
|
:disabled="missionFooterPrimary.disabled"
|
||||||
|
@click="missionFooterPrimary.action()"
|
||||||
|
>
|
||||||
|
<v-icon size="18">{{ missionFooterPrimary.icon }}</v-icon>
|
||||||
|
{{ missionFooterPrimary.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2012,6 +2018,11 @@ if (typeof window !== 'undefined') {
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
applyRouteStateFromQuery(route.query as RouteQueryLike)
|
applyRouteStateFromQuery(route.query as RouteQueryLike)
|
||||||
|
try {
|
||||||
|
nextHintSeen.value = window.localStorage.getItem(NEXT_HINT_STORAGE_KEY) === '1'
|
||||||
|
} catch {
|
||||||
|
nextHintSeen.value = true
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const manualCallsignPreview = computed(() => {
|
const manualCallsignPreview = computed(() => {
|
||||||
@@ -2714,6 +2725,23 @@ const userAnswers = reactive<Record<string, string>>({})
|
|||||||
const readbackFieldRefs = new Map<string, HTMLInputElement>()
|
const readbackFieldRefs = new Map<string, HTMLInputElement>()
|
||||||
const result = ref<ScoreResult | null>(null)
|
const result = ref<ScoreResult | null>(null)
|
||||||
const evaluating = ref(false)
|
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 ttsLoading = ref(false)
|
||||||
const isSpeaking = ref(false)
|
const isSpeaking = ref(false)
|
||||||
const audioElement = ref<HTMLAudioElement | null>(null)
|
const audioElement = ref<HTMLAudioElement | null>(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(() => {
|
const lessonAnswerSignature = computed(() => {
|
||||||
if (!activeLesson.value) return ''
|
if (!activeLesson.value) return ''
|
||||||
return activeLesson.value.fields
|
return activeLesson.value.fields
|
||||||
@@ -3972,6 +4009,7 @@ function goToPreviousLesson() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function goToNextLesson() {
|
function goToNextLesson() {
|
||||||
|
dismissNextHint()
|
||||||
const meta = nextLessonMeta.value
|
const meta = nextLessonMeta.value
|
||||||
if (meta) {
|
if (meta) {
|
||||||
activeLesson.value = meta.lesson
|
activeLesson.value = meta.lesson
|
||||||
@@ -4046,6 +4084,9 @@ function evaluate() {
|
|||||||
if (!summary) return
|
if (!summary) return
|
||||||
result.value = summary
|
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 modId = current.value.id
|
||||||
const lesId = activeLesson.value.id
|
const lesId = activeLesson.value.id
|
||||||
if (!progress.value[modId]) progress.value[modId] = {}
|
if (!progress.value[modId]) progress.value[modId] = {}
|
||||||
@@ -6870,6 +6911,56 @@ onBeforeUnmount(() => {
|
|||||||
justify-content: center;
|
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 {
|
.mission-footer-primary.is-skip {
|
||||||
background: color-mix(in srgb, var(--text) 8%, transparent);
|
background: color-mix(in srgb, var(--text) 8%, transparent);
|
||||||
color: var(--t2);
|
color: var(--t2);
|
||||||
|
|||||||
Reference in New Issue
Block a user