@creaditor/cdtr-quiz-player
v0.6.0
Published
Embeddable <cdtr-quiz-player> web component. Draws a quiz authored in cdtr-course-builder, marks each answer, and grades the finished attempt against a passing mark. No networking, no storage, no identity: the host owns the gradebook and what a result mea
Readme
@creaditor/cdtr-quiz-player
The learner's half of a quiz authored in
@creaditor/cdtr-course-builder. It reads the stored
quiz shape, draws the questions, accepts one answer each and says whether it was
right.
It does not fetch, store, or know who the learner is. Every answer leaves as a
quiz-answered event, and the finished attempt leaves as quiz-completed with
a grade. That is the same division the builder uses: the component owns the
surface, the host owns the data.
<script type="module" src="https://unpkg.com/@creaditor/cdtr-quiz-player"></script>
<cdtr-quiz-player language="he"></cdtr-quiz-player>
<script type="module">
const player = document.querySelector('cdtr-quiz-player');
// The `props` of a `type: "quiz"` node, straight out of save-lesson-data.
player.questions = quizNode.props;
player.addEventListener('quiz-answered', (e) => {
// { questionId, optionId, result: 'correct' | 'incorrect' | 'ungraded' }
console.log(e.detail);
});
</script>What it takes
| | |
|---|---|
| player.questions = … | A quiz node's props, or the bare questions array. Setting it clears any answers already given. |
| player.props = … | Alias, for hosts that spread a node's props. |
| language="he" \| "en" | Learner-facing copy and text direction. Defaults to Hebrew, matching the builder. |
| player.questions (read) | The parsed, drawable questions. |
| player.dropped (read) | How many authored questions were too malformed to draw. |
| passing-grade="70" | The mark, 0–100, the learner must reach. Absent means a score and no verdict. |
| display-mode="all-at-once" \| "one-at-a-time" | Every question on one page, or one at a time. Defaults to all-at-once, and so does anything unrecognised. |
| player.score (read) | The running QuizScore. Readable mid-attempt. |
| player.passingGrade (read) | The parsed mark, or null. |
| player.displayMode (read) | The parsed layout mode. |
| props.intro | Instructions shown on their own screen before the questions. Optional. |
| props.showAnswers | false hides the right answer after a wrong one. Defaults to showing. |
| kind: "open" on a question | The learner writes an answer instead of picking one. Never graded. |
What it emits
Both events bubble and are composed, so a host can delegate from a container.
quiz-answered, once per question — answering is one-shot, and the question
locks afterwards:
{ questionId: 'q_48c9574f', optionIds: ['o_178416ec'], result: 'correct' }optionIds is always an array — one entry for a single-answer question — so a
host reading the event never has to branch on the question type.
result: 'ungraded' means the authored question marked no correct answer at
all; the learner could still pick, and no verdict was shown.
Choose all that apply
Mark more than one option correct: true and the question becomes a
multiple-answer one on its own. No extra flag:
- The learner gets checkboxes instead of radios, and a "choose all that apply" line.
- It is marked right for exactly the correct set. No partial credit: half of a set is a different answer, not most of the right one, and awarding it half a mark is a grading policy the creator never set.
- After answering, every right option is marked, not only the one picked — the learner needs to see the whole set they were aiming at.
- It counts as one gradable question, however many boxes it has.
The input type does tell a learner whether to look for one answer or several. That is a non-issue here for the reason stated under What this is not: the correct answers are already in page memory.
quiz-completed, once, when the last question is answered — after that
question's own quiz-answered, so a host sees them in the order the learner
lived them:
{
answered: 4, total: 4, // every drawn question
gradable: 3, correct: 3, // of those, the ones that CAN be scored
percent: 100, // correct out of gradable
complete: true,
passed: true, // null when there is no verdict to give
passingGrade: 70,
answers: [ /* every pick, in the order the questions were drawn */ ]
}One question at a time
display-mode="one-at-a-time" shows a single question, a "Question 3 of 8"
counter, and a Next button that appears once the question has been answered.
player.setAttribute('display-mode', 'one-at-a-time');It is the creator's layout choice, not the learner's, and it changes nothing a
host sees: the same quiz-answered per question, the same quiz-completed
after the last one, the same score.
- Forward only. Answering is one-shot — the inputs lock as soon as a question is checked — so a Back button would offer nothing to do but re-read, while implying an edit that is not on offer.
- Next appears only after the question is answered, and never on the last one, where the summary is what follows.
- Feedback still lands per question. When the verdict appears is a separate policy from how many questions are on screen; both modes check one question at a time.
- Anything unrecognised means
all-at-once. Forgiving in the direction that keeps content visible: a misspelt attribute shows the whole quiz, rather than windowing it to one question and looking like content that went missing. - Focus follows the learner — to the verdict after Check, to the new question after Next. Every action repaints the shadow tree, so without it the keyboard would fall to the body and Next would be reachable only by tabbing in from the top of your page.
Instructions before the questions
If the quiz node's props carry an intro string, the player opens on it —
the text, and a Start button — instead of the first question.
player.questions = { questions: [...], intro: 'Read carefully — you get one attempt.' };- Only when the creator wrote one. No
intro, whitespace-only, or a non-string, and there is no screen: the quiz opens on question 1 exactly as it did before this existed. - It replaces the questions, it does not sit above them. Pressing Start is what makes it an acknowledgement rather than something scrolled past.
- Both display modes.
one-at-a-timeshows it before the counter. - Never enforced. Start records that the learner pressed a button, nothing more. "You get one attempt" is a rule only you can hold them to, because only you know who is watching.
- Not persisted. A reload shows it again, the same way a reload already
returns a
one-at-a-timelearner to question 1.
Setting questions again — a host swapping lessons — makes the instructions
unread again, for the same reason it clears the answers.
Grading a whole attempt
Set passing-grade and the player scores the attempt, shows the learner where
they landed, and reports it. It still decides nothing: whether a failed quiz
blocks the lesson, allows a retry, or notifies a teacher is the host's rule.
player.setAttribute('passing-grade', '70');
player.addEventListener('quiz-completed', (e) => {
myBackend.saveGrade(lessonId, e.detail.percent); // YOUR gradebook
if (e.detail.passed) markLessonComplete(lessonId); // YOUR rule
});Four things it is deliberate about:
gradableis separate fromtotal. A question naming no single correct answer cannot be scored, so it is kept out of the denominator — otherwise a flawless learner reads 3/4 because of the creator's authoring mistake. It still draws, still takes a pick, and still has to be answered before the attempt is complete.passedisnull, notfalse, until there is a verdict to give: no pass mark set, nothing gradable, or the attempt unfinished. A quiz in progress has not been failed.- A pass mark is a percentage, not a question count.
70%survives the creator adding a seventh question; "5 of 6" quietly becomes wrong. - A quiz with nothing gradable says so in words rather than showing 0%, which a learner reads as "I got everything wrong".
The mark is read as a percentage 0–100. Anything else — blank, unparseable, out of range — means no pass mark rather than a mark of zero.
The shape it reads
Documented in full under The stored quiz shape in the builder's README. The part this package binds to:
{
"type": "quiz",
"props": {
"questions": [
{
"id": "q_48c9574f",
"text": "What is the capital of France?",
"options": [
{ "id": "o_959bda38", "text": "Lyon", "correct": false },
{ "id": "o_178416ec", "text": "Paris", "correct": true }
]
}
]
}
}That JSON is the coupling between the two packages — not a TypeScript import. This package depends on nothing at all, which is what lets it drop into a renderer without dragging an authoring tool and its editor bundle behind it.
It expects to be handed broken data
The builder enforces 2–6 options, exactly one correct, and non-blank text — but only while a creator is typing, and explicitly not on data that reached it from somewhere else. By the time a quiz arrives here it has been through a database and a network hop, so every field is read defensively:
- A question that cannot be drawn is counted in
dropped, never thrown. - Malformed options are dropped individually; the question survives if two usable ones remain.
- Duplicate question or option ids collapse — answers are reported by id, and duplicates would make them unattributable.
correctmust be exactlytrue. A stringified"false"from a database round-trip does not mark an option correct.- Zero correct answers is the only ungradable state, and the question still draws rather than hiding the creator's work from a learner. Two or more is a choose-all-that-apply question, not a broken one.
parseQuiz, gradeAnswer, scoreQuiz and readPassingGrade are exported if
you want the logic without the element — scoreQuiz is what a host would call
to grade server-side against answers it collected itself. They are pure, and
their tests run with no DOM in scope.
What this is not
Not an assessment. The verdict is computed in the browser, so the correct answer is on the client and anyone can read it out of memory. That is fine for practice and formative feedback. A grade that counts has to be scored on a server against answers the client never sees — a different contract, deliberately not attempted here.
A score is now computed across questions, and it is still not an assessment for the reason above: the marking happens on the client.
Also absent, and belonging to a host or a later milestone: retries, saving a grade, one-question-at-a-time reveal, and anything that remembers a learner between visits.
Development
npm install
npm test # 105 tests, node + happy-dom in one run
npm run dev # preview.html on :5182
npm run buildpreview.html carries three fixtures — a well-formed quiz, deliberately
half-broken data, and an empty one — plus a live log of every event the
component emits.
