explico 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (151) hide show
  1. package/CHANGELOG.md +344 -1
  2. package/README.md +117 -3
  3. package/dist/compiler/astro.d.ts +38 -0
  4. package/dist/compiler/astro.js +258 -0
  5. package/dist/compiler/cli.d.ts +13 -0
  6. package/dist/compiler/cli.js +32 -12
  7. package/dist/compiler/collections.d.ts +3 -2
  8. package/dist/compiler/collections.js +28 -1
  9. package/dist/compiler/course-config.d.ts +86 -4
  10. package/dist/compiler/course-config.js +39 -0
  11. package/dist/compiler/exports/standalone-html.d.ts +34 -0
  12. package/dist/compiler/exports/standalone-html.js +803 -0
  13. package/dist/compiler/lesson-derivation.d.ts +1 -1
  14. package/dist/compiler/lesson-derivation.js +2 -2
  15. package/dist/compiler/manifest.d.ts +112 -7
  16. package/dist/compiler/manifest.js +205 -40
  17. package/dist/compiler/resolution-report.d.ts +4 -0
  18. package/dist/reference/base-abbreviations.d.mts +30 -0
  19. package/dist/reference/base-abbreviations.mjs +60 -0
  20. package/dist/reference/base-operators.d.mts +15 -0
  21. package/dist/reference/base-operators.mjs +40 -0
  22. package/dist/reference/base-roman-labels.d.mts +35 -0
  23. package/dist/reference/base-roman-labels.mjs +43 -0
  24. package/dist/reference/consistency.d.ts +8 -2
  25. package/dist/reference/consistency.js +50 -3
  26. package/dist/reference/diagrams-validate.d.ts +21 -0
  27. package/dist/reference/diagrams-validate.js +13 -0
  28. package/dist/reference/equations-validate.d.ts +8 -3
  29. package/dist/reference/equations-validate.js +1 -1
  30. package/dist/reference/equations.d.mts +66 -35
  31. package/dist/reference/equations.mjs +142 -40
  32. package/dist/reference/gate-abbreviations.d.ts +17 -0
  33. package/dist/reference/gate-abbreviations.js +64 -0
  34. package/dist/reference/gate-math.d.ts +28 -5
  35. package/dist/reference/gate-math.js +5 -2
  36. package/dist/reference/gate-prose-notation.d.ts +60 -0
  37. package/dist/reference/gate-prose-notation.js +125 -0
  38. package/dist/reference/keyed-float-validate.d.ts +14 -8
  39. package/dist/reference/keyed-float-validate.js +1 -1
  40. package/dist/reference/keyed-jsx-floats.d.mts +24 -21
  41. package/dist/reference/keyed-jsx-floats.mjs +90 -41
  42. package/dist/reference/knowledge-checks-validate.d.ts +35 -0
  43. package/dist/reference/knowledge-checks-validate.js +78 -0
  44. package/dist/reference/knowledge-checks.d.mts +25 -0
  45. package/dist/reference/knowledge-checks.mjs +45 -0
  46. package/dist/reference/lab-math-scope.d.ts +9 -1
  47. package/dist/reference/lab-math-scope.js +32 -4
  48. package/dist/reference/math-glyphs.d.mts +30 -0
  49. package/dist/reference/math-glyphs.mjs +367 -0
  50. package/dist/reference/mdx-attributes.d.mts +13 -0
  51. package/dist/reference/mdx-attributes.mjs +38 -0
  52. package/dist/reference/mixed-content.d.ts +17 -0
  53. package/dist/reference/mixed-content.js +29 -0
  54. package/dist/reference/page-notation.d.ts +52 -0
  55. package/dist/reference/page-notation.js +171 -0
  56. package/dist/reference/raw-floats-validate.d.ts +18 -0
  57. package/dist/reference/raw-floats-validate.js +53 -0
  58. package/dist/reference/raw-floats.d.mts +18 -0
  59. package/dist/reference/raw-floats.mjs +129 -0
  60. package/dist/reference/rehype-heading-anchors.d.mts +1 -1
  61. package/dist/reference/rehype-heading-anchors.mjs +107 -1
  62. package/dist/reference/rehype-table-header-labels.d.mts +26 -0
  63. package/dist/reference/rehype-table-header-labels.mjs +152 -0
  64. package/dist/reference/remark-abbreviations.d.mts +7 -0
  65. package/dist/reference/remark-abbreviations.mjs +174 -0
  66. package/dist/reference/remark-memo.d.mts +16 -0
  67. package/dist/reference/remark-memo.mjs +339 -0
  68. package/dist/reference/remark-notation.d.mts +102 -5
  69. package/dist/reference/remark-notation.mjs +506 -50
  70. package/dist/reference/remark-short-note.d.mts +4 -0
  71. package/dist/reference/remark-short-note.mjs +237 -0
  72. package/dist/reference/types.d.ts +16 -1
  73. package/dist/reference/units-vocab.js +4 -0
  74. package/docs/VERSIONING.md +33 -4
  75. package/docs/authoring/README.md +12 -0
  76. package/docs/authoring/agents.md +17 -2
  77. package/docs/authoring/components.md +135 -0
  78. package/docs/authoring/migrating.md +154 -7
  79. package/docs/authoring/notation.md +568 -8
  80. package/docs/authoring/structure.md +251 -0
  81. package/docs/authoring/visualizations.md +36 -3
  82. package/package.json +5 -2
  83. package/src/compiler/astro.ts +332 -0
  84. package/src/compiler/cli.ts +33 -10
  85. package/src/compiler/collections.ts +45 -4
  86. package/src/compiler/course-config.ts +131 -3
  87. package/src/compiler/exports/standalone-html.ts +963 -0
  88. package/src/compiler/lesson-derivation.ts +2 -2
  89. package/src/compiler/manifest.ts +367 -49
  90. package/src/components/HoverPanel.astro +45 -4
  91. package/src/components/abbreviation/AbbreviationGlossary.astro +70 -0
  92. package/src/components/abbreviation/AbbreviationLayer.astro +127 -0
  93. package/src/components/assessments/AssessmentRunner.tsx +52 -17
  94. package/src/components/assessments/AssessmentSet.astro +98 -14
  95. package/src/components/assessments/RenderedContent.tsx +55 -0
  96. package/src/components/assessments/assessment-randomization.ts +16 -0
  97. package/src/components/diagram/Diagram.astro +444 -83
  98. package/src/components/disclosure/hover-preview.ts +202 -0
  99. package/src/components/disclosure/persisted-disclosure.ts +66 -0
  100. package/src/components/examples/CompactExamples.astro +2 -0
  101. package/src/components/figure/Figure.astro +279 -10
  102. package/src/components/memo/Memo.astro +453 -0
  103. package/src/components/memo/ShortNote.astro +223 -0
  104. package/src/components/notation/NotationFormula.astro +18 -1
  105. package/src/components/notation/NotationLayer.astro +116 -8
  106. package/src/components/notation/render-notation-math.ts +39 -2
  107. package/src/components/notation/safe-math-to-react.ts +50 -0
  108. package/src/components/reference/FloatRefLayer.astro +233 -15
  109. package/src/components/starlight/CollapsibleSections.astro +461 -0
  110. package/src/components/starlight/DiagramEnhancer.astro +385 -0
  111. package/src/components/starlight/HeadingAnchorEnhancer.astro +5 -17
  112. package/src/components/starlight/KeyedFloatEnhancer.astro +81 -3
  113. package/src/components/starlight/LayoutHeader.astro +50 -0
  114. package/src/components/starlight/LayoutSidebar.astro +62 -1
  115. package/src/components/starlight/LayoutSocialIcons.astro +41 -2
  116. package/src/components/starlight/LessonExportActions.astro +436 -0
  117. package/src/components/starlight/LessonFooter.astro +38 -2
  118. package/src/components/starlight/section-parts.ts +31 -0
  119. package/src/components/table/Table.astro +296 -9
  120. package/src/components/table/TableOverflowExpander.astro +489 -0
  121. package/src/content-config.ts +20 -0
  122. package/src/reference/base-abbreviations.mjs +60 -0
  123. package/src/reference/base-operators.mjs +40 -0
  124. package/src/reference/base-roman-labels.mjs +43 -0
  125. package/src/reference/consistency.ts +73 -2
  126. package/src/reference/diagrams-validate.ts +40 -0
  127. package/src/reference/equations-validate.ts +6 -4
  128. package/src/reference/equations.mjs +142 -40
  129. package/src/reference/gate-abbreviations.ts +128 -0
  130. package/src/reference/gate-math.ts +27 -7
  131. package/src/reference/gate-prose-notation.ts +224 -0
  132. package/src/reference/keyed-float-validate.ts +12 -9
  133. package/src/reference/keyed-jsx-floats.mjs +90 -41
  134. package/src/reference/knowledge-checks-validate.ts +130 -0
  135. package/src/reference/knowledge-checks.mjs +45 -0
  136. package/src/reference/lab-math-scope.ts +45 -3
  137. package/src/reference/math-glyphs.mjs +367 -0
  138. package/src/reference/mdx-attributes.mjs +38 -0
  139. package/src/reference/mixed-content.ts +69 -0
  140. package/src/reference/page-notation.ts +252 -0
  141. package/src/reference/raw-floats-validate.ts +103 -0
  142. package/src/reference/raw-floats.mjs +129 -0
  143. package/src/reference/rehype-heading-anchors.mjs +107 -1
  144. package/src/reference/rehype-table-header-labels.mjs +152 -0
  145. package/src/reference/remark-abbreviations.mjs +174 -0
  146. package/src/reference/remark-memo.mjs +339 -0
  147. package/src/reference/remark-notation.mjs +506 -50
  148. package/src/reference/remark-short-note.mjs +237 -0
  149. package/src/reference/types.ts +16 -0
  150. package/src/reference/units-vocab.ts +4 -0
  151. package/src/styles/global.css +1125 -50
