Skip to content

Commit 8038eb0

Browse files
jsollycursoragent
andauthored
fix(quiz): keep answer viewport stable with sticky-bar verdict (#38)
Stop auto-scrolling to the explanation panel after answering. Surface Correct!/Not quite (and tier verdicts) in the sticky bottom bar with aria-live, mark the picked option with Your answer, and only scrollIntoView(nearest) the selection when needed. Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: John Solly <jsolly@users.noreply.github.com>
1 parent e9f7d40 commit 8038eb0

1 file changed

Lines changed: 42 additions & 17 deletions

File tree

src/components/app/QuizView.svelte

Lines changed: 42 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
let counts = $derived(game.progressCount);
1818
let pct = $derived(counts.total === 0 ? 0 : Math.round((counts.done / counts.total) * 100));
1919
let feedbackStatusEl: HTMLParagraphElement | null = null;
20+
let pickedOptionEl: HTMLButtonElement | null = null;
2021
let lastOutcome = $derived(game.outcomes[game.outcomes.length - 1]);
2122
let missed = $derived(Boolean(answered && lastOutcome && !lastOutcome.cleared));
2223
let studyChapters = $derived(q && missed ? chaptersForQuestion(q.id) : []);
@@ -40,6 +41,13 @@
4041
};
4142
}
4243
44+
function capturePickedOption(element: HTMLButtonElement) {
45+
pickedOptionEl = element;
46+
return () => {
47+
if (pickedOptionEl === element) pickedOptionEl = null;
48+
};
49+
}
50+
4351
let pickedOption = $derived(
4452
q && game.answeredOptionId ? q.options.find((o) => o.id === game.answeredOptionId) : undefined,
4553
);
@@ -62,8 +70,10 @@
6270
6371
// Visual state for one option once the question is answered (quiz mode).
6472
// 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.
6574
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";
6777
if (!answered) {
6878
return `${base} border-border bg-card hover:border-primary/50 hover:bg-muted/50 active:scale-[0.99]`;
6979
}
@@ -83,7 +93,11 @@
8393
const questionId = q?.id;
8494
game.answer(optionId);
8595
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+
}
87101
}
88102
89103
function verdict(): { label: string; tone: Tone } | undefined {
@@ -98,7 +112,9 @@
98112
</script>
99113

100114
{#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+
>
102118
<!-- header -->
103119
<div class="flex items-center gap-3">
104120
<Button variant="ghost" size="sm" class="shrink-0 px-2" aria-label="Exit session" onclick={() => game.exitSession()}>
@@ -143,10 +159,17 @@
143159
class={optionClass(opt)}
144160
disabled={answered}
145161
onclick={() => answer(opt.id)}
162+
{@attach answered && opt.id === game.answeredOptionId && capturePickedOption}
146163
>
147164
<span class="flex items-start gap-3">
148165
<span class="text-base leading-6">{opt.text}</span>
149166
</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}
150173
{#if answered && (q.scoring === "tiered" || q.scoring === "reveal-tradeoff") && opt.tier}
151174
<span class={`mt-2 block text-xs font-semibold ${TONE_CLASS[TIER_VERDICT[opt.tier].tone]}`}>
152175
{TIER_VERDICT[opt.tier].label}
@@ -161,17 +184,7 @@
161184

162185
{#if answered}
163186
<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>
175188
<div class="mt-3 flex flex-col gap-2">
176189
<a
177190
href={q.sourceUrl}
@@ -198,13 +211,25 @@
198211
</div>
199212
{/if}
200213

201-
<!-- sticky action bar -->
214+
<!-- sticky action bar — primary feedback lives here after answering -->
202215
<div class="fixed inset-x-0 bottom-0 border-t bg-background/95 backdrop-blur">
203216
<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"}`}
205218
>
206219
{#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()}>
208233
{game.willFinishAfterNext ? "Finish" : "Next Question"}
209234
</Button>
210235
{:else}

0 commit comments

Comments
 (0)