|
17 | 17 | let counts = $derived(game.progressCount); |
18 | 18 | let pct = $derived(counts.total === 0 ? 0 : Math.round((counts.done / counts.total) * 100)); |
19 | 19 | let feedbackStatusEl: HTMLParagraphElement | null = null; |
| 20 | + let pickedOptionEl: HTMLButtonElement | null = null; |
20 | 21 | let lastOutcome = $derived(game.outcomes[game.outcomes.length - 1]); |
21 | 22 | let missed = $derived(Boolean(answered && lastOutcome && !lastOutcome.cleared)); |
22 | 23 | let studyChapters = $derived(q && missed ? chaptersForQuestion(q.id) : []); |
|
40 | 41 | }; |
41 | 42 | } |
42 | 43 |
|
| 44 | + function capturePickedOption(element: HTMLButtonElement) { |
| 45 | + pickedOptionEl = element; |
| 46 | + return () => { |
| 47 | + if (pickedOptionEl === element) pickedOptionEl = null; |
| 48 | + }; |
| 49 | + } |
| 50 | +
|
43 | 51 | let pickedOption = $derived( |
44 | 52 | q && game.answeredOptionId ? q.options.find((o) => o.id === game.answeredOptionId) : undefined, |
45 | 53 | ); |
|
62 | 70 |
|
63 | 71 | // Visual state for one option once the question is answered (quiz mode). |
64 | 72 | // Avoid font-weight changes — semibold shifts glyph widths and reflows the line. |
| 73 | + // scroll-mb keeps nearest scrollIntoView clear of the taller sticky feedback bar. |
65 | 74 | function optionClass(opt: QuizOption): string { |
66 | | - const base = "w-full rounded-2xl border-2 p-4 text-left transition-[transform,opacity] duration-150"; |
| 75 | + const base = |
| 76 | + "w-full scroll-mb-40 rounded-2xl border-2 p-4 text-left transition-[transform,opacity] duration-150"; |
67 | 77 | if (!answered) { |
68 | 78 | return `${base} border-border bg-card hover:border-primary/50 hover:bg-muted/50 active:scale-[0.99]`; |
69 | 79 | } |
|
83 | 93 | const questionId = q?.id; |
84 | 94 | game.answer(optionId); |
85 | 95 | await tick(); |
86 | | - if (questionId && game.currentQuestion?.id === questionId) feedbackStatusEl?.focus(); |
| 96 | + if (questionId && game.currentQuestion?.id === questionId) { |
| 97 | + // Keep the option list in view — announce via sticky-bar status, never jump to explanation. |
| 98 | + feedbackStatusEl?.focus({ preventScroll: true }); |
| 99 | + pickedOptionEl?.scrollIntoView({ block: "nearest", inline: "nearest", behavior: "smooth" }); |
| 100 | + } |
87 | 101 | } |
88 | 102 |
|
89 | 103 | function verdict(): { label: string; tone: Tone } | undefined { |
|
98 | 112 | </script> |
99 | 113 |
|
100 | 114 | {#if q && unit} |
101 | | - <div class="mx-auto flex min-h-[100dvh] w-full max-w-2xl flex-col px-4 pb-28 pt-4 sm:pt-6"> |
| 115 | + <div |
| 116 | + class={`mx-auto flex min-h-[100dvh] w-full max-w-2xl flex-col px-4 pt-4 sm:pt-6 ${answered ? "pb-40" : "pb-28"}`} |
| 117 | + > |
102 | 118 | <!-- header --> |
103 | 119 | <div class="flex items-center gap-3"> |
104 | 120 | <Button variant="ghost" size="sm" class="shrink-0 px-2" aria-label="Exit session" onclick={() => game.exitSession()}> |
|
143 | 159 | class={optionClass(opt)} |
144 | 160 | disabled={answered} |
145 | 161 | onclick={() => answer(opt.id)} |
| 162 | + {@attach answered && opt.id === game.answeredOptionId && capturePickedOption} |
146 | 163 | > |
147 | 164 | <span class="flex items-start gap-3"> |
148 | 165 | <span class="text-base leading-6">{opt.text}</span> |
149 | 166 | </span> |
| 167 | + {#if answered && opt.id === game.answeredOptionId} |
| 168 | + <span class="mt-2 flex items-center gap-1.5 text-xs text-foreground/80"> |
| 169 | + <span aria-hidden="true">✓</span> |
| 170 | + Your answer |
| 171 | + </span> |
| 172 | + {/if} |
150 | 173 | {#if answered && (q.scoring === "tiered" || q.scoring === "reveal-tradeoff") && opt.tier} |
151 | 174 | <span class={`mt-2 block text-xs font-semibold ${TONE_CLASS[TIER_VERDICT[opt.tier].tone]}`}> |
152 | 175 | {TIER_VERDICT[opt.tier].label} |
|
161 | 184 |
|
162 | 185 | {#if answered} |
163 | 186 | <div class="mt-5 rounded-2xl border bg-card p-4"> |
164 | | - {#if v} |
165 | | - <p |
166 | | - {@attach captureFeedbackStatus} |
167 | | - role="status" |
168 | | - aria-live="polite" |
169 | | - aria-atomic="true" |
170 | | - tabindex="-1" |
171 | | - class={`text-lg font-bold ${TONE_CLASS[v.tone]}`}>{v.label}</p |
172 | | - > |
173 | | - {/if} |
174 | | - <p class="mt-1 text-sm leading-6 text-foreground/90">{q.explanation}</p> |
| 187 | + <p class="text-sm leading-6 text-foreground/90">{q.explanation}</p> |
175 | 188 | <div class="mt-3 flex flex-col gap-2"> |
176 | 189 | <a |
177 | 190 | href={q.sourceUrl} |
|
198 | 211 | </div> |
199 | 212 | {/if} |
200 | 213 |
|
201 | | - <!-- sticky action bar --> |
| 214 | + <!-- sticky action bar — primary feedback lives here after answering --> |
202 | 215 | <div class="fixed inset-x-0 bottom-0 border-t bg-background/95 backdrop-blur"> |
203 | 216 | <div |
204 | | - class={`mx-auto flex w-full max-w-2xl items-center gap-3 px-4 py-3 ${answered ? "justify-center" : ""}`} |
| 217 | + class={`mx-auto flex w-full max-w-2xl px-4 py-3 ${answered ? "flex-col items-stretch gap-2" : "items-center gap-3"}`} |
205 | 218 | > |
206 | 219 | {#if answered} |
207 | | - <Button size="lg" class="w-full max-w-xs sm:w-auto sm:px-10" onclick={() => game.next()}> |
| 220 | + {#if v} |
| 221 | + <p |
| 222 | + {@attach captureFeedbackStatus} |
| 223 | + role="status" |
| 224 | + aria-live="polite" |
| 225 | + aria-atomic="true" |
| 226 | + tabindex="-1" |
| 227 | + class={`text-center text-base font-bold focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 sm:text-lg ${TONE_CLASS[v.tone]}`} |
| 228 | + > |
| 229 | + {v.label} |
| 230 | + </p> |
| 231 | + {/if} |
| 232 | + <Button size="lg" class="w-full max-w-xs self-center sm:w-auto sm:px-10" onclick={() => game.next()}> |
208 | 233 | {game.willFinishAfterNext ? "Finish" : "Next Question"} |
209 | 234 | </Button> |
210 | 235 | {:else} |
|
0 commit comments