@@ -50,7 +50,9 @@ const { id, label, class: className, ...rest } = Astro.props;
50
50
  >
51
51
  <span aria-hidden="true">&times;</span>
52
52
  </button>
53
- <slot />
53
+ <div class="hover-panel-scroll">
54
+ <slot />
55
+ </div>
54
56
  </aside>
55
57
 
56
58
  <style>
@@ -63,9 +65,15 @@ const { id, label, class: className, ...rest } = Astro.props;
63
65
  JS placement (which clears display equations) must start from zero. */
64
66
  margin: 0;
65
67
  width: min(23rem, calc(100vw - 1.5rem));
66
- overflow: auto;
67
- padding: 1rem;
68
- padding-inline-end: 2.4rem;
68
+ display: flex;
69
+ flex-direction: column;
70
+ /* The scrolling lives on `.hover-panel-scroll` below, not here — the
71
+ close button is an absolutely-positioned sibling of that scroller,
72
+ so it stays put when the content scrolls instead of scrolling away
73
+ with it (which is what happens to an absolutely-positioned child of
74
+ the scroll container itself). `hidden` also keeps the rounded
75
+ corners clean around the inner scroller. */
76
+ overflow: hidden;
69
77
  border: 1px solid var(--sl-color-gray-5);
70
78
  border-top: 0.25rem solid var(--sl-color-accent);
