// coursebank — individual diagnostic template // // This file is a template, not generated output. `coursebank report students` // replaces only the marked regions below and leaves every other line exactly as // you wrote it, so this is where layout decisions belong. // // coursebank template dump --variant student-report // typst watch templates/student-report.typ // // Two markers are in play: // // // coursebank:begin meta course, assessment, how many sat it, policy // // coursebank:end meta // // coursebank:begin data one student's diagnostic // // coursebank:end data // // Both regions ship with sample values, so `typst watch` works before any report // has been generated. // // What is not in the payload: the questions. There is no stem field and no option // text field, on any question, in any configuration. A student report is handed // back before the makeup exam is given, and a report that reproduces the paper // cannot be. If you find yourself wanting to print the question, print its number // and let the student read it off their own copy. // // Two fields appear only when you ask for them at the command line, because both // trade a student's understanding against reusing the question: `misconception` // (`--misconceptions`) and `worked` (`--solutions`). The blocks that print them // are below and cost nothing when the fields are absent. // // The prose in this file is addressed to a nineteen-year-old reading their own // result, alone, possibly disappointed. It explains every number before showing // it. If you change one thing here, keep that. #import "@preview/mitex:0.2.7": mi #import "@preview/whalogen:0.3.0": ce // ───────────────────────────────────────────────────────────────────────────── // Data // ───────────────────────────────────────────────────────────────────────────── // coursebank:begin meta #let cb-meta = ( course: (code: "COURSE 101", title: "Sample Course", term: "2026f"), assessment: (id: "sample", title: "Sample assessment", date: "2026-01-01"), generator: (tool: "coursebank", version: "0.0.0", on: "2026-01-02"), policy: (mastery-threshold: 0.75, min-items-for-mastery: 2), students-tested: 24, class-size: 24, extra: (:), ) // coursebank:end meta // coursebank:begin data #let cb-data = ( student-key: "s-000000000000", name: "Sample Student", email: "sample@example.edu", form: "A", score: (points: 27.0, possible: 36.0, percent: 75.0, bonus: 1.0, correct: 27, items: 36), standing: (class-mean: 71.2, class-sd: 11.4, band: "upper half"), levels: ( ( level: 1, name: "Remember", blurb: "recalling terms, facts, and definitions", items: 6, rate: 1.0, class-rate: 0.91, comparison: "above the class", ), ( level: 3, name: "Apply", blurb: "using a procedure in a new situation", items: 9, rate: 0.56, class-rate: 0.64, comparison: "below the class", ), ), objectives: ( ( id: "lo-sample-met", text: [A sample objective this student met.], items: 3, credit: 3.0, rate: 1.0, lower: 0.44, upper: 1.0, class-rate: 0.81, status: "meeting", symbol: "✓", confident: false, thin-evidence: false, levels: (1, 2), ), ( id: "lo-sample-gap", text: [A sample objective to work on.], items: 3, credit: 1.0, rate: 0.33, lower: 0.06, upper: 0.79, class-rate: 0.58, status: "not yet", symbol: "✗", confident: false, thin-evidence: false, levels: (3,), ), ), strengths: ((id: "lo-sample-met", text: [A sample objective this student met.], rate: 1.0, items: 3),), focus: ((id: "lo-sample-gap", text: [A sample objective to work on.], rate: 0.33, items: 3),), questions: ( ( number: 1, level: 1, objectives: ("lo-sample-met",), objective-texts: (), correct: true, credit: 1.0, bonus: false, dropped: false, blank: false, class-rate: 0.91, taught-in: (), review: (), ), ( number: 2, level: 3, objectives: ("lo-sample-gap",), objective-texts: ([A sample objective to work on.],), correct: false, credit: 0.0, bonus: false, blank: false, class-rate: 0.58, feedback: [This is the note written for the option that was chosen.], hint: [This is the question you would ask someone reconsidering that option.], taught-in: ("Enthalpy (L1.1), slides 12, 13",), review: ((citation: "KKW §6.2", title: "Molecules and Medicine", url: "https://example.edu/6/2"),), ), ), dropped-questions: ((number: 35, full-credit: true),), review-lectures: ( ( lecture: "L1.1", title: "Enthalpy", objectives-missed: 2, questions-missed: 3, questions: (2, 14, 15), slides: (12, 13), objectives: ([A sample objective to work on.], [A second one from the same lecture.]), ), ), study: ( ( objective: "lo-sample-gap", text: [A sample objective to work on.], rate: 0.33, readings: ( ( citation: "KKW §6.2", lecture: "L1.1", lecture-title: "Enthalpy", focus: [What to take from this section.], supplemental: false, ), ), ), ), ) // coursebank:end data // ───────────────────────────────────────────────────────────────────────────── // Settings // ───────────────────────────────────────────────────────────────────────────── #let extra = cb-meta.at("extra", default: (:)) #let accent = rgb(extra.at("accent", default: "#1f4e79")) #let body-font = extra.at("font", default: "Roboto") #let body-size = eval(extra.at("font-size", default: "10pt")) #let paper = extra.at("paper", default: "us-letter") // A five-step type scale. Sizes are picked from this list rather than invented at // the call site, which is what stops a document from drifting into a dozen // slightly different smalls. #let size-tag = 0.6em // the level tag inside a question box #let size-micro = 0.72em // column heads, badges, legends #let size-meta = 0.9em // provenance: lecture ids, citations, dates #let size-small = 0.88em // table cells, objective bullets, callouts #let size-lead = 0.94em // the explanation under each heading #let size-name = 1.1em // the student's name #let size-h2 = 1.02em #let size-h1 = 1.15em #let size-display = 1.4em // the three numbers at the top #let size-title = 1.5em // One vertical step. Every gap inside a list entry is this or a stated multiple // of it, and the entries themselves are separated by `entry-gap`. Uneven rhythm // in a document like this comes from mixing `v()`, linebreaks, and Typst's // default paragraph spacing in the same block; a stack plus one step avoids all // three. #let step = 1.2em #let entry-gap = 1.0em // Prose is held to a readable measure instead of spanning the full text block. // At 10pt across 17.8cm a line runs to about a hundred characters, which is // roughly a third too long to track comfortably. Tables and the question grid // still use the whole width, which is what they are for. #let prose-pad = 1.5cm // The same right edge for prose that already sits in a gutter, so a note body // lines up with the explanation above it. One centimetre is the 2.8em of number // column plus gutter at the default body size. #let prose-pad-inset = prose-pad - 1cm // Turn sections off from templates/typst.yaml rather than by deleting code, so // a course that does not want the question map keeps the rest of this file. #let show-question-map = extra.at("question-map", default: true) #let show-feedback = extra.at("feedback", default: true) #let show-study = extra.at("study-plan", default: true) #let show-comparison = extra.at("comparison", default: true) #let show-lecture-plan = extra.at("lecture-plan", default: true) #let show-item-readings = extra.at("item-readings", default: true) #let show-intro = extra.at("intro", default: true) #let threshold = cb-meta.policy.at("mastery-threshold", default: 0.75) #let n-tested = cb-meta.at("students-tested", default: cb-meta.at("class-size", default: 0)) #let ok-color = rgb("#2a9d8f") #let mid-color = rgb("#D19F1F") #let bad-color = rgb("#E24E29") #let thin-color = luma(150) #set page( paper: paper, margin: (x: 1.9cm, y: 2.1cm), header: text(size: size-meta, fill: luma(110))[ #cb-meta.course.code · #cb-meta.assessment.title · individual diagnostic ], footer: context text(size: size-meta, fill: luma(110))[ #h(1fr) Page #counter(page).display("1 of 1", both: true) ], ) #set text(font: body-font, size: body-size, lang: "en") #set par(justify: false, leading: 0.62em) // Figures in the tables are columns of numbers, so they get tabular widths and // line up under one another. #show table: set text(number-width: "tabular") #show heading.where(level: 1): it => block(above: 1.5em, below: entry-gap)[ #text(size: size-h1, weight: "bold", fill: accent)[#it.body] #v(-0.45em) #line(length: 100%, stroke: 0.6pt + accent.lighten(55%)) ] #show heading.where(level: 2): it => block(above: 1em, below: 0.45em)[ #text(size: size-h2, weight: "bold")[#it.body] ] // ───────────────────────────────────────────────────────────────────────────── // Helpers // ───────────────────────────────────────────────────────────────────────────── #let markup(v) = if type(v) == str { eval(v, mode: "markup") } else { v } // A rate is a fraction in 0..1; a percent is already out of 100. Keeping the two // straight is the only arithmetic this template does. #let pct(rate) = str(calc.round(rate * 100)) + "%" #let pct1(value) = str(calc.round(value, digits: 0)) + "%" #let plural(n, one, many) = if n == 1 { one } else { many } #let status-color(status) = { if status == "meeting" { ok-color } else if status == "developing" { mid-color } else if status == "not yet" { bad-color } else { thin-color } } #let rate-color(rate) = { if rate >= threshold { ok-color } else if rate >= threshold * 0.6 { mid-color } else { bad-color } } #let badge(label, color) = box( fill: color.lighten(82%), radius: 3pt, inset: (x: 5pt, y: 2.5pt), )[#text(size: size-micro, weight: "bold", fill: color.darken(12%))[#label]] // A column head. One definition rather than the same three arguments repeated at // every header cell, so the heads cannot drift apart. #let th(body) = text(size: size-micro, fill: luma(95), tracking: 0.04em)[#body] // A numbered disc for a ranked list. Fixed width, so the text of every entry // starts at the same place no matter whether the rank is 1 or 11. #let rank(n, color) = box( width: 1.35em, height: 1.35em, radius: 50%, fill: color, )[#align(center + horizon)[#text(size: size-micro, weight: "bold", fill: white)[#str(n)]]] // An aside with a rule down its left edge: the action to take, set apart from the // explanation above it without another box or another tint. #let callout(name, body) = block( inset: (left: 0.65em), stroke: (left: 1.5pt + accent.lighten(55%)), )[ #text(size: size-small)[#text(weight: "bold", fill: luma(75))[#name:] #body] ] // A provenance line: where something was taught, or what to read. #let meta-pair(name, body) = text(size: size-meta, fill: luma(115))[ #text(weight: "bold")[#name:] #body ] // Two boxes side by side rather than an overlay: no `place`, no coordinate // arithmetic, and it degrades to something sensible at any width. #let bar(rate, color: accent, width: 3.6cm) = { let r = calc.max(0.0, calc.min(1.0, rate)) box(baseline: 0.15em)[ #stack( dir: ltr, box(width: width * r, height: 0.62em, fill: color, radius: (left: 2pt)), box(width: width * (1.0 - r), height: 0.62em, fill: luma(232), radius: (right: 2pt)), ) ] } // The three cards at the top. The label sits above the number and the gloss // below it, so the eye lands on the figure and can then read outwards. #let stat-card(label, value, note: none) = { let parts = ( text(size: size-micro, fill: luma(95), tracking: 0.06em)[#upper(label)], text(size: size-display, weight: "bold", fill: accent)[#value], ) if note != none { parts.push(text(size: size-meta, fill: luma(95))[#note]) } block( height: 9em, width: 100%, fill: luma(247), radius: 4pt, inset: (x: 10pt, y: 9pt), )[#stack(dir: ttb, spacing: step * 1.0, ..parts)] } // The explanation under a heading. Every section has one, because a number a // student cannot interpret is worse than no number at all. #let explain(body) = block(below: entry-gap)[ #pad(right: prose-pad)[#text(size: size-lead, fill: luma(95))[#body]] ] // ───────────────────────────────────────────────────────────────────────────── // Heading // ───────────────────────────────────────────────────────────────────────────── #block(below: 0.35em)[ #text(size: size-title, weight: "bold")[#cb-meta.assessment.title] #h(0.6em) #text(fill: luma(110))[ #cb-meta.course.code · #cb-meta.course.title ] ] #let student-name = cb-data.at("name", default: none) #let student-email = cb-data.at("email", default: none) #let student-sid = cb-data.at("sid", default: none) #block(below: entry-gap)[ #stack( dir: ttb, spacing: step * 0.5, text(size: size-name, weight: "bold")[ #if student-name != none { student-name } else { cb-data.student-key } ], { // Identity on its own line, and only what the export actually carried. A // label with nothing after it reads like a mistake. let parts = () if student-email != none { parts.push(student-email) } if student-sid != none { parts.push(student-sid) } let form = cb-data.at("form", default: none) if form != none { parts.push("Form " + form) } let date = cb-meta.assessment.at("date", default: none) if date != none { parts.push(date) } if parts.len() > 0 { text(size: size-meta, fill: luma(110))[#parts.join(" · ")] } }, ) ] #if show-intro [ #block( width: 100%, fill: accent.lighten(95%), radius: 4pt, inset: (x: 11pt, y: 10pt), below: 1.2em, )[ #pad(right: prose-pad - 1.1cm)[ #text(size: size-lead)[ This map explains what the exam covered and what your results mean, so you can use the information. Your score appears first because that's what you probably want to see. After that, you'll find more helpful details: which types of questions you did well on, which topics you might want to review, and what to read for each area. The last two sections are the most practical, so if you only read part of this, focus on those. This report does not judge your abilities, and one question alone does not say much. If a score is based on just one or two questions, the report will point that out so you don't read too much into it. ] ] ] ] // ───────────────────────────────────────────────────────────────────────────── // Score // ───────────────────────────────────────────────────────────────────────────── #let score = cb-data.score #let standing = cb-data.at("standing", default: none) #grid( columns: (1fr, 1fr, 1fr), gutter: 10pt, stat-card( "your score", pct1(score.percent), note: str(score.points) + " of " + str(score.possible) + " points" + (if score.at("bonus", default: 0.0) > 0.0 { " (+" + str(score.bonus) + " bonus)" } else { "" }), ), stat-card( "questions right", str(score.correct) + " / " + str(score.items), note: "out of " + str(score.items) + " " + plural(score.items, "question", "questions"), ), if standing != none and show-comparison { stat-card( "class average", pct1(standing.class-mean), note: "across the " + str(n-tested) + " students who took this exam · you are in the " + standing.band, ) } else { stat-card( "students tested", str(n-tested), note: "took this exam", ) }, ) #let dropped-questions = cb-data.at("dropped-questions", default: ()) #if dropped-questions.len() > 0 [ #block(above: entry-gap)[ #pad(right: prose-pad)[ #text(size: size-lead)[ #{ // Two kinds of drop, and they need different sentences. A credited // question is still in the denominator, so telling a student it was // removed would not match the arithmetic they can do themselves. let credited = dropped-questions.filter(d => d.at("full-credit", default: false)) let removed = dropped-questions.filter(d => not d.at("full-credit", default: false)) let numbers = list => list.map(d => str(d.number)).join(", ") if credited.len() > 0 [ #plural(credited.len(), "Question", "Questions") #numbers(credited) #plural(credited.len(), "was", "were") thrown out after the exam. Everyone received full credit for #plural(credited.len(), "it", "them"), so #plural(credited.len(), "it is", "they are") still counted in the score above and whatever you chose made no difference. ] if removed.len() > 0 [ #plural(removed.len(), "Question", "Questions") #numbers(removed) #plural(removed.len(), "was", "were") thrown out and removed from scoring, so your percentage is out of the remaining questions. Nothing you wrote on #plural(removed.len(), "it", "them") counted either way. ] } ] ] ] ] // ───────────────────────────────────────────────────────────────────────────── // Levels // ───────────────────────────────────────────────────────────────────────────── #if cb-data.levels.len() > 0 [ = How you did, by kind of thinking #explain[ Each question on this exam was designed to test a specific type of thinking, from recalling definitions to analyzing situations. The table below breaks down your results by these types. The number in brackets shows how many questions of each type you answered. 'You' shows the percentage you got right, and 'class' shows the average for everyone who took the exam. Focus on the overall pattern, not just the specific numbers. If you did well on recall but struggled with applied questions, try practicing more problems. This is different from just reviewing the material. If your results are similar across all levels, it may be helpful to review the material itself. ] // The figure comes before its bar in both tables on this page, so the numbers // read as a column and the bars all start from the same left edge. #table( columns: (auto, 1fr, auto, 2.7cm, auto), stroke: none, align: (left + horizon, left + horizon, right + horizon, left + horizon, right + horizon), inset: (x: 5pt, y: 6pt), fill: (_, row) => if calc.odd(row) { luma(250) } else { white }, table.header(th[LEVEL], th[WHAT IT ASKS FOR], th[YOU], th[], th[CLASS]), ..cb-data .levels .map(level => ( [*#level.name* #text(size: size-meta, fill: luma(120))[(#level.items)]], text(size: size-small, fill: luma(80))[#level.blurb], text(weight: "medium")[#pct(level.rate)], bar(level.rate, color: rate-color(level.rate), width: 2.4cm), { let class-rate = level.at("class-rate", default: none) if class-rate != none and show-comparison { text(size: size-small, fill: luma(100))[#pct(class-rate)] } else { [] } }, )) .flatten(), ) ] // ───────────────────────────────────────────────────────────────────────────── // Objectives // ───────────────────────────────────────────────────────────────────────────── #if cb-data.objectives.len() > 0 [ = What the exam measured, objective by objective #explain[ Each line shows something the course asked you to do, just as it appears in the syllabus. *Q* tells you how many questions measured that skill. *You* shows the share you got right, and *class* shows the same for everyone else. The symbol in the first column is a summary. The mark you should focus on is #text(fill: thin-color, weight: "bold")[?], which means there were not enough questions to draw any conclusions about that line. On exams with many objectives, most lines will have this mark, since one question cannot show if you really know something or just guessed. These lines are not good or bad news. Instead, look for groups of lines that point in the same direction, and check the next two sections, which organize them for you. ] // The objective text is the only thing in this table that wants width, so it // takes the free column and everything else is sized to its content. The // thin-evidence badge that used to sit inline is gone: it repeated on every // row of a three-page table, wrapped the text, and said no more than the `?` // in the first column already says. #table( columns: (auto, 1fr, auto, auto, 2.4cm, auto), stroke: none, align: (center + horizon, left + horizon, right + horizon, right + horizon, left + horizon, right + horizon), inset: (x: 5pt, y: 6pt), fill: (_, row) => if calc.odd(row) { luma(250) } else { white }, table.header(th[], th[OBJECTIVE], th[Q], th[YOU], th[], th[CLASS]), ..cb-data .objectives .map(objective => ( text(fill: status-color(objective.status), weight: "bold")[#objective.symbol], text(size: size-small)[#markup(objective.text)], text(size: size-small, fill: luma(110))[#objective.items], text(size: size-small, weight: "medium")[#pct(objective.rate)], bar(objective.rate, color: status-color(objective.status), width: 2.1cm), { let class-rate = objective.at("class-rate", default: none) if class-rate != none and show-comparison { text(size: size-small, fill: luma(100))[#pct(class-rate)] } else { [] } }, )) .flatten(), ) #v(0.5em) #text(size: size-micro, fill: luma(110))[ #text(fill: ok-color, weight: "bold")[✓] you have this · #text(fill: mid-color, weight: "bold")[~] getting there · #text(fill: bad-color, weight: "bold")[✗] not yet · #text(fill: thin-color, weight: "bold")[?] too few questions to say, which is every line measured by one question. A line counts as solid at #pct(threshold) or better. ] ] // ───────────────────────────────────────────────────────────────────────────── // Strengths and focus // ───────────────────────────────────────────────────────────────────────────── // The two panels are the same object twice, so they are one function. `rows` is // a list of (text, trailing) pairs; the trailing part is the rate, which only // the focus panel carries. #let panel(title, color, note, rows) = block( width: 100%, fill: color.lighten(93%), radius: 4pt, inset: 10pt, )[ #stack( dir: ttb, spacing: step, text(weight: "bold", fill: color.darken(15%))[#title], text(size: size-small, fill: luma(95))[#note], { set text(size: size-small) list( indent: 0pt, body-indent: 0.45em, spacing: step * 0.7, marker: text(fill: color.darken(5%))[·], ..rows, ) }, ) ] // ───────────────────────────────────────────────────────────────────────────── #let strengths = cb-data.at("strengths", default: ()) #let focus = cb-data.at("focus", default: ()) #if strengths.len() > 0 or focus.len() > 0 [ = Where your time will go furthest #explain[ These two lists are based on the table above. I left out the single-question lines, so what is left has enough evidence to support action. ] #grid( columns: (1fr, 1fr), gutter: 12pt, if focus.len() > 0 { panel( "Start here", bad-color, [Start by reviewing the material that is likely to change the most and is the hardest for you.], focus.map(objective => [ #markup(objective.text) #text(size: size-meta, fill: luma(110))[(#pct(objective.rate) of #objective.items)] ]), ) } else { [] }, if strengths.len() > 0 { panel( "Already solid", ok-color, [You showed these. Spend your review time elsewhere.], strengths.map(objective => markup(objective.text)), ) } else { [] }, ) ] // ───────────────────────────────────────────────────────────────────────────── // Which lecture to go back to // ───────────────────────────────────────────────────────────────────────────── // One ranked lecture. Three fixed columns: the rank, the body, the count. The // body is a stack, so the title, the provenance line, and the objectives are one // step apart and no paragraph contributes spacing of its own. #let lecture-entry(index, lecture) = { let count = lecture.at("objectives-missed", default: 0) let numbers = lecture.at("questions", default: ()) let slides = lecture.at("slides", default: ()) let objectives = lecture.at("objectives", default: ()) let parts = () parts.push({ let url = lecture.at("url", default: none) let title = text(weight: "bold")[#lecture.title] [ #(if url != none { link(url)[#title] } else { title }) #text(size: size-meta, fill: luma(115))[(#lecture.lecture)] ] }) // Slides and question numbers were two separate lines, one of them reached by // a linebreak and one by a block. Together on one line they are easier to skim // and the entry loses a ragged gap. let trail = () if slides.len() > 0 { trail.push(plural(slides.len(), "slide ", "slides ") + slides.map(str).join(", ")) } if numbers.len() > 0 { trail.push( "you missed " + plural(numbers.len(), "question ", "questions ") + numbers.map(str).join(", "), ) } if trail.len() > 0 { parts.push(text(size: size-meta, fill: luma(115))[#trail.join(" · ")]) } // A real list rather than a middot glued to the front of a paragraph, so the // second line of a long objective indents under the first instead of running // back to the margin. if objectives.len() > 0 { parts.push({ set text(size: size-small, fill: luma(80)) list( indent: 0pt, body-indent: 0.45em, spacing: step * 0.7, marker: text(fill: luma(165))[·], ..objectives.map(objective => markup(objective)), ) }) } block(breakable: false, above: entry-gap, width: 100%)[ #grid( columns: (1.35em, 1fr, 5.2em), column-gutter: 0.7em, align: (left + top, left + top, right + top), rank(index + 1, if index == 0 { bad-color } else if count > 1 { mid-color } else { accent }), pad(right: prose-pad-inset)[#stack(dir: ttb, spacing: step, ..parts)], text(size: size-micro, fill: luma(105))[ #count #plural(count, "objective", "objectives") ], ) ] } // ───────────────────────────────────────────────────────────────────────────── #let review-lectures = cb-data.at("review-lectures", default: ()) #if show-lecture-plan and review-lectures.len() > 0 [ = Which lectures to go back to #explain[ Each question connects to the lecture it came from. If you sort the lectures by how many different objectives were missed, you get a clear order to review, starting with the most challenging. When one lecture covers several objectives, it often means an early idea was unclear, and fixing that is usually the easiest way to help. ] #for (index, lecture) in review-lectures.enumerate() [ #lecture-entry(index, lecture) ] ] // ───────────────────────────────────────────────────────────────────────────── // Study plan // ───────────────────────────────────────────────────────────────────────────── #let study = cb-data.at("study", default: ()) #if show-study and study.len() > 0 [ = What to read #explain[ Here are the sections from the course reading list that match the objectives above. Under each section, you'll find a brief sentence on what to focus on, so you don't need to reread the entire section. ] #for group in study [ #block(breakable: false, above: entry-gap)[ === #markup(group.text) #for reading in group.readings [ #block(inset: (left: 0.8em), above: step)[ #{ let url = reading.at("url", default: none) let cite = text(weight: "bold")[#reading.citation] let parts = ( [ #(if url != none { link(url)[#cite] } else { cite }) #text(size: size-meta, fill: luma(110))[ · #reading.lecture-title (#reading.lecture)#{ if reading.at("supplemental", default: false) { ", optional" } } ] ], ) let focus-note = reading.at("focus", default: none) if focus-note != none { parts.push(pad(right: prose-pad-inset)[ #text(size: size-small)[#markup(focus-note)] ]) } stack(dir: ttb, spacing: step * 0.6, ..parts) } ] ] ] ] ] // ───────────────────────────────────────────────────────────────────────────── // Question map // ───────────────────────────────────────────────────────────────────────────── #let questions = cb-data.at("questions", default: ()) #let question-box(q) = { let dropped = q.at("dropped", default: false) let color = if dropped { luma(130) } else if q.at("blank", default: false) { luma(160) } else if q.at("correct", default: false) == true { ok-color } else if q.at("credit", default: 0.0) > 0.0 { mid-color } else { bad-color } box( width: 100%, fill: color.lighten(85%), stroke: 0.5pt + color.lighten(45%), radius: 3pt, inset: (x: 2pt, y: 4pt), )[ #align(center)[ #text(size: size-small, weight: "bold", fill: color.darken(18%))[#q.number] #{ if dropped { linebreak() text(size: size-tag, fill: luma(110))[out] } else { let level = q.at("level", default: none) if level != none { linebreak() text(size: size-tag, fill: luma(120))[L#level] } } } ] ] } #if show-question-map and questions.len() > 0 [ #block(breakable: false)[ = Question by question #explain[ There is one box for each question, in the same order as on your exam paper. Each box is colored to show your result. The small *L* below each box shows the type of thinking required, matching the first table. The questions are not shown here, so use these numbers during office hours. ] #grid( columns: (1fr,) * 10, column-gutter: 4pt, row-gutter: 5pt, ..questions.map(question-box), ) #v(0.6em) #text(size: size-micro, fill: luma(110))[ #box(width: 0.7em, height: 0.7em, fill: ok-color.lighten(70%), radius: 2pt) right · #box(width: 0.7em, height: 0.7em, fill: mid-color.lighten(70%), radius: 2pt) part marks · #box(width: 0.7em, height: 0.7em, fill: bad-color.lighten(70%), radius: 2pt) not right · #box(width: 0.7em, height: 0.7em, fill: luma(210), radius: 2pt) left blank · #box(width: 0.7em, height: 0.7em, fill: luma(150), radius: 2pt) dropped, not scored ] ] ] // ───────────────────────────────────────────────────────────────────────────── // Notes on what was missed // ───────────────────────────────────────────────────────────────────────────── // One note. Four layers, in the order a student needs them: what the question // was measuring, what went wrong, what to ask next time, and where to look it // up. Each is a stack child, so every gap is one step and the provenance lines // at the end sit tight together as a single footer. #let note-entry(q) = { let parts = () let texts = q.at("objective-texts", default: ()) if texts.len() > 0 { parts.push(text(fill: luma(21.57%))[ #text(weight: "bold")[Measuring:] #texts.map(t => markup(t)).join([; ]) ]) } // The diagnosis carries full body size. It is the sentence worth reading // twice, and it was previously the same weight as the objective above it. let feedback = q.at("feedback", default: none) if feedback != none { parts.push(markup(feedback)) } let hint = q.at("hint", default: none) if hint != none { parts.push(callout("Try this", markup(hint))) } // Present only with `--misconceptions`. This one is written to the instructor // about the answer, which is why it reads differently. let misconception = q.at("misconception", default: none) if misconception != none { parts.push(callout("The idea this option tests for", markup(misconception))) } // Present only with `--solutions`. let worked = q.at("worked", default: none) if worked != none { parts.push(block( width: 100%, fill: luma(249), radius: 3pt, inset: (x: 8pt, y: 7pt), )[ #stack( dir: ttb, spacing: step * 0.5, text(size: size-meta, weight: "bold", fill: luma(85))[HOW IT WORKS OUT], text(size: size-small)[#markup(worked)], ) ]) } let trail = () let taught = q.at("taught-in", default: ()) if taught.len() > 0 { trail.push(meta-pair("Taught in", taught.join("; "))) } let review = q.at("review", default: ()) if show-item-readings and review.len() > 0 { let cites = review.map(reading => { let url = reading.at("url", default: none) let cite = reading.citation if url != none { link(url)[#cite] } else { [#cite] } }) trail.push(meta-pair("Read again", cites.join([ · ]))) } if trail.len() > 0 { parts.push(stack(dir: ttb, spacing: step * 0.4, ..trail)) } block(breakable: false, above: entry-gap, width: 100%)[ #grid( columns: (1.8em, 1fr), column-gutter: 0.7em, align: (right + top, left + top), text(weight: "bold", fill: accent)[#str(q.number)], pad(right: prose-pad-inset)[#stack(dir: ttb, spacing: step, ..parts)], ) ] } // ───────────────────────────────────────────────────────────────────────────── #let missed = questions.filter(q => ( q.at("credit", default: 0.0) < 0.999 and ( q.at("feedback", default: none) != none or q.at("hint", default: none) != none or q.at("worked", default: none) != none or q.at("review", default: ()).len() > 0 ) )) #if show-feedback and missed.len() > 0 [ = A closer look at the ones you missed #explain[ Each note below explains the reasoning behind your answer instead of just giving the correct one. It helps you see where things went off track so you can avoid the same mistake next time. You can also use your paper to compare your answer with the question. If you see a line starting with *try this*, it suggests a question to ask yourself as you review the problem. If a reading is listed, it shows which section it came from. ] #for q in missed [ #note-entry(q) ] ] // ───────────────────────────────────────────────────────────────────────────── // Footer note // ───────────────────────────────────────────────────────────────────────────── #v(1.3em) #line(length: 100%, stroke: 0.5pt + luma(210)) #v(0.45em) #pad(right: prose-pad)[#text(size: size-micro, fill: luma(120))[ Built on #cb-meta.generator.on by #cb-meta.generator.tool #cb-meta.generator.version, from the #str(n-tested) #plural(n-tested, "student", "students") who took this exam. Because a percentage based on one or two questions can be highly uncertain, bring anything here that looks wrong or surprising to office hours. That is the best use you can make of this page. ]]