From 084ff860fa2d7a45dfe5abc377a23cfe112dc52a Mon Sep 17 00:00:00 2001 From: itsrubberduck Date: Tue, 26 May 2026 10:37:33 +0200 Subject: [PATCH] Group readback label and field so they wrap together Detlef reported that the prompt for a blank (e.g. "runway") often ended up on the line above the input itself, forcing him to look back up to remember what he was typing. Each preceding text segment is now paired with its following input field into a `.cloze-group`. The group uses `inline-flex; flex-wrap: nowrap` so its label and input stay on one line, while the outer `.cloze` container still wraps groups normally. On narrow viewports (<640px) the group falls back to wrapping internally so it never overflows. Co-Authored-By: Claude Opus 4.7 --- app/pages/classroom.vue | 113 ++++++++++++++++++++++++++++------------ 1 file changed, 79 insertions(+), 34 deletions(-) diff --git a/app/pages/classroom.vue b/app/pages/classroom.vue index e9de6d3..dad4463 100644 --- a/app/pages/classroom.vue +++ b/app/pages/classroom.vue @@ -1058,38 +1058,40 @@
Your readback
- +
+ +
@@ -1423,7 +1425,7 @@ import { altitudeToWords, minutesToWords } from '~~/shared/learn/scenario' -import type {BlankWidth, Frequency, Lesson, LessonField, ModuleDef, Scenario} from '~~/shared/learn/types' +import type {BlankWidth, Frequency, Lesson, LessonField, ModuleDef, ReadbackSegment, 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' @@ -3376,6 +3378,33 @@ const correctReadbackText = computed(() => { }).join('').trim() }) +// Pair each input field with its preceding text label so they wrap together +// as a single visual unit — otherwise the label (e.g. "runway") can land on +// one line while the input lands on the next, forcing the user to look up to +// remember what they're typing. Reported by Detlef (FSC e.V.). +type ClozeGroup = { id: string; segments: ReadbackSegment[] } +const clozeGroups = computed(() => { + if (!activeLesson.value) return [] + const segments = activeLesson.value.readback + const groups: ClozeGroup[] = [] + let i = 0 + while (i < segments.length) { + const seg = segments[i]! + const next = segments[i + 1] + if (seg.type === 'text' && next && next.type === 'field') { + groups.push({ id: `g-${next.key}`, segments: [seg, next] }) + i += 2 + } else if (seg.type === 'field') { + groups.push({ id: `g-${seg.key}`, segments: [seg] }) + i += 1 + } else { + groups.push({ id: `g-t-${i}`, segments: [seg] }) + i += 1 + } + } + return groups +}) + async function speakCorrectReadback() { const text = correctReadbackText.value if (!text || ttsLoading.value) return @@ -6605,12 +6634,28 @@ onMounted(() => { display: flex; flex-wrap: wrap; gap: 10px; - align-items: stretch; + align-items: flex-start; line-height: 1.4; text-transform: uppercase; letter-spacing: .08em; } +/* Each label/text + its input field travel together as one wrap-unit so the + user always sees the prompt next to the blank they're filling. */ +.cloze-group { + display: inline-flex; + flex-wrap: nowrap; + gap: 10px; + align-items: stretch; + max-width: 100%; +} + +@media (max-width: 640px) { + .cloze-group { + flex-wrap: wrap; + } +} + .cloze-chunk { display: inline-flex; align-items: center;