71
79
  border-radius: 0.65rem;
@@ -75,6 +83,39 @@ const { id, label, class: className, ...rest } = Astro.props;
75
83
  font-size: var(--sl-text-sm);
76
84
  }
77
85
 
86
+ /* `.hover-panel { display: flex }` above has the same specificity as the
87
+ UA stylesheet's `[hidden] { display: none }` and comes later in the
88
+ cascade, so it would otherwise win and defeat the `hidden` attribute
89
+ that every layer's JS relies on to actually hide the panel. */
90
+ .hover-panel[hidden] {
91
+ display: none;
92
+ }
93
+
94
+ /* The equation/table/figure preview card (`FloatRefLayer.astro`) is the
95
+ one panel whose content size varies wildly — a short reference vs. a
96
+ wide equation — so it grows to fit instead of staying pinned at the
97
+ default width. `--float-ref-max-width` is computed in JS from the live
98
+ layout (the room between the left sidebar and the viewport edge); the
99
+ notation and citation panels never set it and keep the fixed default. */
100
+ .hover-panel[data-float-ref-panel] {
101
+ width: fit-content;
102
+ min-width: min(23rem, var(--float-ref-max-width, 100vw));
103
+ max-width: var(--float-ref-max-width, min(23rem, calc(100vw - 1.5rem)));
104
+ }
105
+
106
+ .hover-panel-scroll {
107
+ overflow: auto;
108
+ /* Without these, this flex item's default `auto` minimum size is its
109
+ *content's* size, not the panel's — content wider or taller than the
110
+ panel would inflate the panel itself outward instead of scrolling
111
+ inside it (the same flexbox trap `.float-ref-panel-content` guards
112
+ against with its own `min-width: 0`). */
113
+ min-width: 0;
114
+ min-height: 0;
115
+ padding: 1rem;
116
+ padding-inline-end: 2.4rem;
117
+ }
118
+
78
119
  .hover-panel-close {
79
120
  position: absolute;
80
121
  top: 0.5rem;
@@ -0,0 +1,70 @@
1
+ ---
2
+ /**
3
+ * A plain, alphabetical listing of the `abbreviations` collection (rule N14)
4
+ * — deliberately the simplest possible reader-facing view of this content.
5
+ * Unlike `NotationGlossary.astro`, there is no hover popup, no formula
6
+ * rendering, and no `NotationLayer`: an abbreviation card is a name
7
+ * resolution only, so a definition list is the whole job.
8
+ */
9
+ import { getCollection } from 'astro:content';
10
+
11
+ const entries = (await getCollection('abbreviations')).sort((left, right) =>
12
+ left.data.abbr.localeCompare(right.data.abbr),
13
+ );
14
+ ---
15
+
16
+ {
17
+ entries.length === 0 ? (
18
+ <p class="abbreviation-glossary-empty">
19
+ No abbreviations are catalogued yet.
20
+ </p>
21
+ ) : (
22
+ <dl class="abbreviation-glossary">
23
+ {entries.map((entry) => (
24
+ <div class="abbreviation-entry" id={`abbr-${entry.data.key}`}>
25
+ <dt>
26
+ {entry.data.abbr}
27
+ {entry.data.aliases.length > 0 && (
28
+ <span class="abbreviation-aliases">
29
+ {' '}
30
+ (also {entry.data.aliases.join(', ')})
31
+ </span>
32
+ )}
33
+ </dt>
34
+ <dd>{entry.data.expansion}</dd>
35
+ </div>
36
+ ))}
37
+ </dl>
38
+ )
39
+ }
40
+
41
+ <style>
42
+ .abbreviation-glossary {
43
+ display: grid;
44
+ grid-template-columns: max-content 1fr;
45
+ column-gap: 1rem;
46
+ row-gap: 0.75rem;
47
+ }
48
+
49
+ .abbreviation-entry {
50
+ display: contents;
51
+ }
52
+
53
+ .abbreviation-entry dt {
54
+ font-weight: 600;
55
+ white-space: nowrap;
56
+ }
57
+
58
+ .abbreviation-entry dd {
59
+ margin: 0;
60
+ }
61
+
62
+ .abbreviation-aliases {
63
+ font-weight: 400;
64
+ color: var(--sl-color-gray-3);
65
+ }
66
+
67
+ .abbreviation-glossary-empty {
68
+ color: var(--sl-color-gray-3);
69
+ }
70
+ </style>
@@ -0,0 +1,127 @@
1
+ ---
2
+ /**
3
+ * Hover/pin panel for the inline abbreviation links `remarkAbbreviations`
4
+ * emits (N14, close sibling of `CitationLayer.astro`). The links are real
5
+ * anchors to the glossary's "Abbreviations" section and work with no
6
+ * JavaScript; this island only adds a quick look at the resolution without
7
+ * leaving the reader's place.
8
+ *
9
+ * The abbreviations table is small by nature (a name + one-line resolution
10
+ * each), so unlike the math-notation layer this renders every entry
11
+ * unconditionally on every lesson page — one hidden block per entry — rather
12
+ * than computing a per-page dependency closure. The panel reads its content
13
+ * from that static markup, never from a second serialized copy of the table.
14
+ * Positioning, pin, Escape, and outside-close come from the shared
15
+ * `HoverPanel` primitive.
16
+ */
17
+ import { getCollection } from 'astro:content';
18
+ import HoverPanel from '../HoverPanel.astro';
19
+
20
+ const entries = await getCollection('abbreviations');
21
+ ---
22
+
23
+ <HoverPanel id="abbreviation-panel" label="Abbreviation" data-abbreviation-panel>
24
+ <div class="abbreviation-panel-body">
25
+ <p class="abbreviation-panel-term" data-panel-term></p>
26
+ <p class="abbreviation-panel-expansion" data-panel-expansion></p>
27
+ <a class="abbreviation-panel-jump" data-panel-jump
28
+ >See it in the glossary</a
29
+ >
30
+ </div>
31
+ </HoverPanel>
32
+
33
+ {
34
+ entries.map((entry) => (
35
+ <p hidden data-abbr-entry={entry.data.key}>
36
+ {entry.data.expansion}
37
+ </p>
38
+ ))
39
+ }
40
+
41
+ <script>
42
+ import { createHoverPanel, closestElement } from '../hover-panel';
43
+
44
+ function setup() {
45
+ const panel = document.querySelector<HTMLElement>(
46
+ '[data-abbreviation-panel]',
47
+ );
48
+ if (!panel || panel.dataset.hoverPanelReady === 'true') return;
49
+
50
+ const markers = Array.from(
51
+ document.querySelectorAll<HTMLAnchorElement>(
52
+ '.sl-markdown-content a.abbreviation-term[data-abbr-key]',
53
+ ),
54
+ );
55
+ if (markers.length === 0) return;
56
+ const markerSet = new Set<HTMLElement>(markers);
57
+
58
+ const term = panel.querySelector<HTMLElement>('[data-panel-term]')!;
59
+ const expansion = panel.querySelector<HTMLElement>(
60
+ '[data-panel-expansion]',
61
+ )!;
62
+ const jump = panel.querySelector<HTMLAnchorElement>('[data-panel-jump]')!;
63
+
64
+ const markerFrom = (node: EventTarget | null) => {
65
+ const candidate = closestElement(
66
+ node,
67
+ 'a.abbreviation-term[data-abbr-key]',
68
+ );
69
+ return candidate && markerSet.has(candidate) ? candidate : null;
70
+ };
71
+
72
+ // Fill the panel from the matching hidden `[data-abbr-entry]` block —
73
+ // never from a second serialized copy of the table.
74
+ const render = (marker: HTMLElement, key: string) => {
75
+ const entry = document.querySelector<HTMLElement>(
76
+ `[data-abbr-entry="${CSS.escape(key)}"]`,
77
+ );
78
+ if (!entry) return false;
79
+
80
+ term.textContent = marker.textContent ?? '';
81
+ expansion.textContent = entry.textContent ?? '';
82
+ jump.href = marker.getAttribute('href') ?? '#abbreviations';
83
+ return true;
84
+ };
85
+
86
+ createHoverPanel({
87
+ panel,
88
+ groupId: 'abbreviation',
89
+ triggerFrom: markerFrom,
90
+ keyFor: (element) => element.dataset.abbrKey ?? null,
91
+ render,
92
+ });
93
+ }
94
+
95
+ setup();
96
+ document.addEventListener('astro:page-load', setup);
97
+ </script>
98
+
99
+ <style>
100
+ @layer starlight.components {
101
+ :global(a.abbreviation-term) {
102
+ text-decoration: underline dotted;
103
+ text-underline-offset: 0.2em;
104
+ color: inherit;
105
+ }
106
+
107
+ :global(a.abbreviation-term:hover),
108
+ :global(a.abbreviation-term:focus-visible) {
109
+ background: var(--sl-color-accent-low);
110
+ color: var(--sl-color-text-accent);
111
+ }
112
+
113
+ .abbreviation-panel-body p {
114
+ margin: 0 0 0.5rem;
115
+ line-height: 1.5;
116
+ }
117
+
118
+ .abbreviation-panel-term {
119
+ font-weight: 600;
120
+ }
121
+
122
+ .abbreviation-panel-jump {
123
+ display: inline-block;
124
+ font-size: var(--sl-text-xs);
125
+ }
126
+ }
127
+ </style>
@@ -10,6 +10,9 @@ import {
10
10
  type ProgressRepository,
11
11
  } from '../../progress/ProgressRepository';
12
12
  import { useSessionUser } from '../../session/user-context';
13
+ import type { MixedContentNode } from '../notation/render-notation-math';
14
+ import RenderedContent from './RenderedContent';
15
+ import { shuffled } from './assessment-randomization';
13
16
 
14
17
  interface ChoiceOption {
15
18
  id: string;
@@ -20,8 +23,14 @@ interface AssessmentItemBase {
20
23
  id: string;
21
24
  competencyId?: string;
22
25
  evidenceKind: 'direct' | 'transfer';
23
- prompt: string;
24
- explanation: string;
26
+ /**
27
+ * Pre-split and glyph-resolved by `AssessmentSet.astro` from
28
+ * `manifest.assessmentContent` (schema v8) — the same KaTeX pipeline
29
+ * lesson body math renders through, not the raw authored string. Render
30
+ * with {@link RenderedContent}, never as a plain string.
31
+ */
32
+ promptContent: readonly MixedContentNode[];
33
+ explanationContent: readonly MixedContentNode[];
25
34
  }
26
35
 
27
36
  interface ChoiceItem extends AssessmentItemBase {
@@ -42,7 +51,10 @@ type Outcome = 'correct' | 'incorrect';
42
51
  interface Props {
43
52
  /** Assessment collection id — scopes recorded attempts and analytics events. */
44
53
  assessmentId: string;
54
+ /** Assessment title; the set wrapper renders it, direct callers use it here. */
45
55
  title: string;
56
+ /** Stable id of the heading emitted by `AssessmentSet.astro`. */
57
+ labelledBy?: string;
46
58
  items: Item[];
47
59
  /**
48
60
  * Seams (Phase H1). Both default to the no-op implementations the static
@@ -70,18 +82,38 @@ function statusFor(outcome: Outcome | undefined): {
70
82
  interface QuestionProps {
71
83
  item: Item;
72
84
  index: number;
85
+ headingTag: 'h3' | 'h4';
73
86
  outcome: Outcome | undefined;
74
87
  onResult: (id: string, outcome: Outcome) => void;
75
88
  onReset: (id: string) => void;
76
89
  }
77
90
 
78
- function Question({ item, index, outcome, onResult, onReset }: QuestionProps) {
91
+ function Question({
92
+ item,
93
+ index,
94
+ headingTag: Heading,
95
+ outcome,
96
+ onResult,
97
+ onReset,
98
+ }: QuestionProps) {
79
99
  const panelId = useId();
80
100
  const groupName = useId();
81
101
  const inputId = useId();
82
102
  const [open, setOpen] = useState(false);
83
103
  const [choice, setChoice] = useState<string | null>(null);
84
104
  const [numeric, setNumeric] = useState('');
105
+ const [choiceOptions, setChoiceOptions] = useState<ChoiceOption[]>(() =>
106
+ item.type === 'single-choice' ? item.options : [],
107
+ );
108
+
109
+ // Keep the server-rendered authored order hydration-safe, then generate a
110
+ // fresh display order in the browser. It remains stable for this view and
111
+ // is regenerated by a full reload; correctness continues to use option ids.
112
+ useEffect(() => {
113
+ if (item.type === 'single-choice') {
114
+ setChoiceOptions(shuffled(item.options));
115
+ }
116
+ }, [item]);
85
117
 
86
118
  const checked = outcome !== undefined;
87
119
  const parsed = Number(numeric);
@@ -108,7 +140,7 @@ function Question({ item, index, outcome, onResult, onReset }: QuestionProps) {
108
140
 
109
141
  return (
110
142
  <li className="assessment-item">
111
- <h4 className="assessment-item-heading">
143
+ <Heading className="assessment-item-heading">
112
144
  <button
113
145
  type="button"
114
146
  className="assessment-disclosure"
@@ -140,17 +172,19 @@ function Question({ item, index, outcome, onResult, onReset }: QuestionProps) {
140
172
  />
141
173
  </svg>
142
174
  </button>
143
- </h4>
175
+ </Heading>
144
176
 
145
177
  <div id={panelId} className="assessment-panel" hidden={!open}>
146
- <p className="assessment-prompt">{item.prompt}</p>
178
+ <p className="assessment-prompt">
179
+ <RenderedContent content={item.promptContent} />
180
+ </p>
147
181
 
148
182
  {item.type === 'single-choice' ? (
149
183
  <fieldset className="assessment-choices">
150
184
  <legend className="sr-only">
151
185
  Answer options for question {index}
152
186
  </legend>
153
- {item.options.map((option) => (
187
+ {choiceOptions.map((option) => (
154
188
  <label key={option.id} className="assessment-option">
155
189
  <input
156
190
  type="radio"
@@ -203,7 +237,7 @@ function Question({ item, index, outcome, onResult, onReset }: QuestionProps) {
203
237
  <strong>
204
238
  {outcome === 'correct' ? 'Correct.' : 'Not quite.'}
205
239
  </strong>{' '}
206
- {item.explanation}
240
+ <RenderedContent content={item.explanationContent} />
207
241
  </>
208
242
  ) : (
209
243
  'Check your answer to reveal the explanation.'
@@ -217,11 +251,13 @@ function Question({ item, index, outcome, onResult, onReset }: QuestionProps) {
217
251
  export default function AssessmentRunner({
218
252
  assessmentId,
219
253
  title,
254
+ labelledBy,
220
255
  items,
221
256
  progress = noopProgressRepository,
222
257
  analytics = noopAnalyticsEmitter,
223
258
  }: Props) {
224
- const headingId = useId();
259
+ const ownHeadingId = useId();
260
+ const headingId = labelledBy ?? ownHeadingId;
225
261
  const user = useSessionUser();
226
262
  const [results, setResults] = useState<Record<string, Outcome>>({});
227
263
 
@@ -295,20 +331,19 @@ export default function AssessmentRunner({
295
331
 
296
332
  return (
297
333
  <section className="assessment-shell" aria-labelledby={headingId}>
298
- <div className="assessment-header">
299
- <h3 id={headingId}>{title}</h3>
300
- <p className="assessment-progress" aria-live="polite">
301
- {answered === 0
302
- ? `${items.length} question${items.length === 1 ? '' : 's'}`
303
- : `${correct} of ${items.length} correct`}
304
- </p>
305
- </div>
334
+ {!labelledBy && <h3 id={headingId}>{title}</h3>}
335
+ <p className="assessment-progress" aria-live="polite">
336
+ {answered === 0
337
+ ? `${items.length} question${items.length === 1 ? '' : 's'}`
338
+ : `${correct} of ${items.length} correct`}
339
+ </p>
306
340
  <ol className="assessment-list">
307
341
  {items.map((item, position) => (
308
342
  <Question
309
343
  key={item.id}
310
344
  item={item}
311
345
  index={position + 1}
346
+ headingTag={labelledBy ? 'h3' : 'h4'}
312
347
  outcome={results[item.id]}
313
348
  onResult={report}
314
349
  onReset={clear}
@@ -1,12 +1,18 @@
1
1
  ---
2
2
  import { getCollection } from 'astro:content';
3
3
  import { loadManifest } from '../../compiler/manifest';
4
+ import type { ContentSegment } from '../../reference/mixed-content';
5
+ import { renderNotationMath } from '../notation/render-notation-math';
6
+ import type { MixedContentNode } from '../notation/render-notation-math';
7
+ import { knowledgeCheckAnchorId } from '../../reference/knowledge-checks.mjs';
4
8
  import AssessmentRunner from './AssessmentRunner';
5
9
 
6
10
  interface Props {
7
11
  /**
8
12
  * Explicit assessment id. Omitted in lesson bodies (Phase C2): the set is
9
13
  * resolved from the route's colocated `<lesson>.checks.yml` via the manifest.
14
+ * Put an explicit id in that list as well to publish its part-wide number to
15
+ * `[[check-…]]` references; undeclared legacy embeds keep a local fallback.
10
16
  */
11
17
  id?: string;
12
18
  }
@@ -19,36 +25,114 @@ const lessonId = Astro.url.pathname
19
25
  .replace(/\/$/, '')
20
26
  .replace(/\//g, '.');
21
27
 
28
+ const manifest = await loadManifest();
29
+ const lesson = manifest.lessons.find((entry) => entry.id === lessonId);
30
+ if (!lesson) {
31
+ throw new Error(
32
+ `AssessmentSet: route "${Astro.url.pathname}" does not resolve to a lesson in the manifest`,
33
+ );
34
+ }
35
+
22
36
  let ids: string[];
23
37
  if (explicitId) {
24
38
  ids = [explicitId];
25
39
  } else {
26
- const manifest = await loadManifest();
27
- ids = [
28
- ...(manifest.lessons.find((lesson) => lesson.id === lessonId)
29
- ?.assessments ?? []),
30
- ];
40
+ ids = [...lesson.assessments];
31
41
  }
32
42
 
33
43
  const assessments = await getCollection('assessments');
34
- const entries = ids.map((id) => {
44
+ const entries = ids.map((id, index) => {
35
45
  const entry = assessments.find((candidate) => candidate.data.id === id);
36
46
  if (!entry) {
37
47
  throw new Error(
38
48
  `AssessmentSet: no assessment collection entry with id "${id}"`,
39
49
  );
40
50
  }
41
- return entry;
51
+ const publishedNumber = lesson.knowledgeChecks.find(
52
+ (check) => check.key === id,
53
+ )?.number;
54
+ const localIndex =
55
+ lesson.numbering.checks + lesson.assessments.length + index + 1;
56
+ const number =
57
+ publishedNumber ??
58
+ (lesson.part > 0 ? `${lesson.part}.${localIndex}` : String(index + 1));
59
+ return { entry, number, anchorId: knowledgeCheckAnchorId(id) };
42
60
  });
61
+
62
+ // `manifest.assessmentContent` (schema v8) already resolved every glyph in
63
+ // every item's `prompt` / `explanation` against this page's lesson scope —
64
+ // same `pageScope`, same relationship, as `gate-math.ts`'s assessment check.
65
+ // Rendering the resolved LaTeX to a `SafeMathNode` tree happens here, in an
66
+ // Astro component, not in `manifest.ts`: the compiler layer stays
67
+ // framework-agnostic, and `renderNotationMath` already lives one directory
68
+ // over for exactly this reason (`NotationMath.astro` calls it the same way).
69
+ const contentByKey = new Map(
70
+ manifest.assessmentContent
71
+ .filter((item) => item.lessonId === lessonId)
72
+ .map((item) => [`${item.assessmentId}:${item.itemId}`, item]),
73
+ );
74
+
75
+ function renderedContent(
76
+ raw: string,
77
+ segments: readonly ContentSegment[] | undefined,
78
+ ): MixedContentNode[] {
79
+ if (!segments) return [{ type: 'text', value: raw }];
80
+ return segments.map((segment) =>
81
+ segment.type === 'text'
82
+ ? segment
83
+ : { type: 'math', nodes: renderNotationMath(segment.latex) },
84
+ );
85
+ }
43
86
  ---
44
87
 
45
88
  {
46
- entries.map((entry) => (
47
- <AssessmentRunner
48
- client:visible
49
- assessmentId={entry.data.id}
50
- title={entry.data.title}
51
- items={entry.data.items}
52
- />
89
+ entries.map(({ entry, number, anchorId }) => (
90
+ <>
91
+ <div
92
+ class="sl-heading-wrapper level-h2 assessment-heading-wrapper"
93
+ data-collapsible="true"
94
+ data-default-open="true"
95
+ >
96
+ <h2 id={anchorId}>
97
+ <span class="assessment-heading-number">Knowledge check {number}</span>
98
+ <span aria-hidden="true"> — </span>
99
+ {entry.data.title}
100
+ </h2>
101
+ <a
102
+ class="sl-anchor-link"
103
+ href={`#${anchorId}`}
104
+ aria-label={`Knowledge check ${number} titled “${entry.data.title}”`}
105
+ >
106
+ <span aria-hidden="true" class="sl-anchor-icon">
107
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
108
+ <path d="m12.11 15.39-3.88 3.88a2.52 2.52 0 0 1-3.5 0 2.47 2.47 0 0 1 0-3.5l3.88-3.88a1 1 0 1 0-1.42-1.42l-3.88 3.89a4.48 4.48 0 0 0 6.33 6.33l3.89-3.88a1 1 0 0 0-1.42-1.42m8.58-12.08a4.49 4.49 0 0 0-6.33 0l-3.89 3.88a1 1 0 1 0 1.42 1.42l3.88-3.88a2.52 2.52 0 0 1 3.5 0 2.47 2.47 0 0 1 0 3.5l-3.88 3.88a1 1 0 0 0 0 1.42 1 1 0 0 0 1.42 0l3.88-3.89a4.49 4.49 0 0 0 0-6.33M8.83 15.17a1 1 0 0 0 .71.29 1 1 0 0 0 .71-.29l4.92-4.92a1 1 0 1 0-1.42-1.42l-4.92 4.92a1 1 0 0 0 0 1.42" />
109
+ </svg>
110
+ </span>
111
+ <span class="sr-only" data-pagefind-ignore="true">
112
+ Link to knowledge check {number}: {entry.data.title}
113
+ </span>
114
+ </a>
115
+ </div>
116
+ <AssessmentRunner
117
+ client:visible
118
+ assessmentId={entry.data.id}
119
+ title={entry.data.title}
120
+ labelledBy={anchorId}
121
+ items={entry.data.items.map((item: (typeof entry.data.items)[number]) => {
122
+ const content = contentByKey.get(`${entry.data.id}:${item.id}`);
123
+ return {
124
+ ...item,
125
+ promptContent: renderedContent(
126
+ item.prompt,
127
+ content?.promptSegments,
128
+ ),
129
+ explanationContent: renderedContent(
130
+ item.explanation,
131
+ content?.explanationSegments,
132
+ ),
133
+ };
134
+ })}
135
+ />
136
+ </>
53
137
  ))
54
138
  }
@@ -0,0 +1,55 @@
1
+ import { Fragment } from 'react';
2
+ import type {
3
+ MixedContentNode,
4
+ SafeMathNode,
5
+ } from '../notation/render-notation-math';
6
+ import { toReactProps } from '../notation/safe-math-to-react';
7
+
8
+ /**
9
+ * The React-side twin of `SafeMathNode.astro`: walk the same structured,
10
+ * pre-sanitized tree and emit real elements via `React.createElement`
11
+ * (`h`/JSX below), never `dangerouslySetInnerHTML` — no HTML string crosses
12
+ * into this island any more than it does into an Astro page.
13
+ */
14
+ function SafeMath({ node }: { node: SafeMathNode }) {
15
+ if (node.type === 'text') return <>{node.value}</>;
16
+ const Tag = node.tagName as keyof JSX.IntrinsicElements;
17
+ return (
18
+ <Tag {...toReactProps(node.properties)}>
19
+ {node.children.map((child, index) => (
20
+ // eslint-disable-next-line react/no-array-index-key -- a static, sanitized render tree; index is stable across renders
21
+ <SafeMath key={index} node={child} />
22
+ ))}
23
+ </Tag>
24
+ );
25
+ }
26
+
27
+ /**
28
+ * Render an assessment `prompt` / `explanation` after `AssessmentSet.astro`
29
+ * has split, resolved, and rendered it (`manifest.assessmentContent` ->
30
+ * `renderNotationMath`): plain text runs verbatim, each math run through
31
+ * {@link SafeMath}.
32
+ */
33
+ export default function RenderedContent({
34
+ content,
35
+ }: {
36
+ content: readonly MixedContentNode[];
37
+ }) {
38
+ return (
39
+ <>
40
+ {content.map((segment, index) =>
41
+ segment.type === 'text' ? (
42
+ // eslint-disable-next-line react/no-array-index-key -- a static, ordered segment list
43
+ <Fragment key={index}>{segment.value}</Fragment>
44
+ ) : (
45
+ <span key={index} className="notation-math">
46
+ {segment.nodes.map((node, nodeIndex) => (
47
+ // eslint-disable-next-line react/no-array-index-key -- a static, sanitized render tree
48
+ <SafeMath key={nodeIndex} node={node} />
49
+ ))}
50
+ </span>
51
+ ),
52
+ )}
53
+ </>
54
+ );
55
+ }
@@ -0,0 +1,16 @@
1
+ /** Return a shuffled copy without changing the authored answer array. */
2
+ export function shuffled<T>(
3
+ values: readonly T[],
4
+ random: () => number = Math.random,
5
+ ): T[] {
6
+ const result = [...values];
7
+ for (let index = result.length - 1; index > 0; index--) {
8
+ const draw = random();
9
+ const bounded = Number.isFinite(draw)
10
+ ? Math.min(Math.max(draw, 0), 1 - Number.EPSILON)
11
+ : 0;
12
+ const swapIndex = Math.floor(bounded * (index + 1));
13
+ [result[index], result[swapIndex]] = [result[swapIndex], result[index]];
14
+ }
15
+ return result;
16
+ }