mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-05 08:55:54 +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>
|
||||
New scenario
|
||||
</button>
|
||||
<button
|
||||
class="btn primary mission-footer-primary"
|
||||
type="button"
|
||||
:class="missionFooterPrimary.mode"
|
||||
:disabled="missionFooterPrimary.disabled"
|
||||
@click="missionFooterPrimary.action()"
|
||||
>
|
||||
<v-icon size="18">{{ missionFooterPrimary.icon }}</v-icon>
|
||||
{{ missionFooterPrimary.label }}
|
||||
</button>
|
||||
<div class="mission-footer-primary-wrap">
|
||||
<div v-if="showNextHint" class="next-coach" role="note">
|
||||
<v-icon size="16">mdi-arrow-down</v-icon>
|
||||
Readback checked — continue here
|
||||
</div>
|
||||
<button
|
||||
class="btn primary mission-footer-primary"
|
||||
type="button"
|
||||
:class="[missionFooterPrimary.mode, { 'is-coaching': showNextHint }]"
|
||||
:disabled="missionFooterPrimary.disabled"
|
||||
@click="missionFooterPrimary.action()"
|
||||
>
|
||||
<v-icon size="18">{{ missionFooterPrimary.icon }}</v-icon>
|
||||
{{ missionFooterPrimary.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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<Record<string, string>>({})
|
||||
const readbackFieldRefs = new Map<string, HTMLInputElement>()
|
||||
const result = ref<ScoreResult | null>(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<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(() => {
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user