Skip to content

Commit f2487ae

Browse files
feat(exercises): one panel at a time on a phone, columns on desktop (#583)
On a 390px screen the thing a student came to do was 1.4 screens down. The artifact page put three links to *other* exercises at 530px, ahead of the exercise itself, and the essay page buried a 500px coach box with its own scrollbar under an 875px wall of instructions. Below `lg` the brief, the task and the last result are now one panel at a time behind a segmented control. From `lg` up they lay out as the two columns they already were, with the result moved above the work it refers to instead of spanning a row it only filled a third of. Both layouts render one tree. The panels are shown and hidden with CSS rather than mounted and unmounted, so switching cannot drop the coach conversation, the artifact iframe's state, or a half-typed answer. They are not ARIA tabs: at `lg` every panel is visible at once, which is not a valid tablist, and roles cannot be varied by media query, so it is a toggle group whose buttons stay individually reachable by Tab. Which panel opens first depends on what the student has: a graded attempt they did not pass opens the result, anyone who has attempted before goes to the work, a first-timer reads the brief. Same rule the lesson checkpoints use, and a fresh grade pulls the student to the result rather than landing it in a panel they cannot see. Also fixed on the way through, all measured in-page against a tenant that overrides the primary hue: - 10 WCAG AA failures, worst at 2.13:1. `-500` fills and `-600` text do not clear AA on white; `-700 dark:-400` does. Small text is out of `text-primary` entirely, since tenants override it and no ratio can be promised. The difficulty badge alone failed at 2.94:1 in four separate copies of the same table, now one `ExerciseHeader`. - `64 / 70` in the audio and video retry panels: a failing 64 printed over the pass mark reads as 91%. Out of 100, as everywhere else. - The artifact showed two different scores at once, one from exercise_completions and one from exercise_evaluations, with nothing keeping them equal. Closes #583
1 parent 84b22dc commit f2487ae

13 files changed

Lines changed: 870 additions & 707 deletions

File tree

app/[locale]/dashboard/student/courses/[courseId]/exercises/[exerciseId]/page.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -267,7 +267,8 @@ export default async function ExercisePage({ params }: PageProps) {
267267
const t = await getTranslations('exercises.audio')
268268
const otherExercisesSection = otherExercises && otherExercises.length > 0 ? (
269269
<>
270-
<h3 className="text-xs font-bold uppercase tracking-widest text-muted-foreground/70 mb-3">{t('moreExercises')}</h3>
270+
{/* Full-strength muted, not /70: the faded variant measured 2.76:1. */}
271+
<h3 className="text-xs font-bold uppercase tracking-widest text-muted-foreground mb-3">{t('moreExercises')}</h3>
271272
<div className="grid gap-3">
272273
{otherExercises.map((ex) => (
273274
<ExerciseCard
@@ -409,6 +410,7 @@ export default async function ExercisePage({ params }: PageProps) {
409410
studentId={userId}
410411
isExerciseCompletedSection={otherExercisesSection}
411412
resultSummary={resultSummary}
413+
resultPassed={latestEvaluation?.passed}
412414
>
413415
{chatComponent}
414416
</EssayExercise>
Lines changed: 100 additions & 198 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,15 @@
11
'use client'
22

33
import { useState, useRef, useCallback, useEffect } from 'react'
4-
import { Badge } from '@/components/ui/badge'
54
import { Button } from '@/components/ui/button'
65
import ExerciseResultSummary from '@/components/exercises/exercise-result-summary'
7-
import { cn } from '@/lib/utils'
8-
import Markdown from 'react-markdown'
6+
import ExerciseBrief from '@/components/exercises/exercise-brief'
7+
import ExerciseHeader from '@/components/exercises/exercise-header'
8+
import ExerciseWorkspace, { initialWorkspacePanel } from '@/components/exercises/exercise-workspace'
99
import { useTranslations } from 'next-intl'
1010
import confetti from 'canvas-confetti'
1111
import { toast } from 'sonner'
12-
import {
13-
IconCheck,
14-
IconClock,
15-
IconFlame,
16-
IconInfoCircle,
17-
IconSparkles,
18-
IconLoader2,
19-
IconAlertTriangle,
20-
IconArrowRight,
21-
IconTrophy,
22-
IconCode,
23-
} from '@tabler/icons-react'
12+
import { IconLoader2, IconAlertTriangle, IconArrowRight } from '@tabler/icons-react'
2413

2514
interface ArtifactExerciseProps {
2615
exercise: {
@@ -64,7 +53,7 @@ export default function ArtifactExercise({
6453
initialEvaluation = null,
6554
}: ArtifactExerciseProps) {
6655
const t = useTranslations('exercises.artifact')
67-
const tAudio = useTranslations('exercises.audio')
56+
const tWorkspace = useTranslations('exercises.workspace')
6857
const tGamification = useTranslations('gamification')
6958

7059
const config = exercise.exercise_config ?? {}
@@ -76,25 +65,8 @@ export default function ArtifactExercise({
7665
const [passed, setPassed] = useState<boolean>(isExerciseCompleted)
7766
const [errorMsg, setErrorMsg] = useState<string | null>(null)
7867
const [rateLimited, setRateLimited] = useState(false)
79-
80-
const completionData = exercise.exercise_completions?.[0]
81-
const completionScore = completionData?.score ?? (evaluation?.score || 0)
82-
const completionDate = completionData?.completed_at
83-
84-
const difficultyLabels: Record<string, string> = {
85-
easy: tAudio('beginner'),
86-
medium: tAudio('intermediate'),
87-
hard: tAudio('advanced'),
88-
}
89-
const difficultyConfig: Record<string, { color: string; icon: typeof IconFlame }> = {
90-
easy: { color: 'text-emerald-600 bg-emerald-500/10 border-emerald-500/20', icon: IconSparkles },
91-
medium: { color: 'text-amber-600 bg-amber-500/10 border-amber-500/20', icon: IconFlame },
92-
hard: { color: 'text-rose-600 bg-rose-500/10 border-rose-500/20', icon: IconFlame },
93-
}
94-
95-
const difficulty = difficultyConfig[exercise.difficulty_level] || difficultyConfig.easy
96-
const difficultyLabel = difficultyLabels[exercise.difficulty_level] || difficultyLabels.easy
97-
const DifficultyIcon = difficulty.icon
68+
/** Bumped on every fresh grade so the workspace can surface the result panel. */
69+
const [gradedNonce, setGradedNonce] = useState(0)
9870

9971
const handleSubmit = useCallback(async (content: string, metadata: Record<string, unknown> = {}) => {
10072
setSubmitState('evaluating')
@@ -126,6 +98,7 @@ export default function ArtifactExercise({
12698
setEvaluation(result)
12799
setPassed(result.passed)
128100
setSubmitState('done')
101+
setGradedNonce((n) => n + 1)
129102

130103
// Send feedback back to iframe
131104
iframeRef.current?.contentWindow?.postMessage(
@@ -171,174 +144,103 @@ export default function ArtifactExercise({
171144
setEvaluation(null)
172145
}
173146

174-
return (
175-
<div className="space-y-6">
176-
{/* Header */}
177-
<div className="space-y-4">
178-
<div className="flex flex-wrap items-center gap-2">
179-
<Badge variant="outline" className="font-bold border-2 text-primary border-primary/20 bg-primary/5 uppercase tracking-wider text-[10px] px-2.5 py-0.5">
180-
<IconCode size={10} className="mr-1" aria-hidden="true" />
181-
{t('typeLabel')}
182-
</Badge>
183-
<Badge variant="outline" className={cn('font-bold border text-[10px] px-2.5 py-0.5 uppercase tracking-wider', difficulty.color)}>
184-
<DifficultyIcon size={11} className="mr-1" aria-hidden="true" />
185-
{difficultyLabel}
186-
</Badge>
187-
{exercise.time_limit && (
188-
<Badge variant="outline" className="font-bold border text-[10px] px-2.5 py-0.5 uppercase tracking-wider text-muted-foreground">
189-
<IconClock size={11} className="mr-1" aria-hidden="true" />
190-
{exercise.time_limit} min
191-
</Badge>
192-
)}
193-
{(isExerciseCompleted || passed) && (
194-
<Badge className="bg-emerald-500 text-white font-bold text-[10px] px-2.5 py-0.5 uppercase tracking-wider">
195-
<IconCheck size={11} className="mr-1" aria-hidden="true" />
196-
{tAudio('completed')}
197-
</Badge>
198-
)}
147+
const taskPanel = (
148+
<div className="space-y-4">
149+
{rateLimited && (
150+
<div className="rounded-xl border border-amber-500/30 bg-amber-500/[0.05] px-4 py-3">
151+
<p className="flex items-center gap-2.5 text-sm font-semibold text-amber-800 dark:text-amber-300">
152+
<IconAlertTriangle size={16} className="shrink-0" aria-hidden="true" />
153+
{t('rateLimited')}
154+
</p>
199155
</div>
200-
201-
<h1 className="text-2xl md:text-3xl font-black tracking-tight text-balance leading-tight">
202-
{exercise.title}
203-
</h1>
204-
</div>
205-
206-
{/* Main Layout */}
207-
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
208-
{/* Left: Instructions */}
209-
<div className="lg:col-span-4 space-y-6">
210-
<div className="rounded-2xl border-2 border-primary/10 bg-gradient-to-b from-primary/[0.03] to-transparent overflow-hidden">
211-
<div className="px-5 py-3.5 border-b border-primary/10 bg-primary/[0.03]">
212-
<h2 className="font-bold text-xs flex items-center gap-2 text-primary uppercase tracking-wider">
213-
<IconInfoCircle size={14} aria-hidden="true" />
214-
{tAudio('instructions')}
215-
</h2>
216-
</div>
217-
<div className="px-5 py-4">
218-
<div className="prose prose-sm prose-neutral max-w-none dark:prose-invert prose-p:leading-relaxed prose-p:text-foreground/80 prose-strong:text-foreground prose-headings:text-foreground prose-headings:font-bold prose-li:text-foreground/80 prose-headings:text-sm">
219-
<Markdown>{exercise.instructions}</Markdown>
220-
</div>
221-
</div>
222-
</div>
223-
224-
{isExerciseCompletedSection && (
225-
<div className="space-y-4">{isExerciseCompletedSection}</div>
226-
)}
156+
)}
157+
158+
{submitState === 'evaluating' && (
159+
<div
160+
className="flex items-center gap-3 rounded-xl border bg-muted/30 px-4 py-3"
161+
role="status"
162+
>
163+
<IconLoader2 size={16} className="animate-spin text-primary shrink-0" aria-hidden="true" />
164+
<p className="text-sm font-medium">{t('evaluating')}</p>
227165
</div>
166+
)}
228167

229-
{/* Right: Artifact + Results */}
230-
<div className="lg:col-span-8">
231-
<div className="lg:sticky lg:top-6 space-y-4">
232-
{/* Completion summary */}
233-
{(isExerciseCompleted || passed) && (
234-
<div className="rounded-2xl border-2 border-emerald-500/20 bg-gradient-to-br from-emerald-500/[0.06] to-emerald-500/[0.02] p-6 space-y-4">
235-
<div className="flex items-center justify-between">
236-
<div>
237-
<h3 className="font-bold text-emerald-700 dark:text-emerald-400 flex items-center gap-2">
238-
<IconTrophy size={18} />
239-
{tAudio('completedScore')}
240-
</h3>
241-
{completionDate && (
242-
<p className="text-xs text-emerald-600/70 dark:text-emerald-400/60 mt-1">
243-
{tAudio('completedOn', {
244-
date: new Date(completionDate).toLocaleDateString(undefined, {
245-
month: 'long', day: 'numeric', year: 'numeric',
246-
}),
247-
})}
248-
</p>
249-
)}
250-
</div>
251-
<div className="flex items-baseline gap-1">
252-
<span className="text-4xl font-black tabular-nums tracking-tight text-emerald-600 dark:text-emerald-400">
253-
{Math.round(completionScore)}
254-
</span>
255-
<span className="text-lg font-bold text-emerald-600/40">/</span>
256-
<span className="text-lg font-bold text-emerald-600/60 tabular-nums">100</span>
257-
</div>
258-
</div>
259-
<p className="text-sm text-emerald-600/80 dark:text-emerald-400/70">
260-
{tAudio('exerciseMarkedComplete')}
261-
</p>
262-
</div>
263-
)}
264-
265-
{/* Rate limit banner */}
266-
{rateLimited && (
267-
<div className="rounded-xl border-2 border-amber-500/20 bg-amber-500/[0.05] p-5">
268-
<div className="flex items-center gap-3">
269-
<div className="rounded-full bg-amber-500/10 p-2">
270-
<IconAlertTriangle size={20} className="text-amber-600" />
271-
</div>
272-
<div>
273-
<h3 className="font-bold text-amber-700 dark:text-amber-400">{t('rateLimited')}</h3>
274-
</div>
275-
</div>
276-
</div>
277-
)}
278-
279-
{/* Evaluating state */}
280-
{submitState === 'evaluating' && (
281-
<div className="flex items-center gap-3 rounded-xl border bg-muted/30 px-4 py-3">
282-
<IconLoader2 size={16} className="animate-spin text-primary shrink-0" />
283-
<div>
284-
<p className="text-sm font-medium">{t('evaluating')}</p>
285-
</div>
286-
</div>
287-
)}
288-
289-
{/* Error */}
290-
{errorMsg && (
291-
<div className="rounded-lg border border-destructive/20 bg-destructive/5 px-4 py-3 text-sm text-destructive">
292-
{errorMsg}
293-
</div>
294-
)}
295-
296-
{/* Sandboxed iframe */}
297-
{artifactHtml && (
298-
<div className="rounded-xl border-2 border-border/50 overflow-hidden bg-white">
299-
<iframe
300-
ref={iframeRef}
301-
srcDoc={artifactHtml}
302-
sandbox="allow-scripts"
303-
className="w-full border-0"
304-
style={{ minHeight: '500px' }}
305-
title={exercise.title}
306-
/>
307-
</div>
308-
)}
309-
310-
{/* Evaluation result — also shown for a restored prior attempt, whose
311-
submitState is still 'idle' because nothing was submitted this visit. */}
312-
{/* One result card across every engine. This block used to be a
313-
near-copy of ExerciseResultSummary that printed the score over
314-
the PASS MARK, twice: "62 / 70" reads as 89% when 62 is a fail. */}
315-
{evaluation && submitState !== 'evaluating' && (
316-
<div className="space-y-4">
317-
<ExerciseResultSummary
318-
score={evaluation.score}
319-
passed={evaluation.passed}
320-
feedback={evaluation.feedback}
321-
strengths={evaluation.strengths}
322-
improvements={evaluation.improvements}
323-
// The API response carries its own threshold; the prop is the
324-
// fallback for a restored attempt that predates that field.
325-
passingScore={evaluation.passingScore ?? passingScore}
326-
/>
327-
328-
{!evaluation.passed && !rateLimited && (
329-
<Button
330-
onClick={handleTryAgain}
331-
className="w-full gap-2.5 h-11 text-sm font-semibold tracking-wide"
332-
>
333-
{t('tryAgain')}
334-
<IconArrowRight size={16} className="ml-auto opacity-60" />
335-
</Button>
336-
)}
337-
</div>
338-
)}
339-
</div>
168+
{errorMsg && (
169+
<div className="rounded-lg border border-destructive/20 bg-destructive/5 px-4 py-3 text-sm text-destructive" role="alert">
170+
{errorMsg}
171+
</div>
172+
)}
173+
174+
{artifactHtml && (
175+
// The srcDoc is authored as a light-mode document, so the frame keeps a
176+
// white backing in both themes rather than showing a dark seam round it.
177+
<div className="rounded-xl border overflow-hidden bg-white">
178+
<iframe
179+
ref={iframeRef}
180+
srcDoc={artifactHtml}
181+
sandbox="allow-scripts"
182+
className="w-full border-0"
183+
style={{ minHeight: '500px' }}
184+
title={exercise.title}
185+
/>
340186
</div>
187+
)}
188+
</div>
189+
)
190+
191+
// One result card across every engine. This block used to be a near-copy of
192+
// ExerciseResultSummary that printed the score over the PASS MARK, alongside
193+
// a second trophy card printing a *different* score from exercise_completions.
194+
const resultPanel =
195+
evaluation && submitState !== 'evaluating' ? (
196+
<div className="space-y-4">
197+
<ExerciseResultSummary
198+
score={evaluation.score}
199+
passed={evaluation.passed}
200+
feedback={evaluation.feedback}
201+
strengths={evaluation.strengths}
202+
improvements={evaluation.improvements}
203+
// The API response carries its own threshold; the prop is the
204+
// fallback for a restored attempt that predates that field.
205+
passingScore={evaluation.passingScore ?? passingScore}
206+
/>
207+
208+
{!evaluation.passed && !rateLimited && (
209+
<Button
210+
onClick={handleTryAgain}
211+
className="w-full gap-2.5 h-11 text-sm font-semibold tracking-wide"
212+
>
213+
{t('tryAgain')}
214+
<IconArrowRight size={16} className="ml-auto opacity-60" />
215+
</Button>
216+
)}
341217
</div>
218+
) : undefined
219+
220+
return (
221+
<div className="space-y-4 sm:space-y-6">
222+
<ExerciseHeader
223+
typeLabel={t('typeLabel')}
224+
title={exercise.title}
225+
difficulty={exercise.difficulty_level}
226+
timeLimit={exercise.time_limit}
227+
completed={isExerciseCompleted || passed}
228+
/>
229+
230+
<ExerciseWorkspace
231+
brief={<ExerciseBrief instructions={exercise.instructions} />}
232+
task={taskPanel}
233+
taskLabel={tWorkspace('task')}
234+
result={resultPanel}
235+
resultPassed={evaluation?.passed}
236+
related={isExerciseCompletedSection}
237+
initialPanel={initialWorkspacePanel({
238+
hasResult: Boolean(resultPanel),
239+
passed: evaluation?.passed,
240+
attempted: isExerciseCompleted || Boolean(initialEvaluation),
241+
})}
242+
revealResultNonce={gradedNonce}
243+
/>
342244
</div>
343245
)
344246
}

0 commit comments

Comments
 (0)