From f2487ae50c51fcaf497ee59145dac3802b2a7b49 Mon Sep 17 00:00:00 2001 From: Guillermo Marin <52298929+guillermoscript@users.noreply.github.com> Date: Tue, 28 Jul 2026 20:21:10 +0200 Subject: [PATCH] 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 --- .../exercises/[exerciseId]/page.tsx | 4 +- components/exercises/artifact-exercise.tsx | 298 +++++--------- components/exercises/audio-exercise.tsx | 370 ++++++++---------- components/exercises/breadcrumb-component.tsx | 6 +- components/exercises/essay-exercise.tsx | 122 ++---- components/exercises/exercise-brief.tsx | 38 ++ components/exercises/exercise-header.tsx | 100 +++++ components/exercises/exercise-workspace.tsx | 196 ++++++++++ components/exercises/speech-feedback.tsx | 24 +- components/exercises/video-exercise.tsx | 354 ++++++++--------- messages/en.json | 8 + messages/es.json | 8 + tests/unit/exercise-workspace-panel.test.ts | 49 +++ 13 files changed, 870 insertions(+), 707 deletions(-) create mode 100644 components/exercises/exercise-brief.tsx create mode 100644 components/exercises/exercise-header.tsx create mode 100644 components/exercises/exercise-workspace.tsx create mode 100644 tests/unit/exercise-workspace-panel.test.ts diff --git a/app/[locale]/dashboard/student/courses/[courseId]/exercises/[exerciseId]/page.tsx b/app/[locale]/dashboard/student/courses/[courseId]/exercises/[exerciseId]/page.tsx index 7d7fe654..992eeb14 100644 --- a/app/[locale]/dashboard/student/courses/[courseId]/exercises/[exerciseId]/page.tsx +++ b/app/[locale]/dashboard/student/courses/[courseId]/exercises/[exerciseId]/page.tsx @@ -267,7 +267,8 @@ export default async function ExercisePage({ params }: PageProps) { const t = await getTranslations('exercises.audio') const otherExercisesSection = otherExercises && otherExercises.length > 0 ? ( <> -

{t('moreExercises')}

+ {/* Full-strength muted, not /70: the faded variant measured 2.76:1. */} +

{t('moreExercises')}

{otherExercises.map((ex) => ( {chatComponent} diff --git a/components/exercises/artifact-exercise.tsx b/components/exercises/artifact-exercise.tsx index 0479b2ca..bbf20d17 100644 --- a/components/exercises/artifact-exercise.tsx +++ b/components/exercises/artifact-exercise.tsx @@ -1,26 +1,15 @@ 'use client' import { useState, useRef, useCallback, useEffect } from 'react' -import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import ExerciseResultSummary from '@/components/exercises/exercise-result-summary' -import { cn } from '@/lib/utils' -import Markdown from 'react-markdown' +import ExerciseBrief from '@/components/exercises/exercise-brief' +import ExerciseHeader from '@/components/exercises/exercise-header' +import ExerciseWorkspace, { initialWorkspacePanel } from '@/components/exercises/exercise-workspace' import { useTranslations } from 'next-intl' import confetti from 'canvas-confetti' import { toast } from 'sonner' -import { - IconCheck, - IconClock, - IconFlame, - IconInfoCircle, - IconSparkles, - IconLoader2, - IconAlertTriangle, - IconArrowRight, - IconTrophy, - IconCode, -} from '@tabler/icons-react' +import { IconLoader2, IconAlertTriangle, IconArrowRight } from '@tabler/icons-react' interface ArtifactExerciseProps { exercise: { @@ -64,7 +53,7 @@ export default function ArtifactExercise({ initialEvaluation = null, }: ArtifactExerciseProps) { const t = useTranslations('exercises.artifact') - const tAudio = useTranslations('exercises.audio') + const tWorkspace = useTranslations('exercises.workspace') const tGamification = useTranslations('gamification') const config = exercise.exercise_config ?? {} @@ -76,25 +65,8 @@ export default function ArtifactExercise({ const [passed, setPassed] = useState(isExerciseCompleted) const [errorMsg, setErrorMsg] = useState(null) const [rateLimited, setRateLimited] = useState(false) - - const completionData = exercise.exercise_completions?.[0] - const completionScore = completionData?.score ?? (evaluation?.score || 0) - const completionDate = completionData?.completed_at - - const difficultyLabels: Record = { - easy: tAudio('beginner'), - medium: tAudio('intermediate'), - hard: tAudio('advanced'), - } - const difficultyConfig: Record = { - easy: { color: 'text-emerald-600 bg-emerald-500/10 border-emerald-500/20', icon: IconSparkles }, - medium: { color: 'text-amber-600 bg-amber-500/10 border-amber-500/20', icon: IconFlame }, - hard: { color: 'text-rose-600 bg-rose-500/10 border-rose-500/20', icon: IconFlame }, - } - - const difficulty = difficultyConfig[exercise.difficulty_level] || difficultyConfig.easy - const difficultyLabel = difficultyLabels[exercise.difficulty_level] || difficultyLabels.easy - const DifficultyIcon = difficulty.icon + /** Bumped on every fresh grade so the workspace can surface the result panel. */ + const [gradedNonce, setGradedNonce] = useState(0) const handleSubmit = useCallback(async (content: string, metadata: Record = {}) => { setSubmitState('evaluating') @@ -126,6 +98,7 @@ export default function ArtifactExercise({ setEvaluation(result) setPassed(result.passed) setSubmitState('done') + setGradedNonce((n) => n + 1) // Send feedback back to iframe iframeRef.current?.contentWindow?.postMessage( @@ -171,174 +144,103 @@ export default function ArtifactExercise({ setEvaluation(null) } - return ( -
- {/* Header */} -
-
- - - - - {exercise.time_limit && ( - - - )} - {(isExerciseCompleted || passed) && ( - - - )} + const taskPanel = ( +
+ {rateLimited && ( +
+

+

- -

- {exercise.title} -

-
- - {/* Main Layout */} -
- {/* Left: Instructions */} -
-
-
-

-

-
-
-
- {exercise.instructions} -
-
-
- - {isExerciseCompletedSection && ( -
{isExerciseCompletedSection}
- )} + )} + + {submitState === 'evaluating' && ( +
+
+ )} - {/* Right: Artifact + Results */} -
-
- {/* Completion summary */} - {(isExerciseCompleted || passed) && ( -
-
-
-

- - {tAudio('completedScore')} -

- {completionDate && ( -

- {tAudio('completedOn', { - date: new Date(completionDate).toLocaleDateString(undefined, { - month: 'long', day: 'numeric', year: 'numeric', - }), - })} -

- )} -
-
- - {Math.round(completionScore)} - - / - 100 -
-
-

- {tAudio('exerciseMarkedComplete')} -

-
- )} - - {/* Rate limit banner */} - {rateLimited && ( -
-
-
- -
-
-

{t('rateLimited')}

-
-
-
- )} - - {/* Evaluating state */} - {submitState === 'evaluating' && ( -
- -
-

{t('evaluating')}

-
-
- )} - - {/* Error */} - {errorMsg && ( -
- {errorMsg} -
- )} - - {/* Sandboxed iframe */} - {artifactHtml && ( -
-