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('rateLimited')} +
{t('evaluating')}
- {tAudio('completedOn', { - date: new Date(completionDate).toLocaleDateString(undefined, { - month: 'long', day: 'numeric', year: 'numeric', - }), - })} -
- )} -- {tAudio('exerciseMarkedComplete')} -
-{t('evaluating')}
-+ {t('topicPrompt')} +
++ {config.topic_prompt} +
++ + {t('dailyLimitReached')} +
++ {t('dailyLimitMessage', { limit: maxDaily })} +
- {t('topicPrompt')} -
-{config.topic_prompt}
+ {submitState === 'analyzing' && ( +{t('analyzing')}
+{t('analyzingDescription')}
+- {t('dailyLimitMessage', { limit: maxDaily })} -
-{t('analyzing')}
-{t('analyzingDescription')}
-{t('passedMessage')}
+{t('passedMessage')}
+
{t('notPassedMessage', { score: passingScore ?? 70 })}
{focus_next}
@@ -218,7 +218,7 @@ export function SpeechFeedback({ evaluation, onTryAgain, passed, passingScore, c diff --git a/components/exercises/video-exercise.tsx b/components/exercises/video-exercise.tsx index c3884ea0..08638978 100644 --- a/components/exercises/video-exercise.tsx +++ b/components/exercises/video-exercise.tsx @@ -4,14 +4,11 @@ import { useState, useCallback } from 'react' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { cn } from '@/lib/utils' -import Markdown from 'react-markdown' import { useTranslations } from 'next-intl' import confetti from 'canvas-confetti' import { IconCheck, IconClock, - IconFlame, - IconInfoCircle, IconVideo, IconSparkles, IconLoader2, @@ -24,6 +21,9 @@ import { } from '@tabler/icons-react' import { VideoRecorderComponent } from './video-recorder' import { SpeechFeedback } from './speech-feedback' +import ExerciseBrief from './exercise-brief' +import ExerciseHeader from './exercise-header' +import ExerciseWorkspace, { initialWorkspacePanel } from './exercise-workspace' import type { SpeechEvaluation } from '@/lib/speech/types' interface SubmissionHistoryItem { @@ -107,12 +107,14 @@ function RetryPanel({ score, passingScore, onRecordAgain }: { return (+ {t('topicPrompt')} +
++ {config.topic_prompt} +
+ + {t('dailyLimitReached')} +
++ {t('dailyLimitMessage', { limit: maxDaily })} +
+- {t('topicPrompt')} -
-{config.topic_prompt}
+ {submitState === 'analyzing' && ( +{t('analyzing')}
+{t('analyzingDescription')}
+- {t('dailyLimitMessage', { limit: maxDaily })} -
-{t('analyzing')}
-{t('analyzingDescription')}
-