Assessment Widget Conventions¶
These rules apply to every assessment widget — quizzes, checks, true/false, multiple choice, and timed-video tests. The goal is consistent learner UX, predictable authoring, and a clean migration path when new variants ship.
The reference implementation is packages/widget-renderers/src/components/assessment/true-or-false-widget.tsx (renderer) and apps/cms/src/components/widgets/assessment/true-or-false-widget.tsx (CMS editor).
1. Core UX rules¶
- Reset / "Try Again" is horizontally centered. Post-submit actions row uses
flex flex-col items-center gap-3(single column, centered). Pre-submit row usesflex flex-wrap items-center justify-center gap-3. The Reset button must never be left-aligned beside the score. - Every assessment exposes a
canTryAgainsetting —z.boolean().default(true)on the wire schema, surfaced as a<Switch>in the CMS editor, and read by the renderer to hide the Reset button when false. Defaulttruepreserves historical behavior for content authored before this rule. - Result pill is inline with the question. The Badge with
data-testid="widget-<type>-<questionId>-result"lives on the same flex row as the question text — never as a sibling block below the answer controls. - Preview surface indicates the correct answer. When
surface !== "lms-learner", the renderer must visually mark the correct answer's control with the success styling (see §2). Buttons remain disabled in preview.
2. Coloring¶
- Correct =
bg-success text-success-foreground hover:bg-successclassName override (on Badge or Button). There is nosuccessButton or Badge variant — always use the className override. - Incorrect =
variant="destructive"on Badge or Button (red is a first-class variant). - Score chip =
<Badge variant="secondary" data-testid="widget-<type>-score">. - Selected pre-submit =
variant="default". Unselected =variant="outline".
3. Renderer conventions¶
Files under packages/widget-renderers/src/components/assessment/:
- Wrap content in
<WidgetShell widget={widget} testId="widget-<type>">. - Coerce content with
asContent / asArray / asBoolean / asNumber / asStringfrom../shared/utils— never trust rawwidget.content. - Detect surface via
const isLearner = surface === "lms-learner"fromuseWidgetRenderContext(). - Show
<EmptyState … testId="widget-<type>-empty" />when no questions are authored. - Pull UI primitives from
useWidgetUi()and strings fromuseWidgetT(). Do not import shadcn components directly in renderer packages — they live behind the adapter for host portability. - Submit Button is disabled until every question is answered (
allAnswered). - After submit, every option control is disabled (
disabled={!isLearner || submitted}). - Each question wrapper carries
data-correct={submitted ? (isCorrect ? "true" : "false") : undefined}. - Hydration-safe randomize: shuffle inside
useEffect(..., [widget.id, randomize, questions.length]), gated on ashuffledOrderstate. Never callMath.random()during render. Keep the inlineshuffle()helper and the explanatory comment block.
4. CMS editor conventions¶
Files under apps/cms/src/components/widgets/assessment/:
- Default-export a component taking
WidgetProps. - Use
useWidgetContent<Content>(widget, contentSchema, onUpdate)— schemas imported from@/lib/validations/widget-content-schemas(re-exports from@open-learning-hub/widget-wire-schemas). - Translations via
useTranslations("widgetEditor.<type>")plususeTranslations("widgetEditor.common"). - Every author-facing string lives in all six locales:
en, de, es, fr, pt, zhunderapps/cms/messages/. Missing locales are a blocker. - Boolean settings render as
<Switch>inside one settings strip:<div className="flex items-center gap-6 rounded-lg bg-muted p-3">, each switch wrapped in<div className="flex items-center gap-2">. - Questions render in
<ItemAccordion testIdPrefix="widget-<type>-question">. - Add-question CTA = full-width outline Button with
<Plus />icon. - Title is rendered via a top
<Input>inside<CardHeader>bound towidget.title.
5. Wire schema & registry conventions¶
- All booleans declared as
z.boolean().default(<sensible default>). The default must preserve prior behavior for already-authored rows. - Required assessment booleans:
canTryAgain(defaulttrue). AddshowFeedback(defaulttrue) andrandomize(defaultfalse) when applicable to the widget's mechanics. defaultContentin the registry must explicitly mirror every schema default — don't rely on Zod parsing to fill them in at create-time.- The
schemamap must include every boolean as{ type: "boolean", label, default }.
6. Test coverage required¶
Per assessment widget:
- Wire schema (
apps/cms/src/lib/validations/__tests__/widget.test.ts): one "accepts valid content" case naming every field, plus one default-check per boolean. - CMS editor (
apps/cms/src/components/widgets/assessment/__tests__/<type>-widget.test.tsx): preview render, edit render, every Switch covered (default-checked + toggle assertion), add/delete-question flow, invalid-content fallback. - Renderer (
packages/widget-renderers/src/components/assessment/__tests__/<type>-widget.test.tsx): empty state, full learner flow (answer → submit → score → reset), feedback gated by surface, Reset hidden whencanTryAgain: false, preview surface highlights the correct answer.
7. data-testid naming¶
- Format:
widget-<type>-...for widget-scoped controls andwidget-<type>-<questionId>-...for per-question controls. - Always kebab-case, predictable, and unique within a render.
- Tests rely on these — do not rename or restructure a testid without updating every test that references it in the same commit.
8. Known gap¶
quick-questions-videocarries thecanTryAgainschema field for forward-compatibility but does not yet expose an interactive submit/reset/score flow. Until that flow is built, the post-submit conventions (centered actions, inline pill, Reset gating) cannot apply at runtime. When the flow is added, it must conform to this rule from the start.