explico 0.8.0 → 0.11.1

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 (230) hide show
  1. package/CHANGELOG.md +1257 -1
  2. package/README.md +60 -15
  3. package/dist/compiler/astro.d.ts +6 -1
  4. package/dist/compiler/cli.d.ts +46 -2
  5. package/dist/compiler/cli.js +70 -4
  6. package/dist/compiler/collections.d.ts +35 -2
  7. package/dist/compiler/collections.js +68 -5
  8. package/dist/compiler/course-config.d.ts +3 -194
  9. package/dist/compiler/course-config.js +78 -5
  10. package/dist/compiler/exports/standalone-html.d.ts +6 -1
  11. package/dist/compiler/knowledge-version.js +1 -1
  12. package/dist/compiler/lesson-derivation.d.ts +27 -10
  13. package/dist/compiler/lesson-derivation.js +23 -8
  14. package/dist/compiler/logging.d.ts +4 -1
  15. package/dist/compiler/logging.js +4 -1
  16. package/dist/compiler/manifest.d.ts +204 -31
  17. package/dist/compiler/manifest.js +270 -55
  18. package/dist/compiler/resolution-report.d.ts +6 -1
  19. package/dist/course-config-Dsnri9Tk.d.ts +464 -0
  20. package/dist/reference/base-accents.d.mts +26 -0
  21. package/dist/reference/base-accents.mjs +31 -0
  22. package/dist/reference/base-roman-labels.d.mts +2 -2
  23. package/dist/reference/base-roman-labels.mjs +2 -2
  24. package/dist/reference/consistency.d.ts +14 -2
  25. package/dist/reference/consistency.js +108 -4
  26. package/dist/reference/delimiter-size.d.ts +3 -2
  27. package/dist/reference/diagrams-validate.d.ts +7 -2
  28. package/dist/reference/diagrams-validate.js +9 -1
  29. package/dist/reference/equations-validate.d.ts +8 -5
  30. package/dist/reference/equations-validate.js +1 -0
  31. package/dist/reference/equations.d.mts +60 -31
  32. package/dist/reference/equations.mjs +83 -43
  33. package/dist/reference/examples-validate.d.ts +31 -0
  34. package/dist/reference/examples-validate.js +48 -0
  35. package/dist/reference/figures-validate.d.ts +1 -1
  36. package/dist/reference/figures-validate.js +4 -1
  37. package/dist/reference/folds-validate.d.ts +33 -0
  38. package/dist/reference/folds-validate.js +32 -0
  39. package/dist/reference/gate-abbreviations.d.ts +3 -1
  40. package/dist/reference/gate-abbreviations.js +1 -0
  41. package/dist/reference/gate-course-rules.d.ts +32 -0
  42. package/dist/reference/gate-course-rules.js +85 -0
  43. package/dist/reference/gate-heading-length.d.ts +5 -2
  44. package/dist/reference/gate-heading-length.js +45 -21
  45. package/dist/reference/gate-heading-structure.d.ts +41 -0
  46. package/dist/reference/gate-heading-structure.js +113 -0
  47. package/dist/reference/gate-math-punctuation.d.ts +71 -0
  48. package/dist/reference/gate-math-punctuation.js +251 -0
  49. package/dist/reference/gate-math.d.ts +22 -6
  50. package/dist/reference/gate-math.js +42 -8
  51. package/dist/reference/gate-part-pages.d.ts +50 -0
  52. package/dist/reference/gate-part-pages.js +118 -0
  53. package/dist/reference/gate-prose-notation.d.ts +21 -11
  54. package/dist/reference/gate-prose-notation.js +25 -7
  55. package/dist/reference/katex-delimiters.mjs +20 -7
  56. package/dist/reference/keyed-float-validate.d.ts +27 -10
  57. package/dist/reference/keyed-float-validate.js +15 -1
  58. package/dist/reference/keyed-jsx-floats.d.mts +11 -5
  59. package/dist/reference/keyed-jsx-floats.mjs +73 -20
  60. package/dist/reference/knowledge-checks-validate.d.ts +1 -1
  61. package/dist/reference/knowledge-checks.d.mts +3 -1
  62. package/dist/reference/knowledge-checks.mjs +9 -4
  63. package/dist/reference/lab-math-scope.d.ts +1 -0
  64. package/dist/reference/lab-math-slot.d.ts +34 -0
  65. package/dist/reference/lab-math-slot.js +6 -0
  66. package/dist/reference/math-glyphs.d.mts +80 -4
  67. package/dist/reference/math-glyphs.mjs +572 -37
  68. package/dist/reference/page-notation.d.ts +6 -1
  69. package/dist/reference/raw-floats-validate.js +1 -1
  70. package/dist/reference/raw-floats.mjs +1 -1
  71. package/dist/reference/rehype-heading-anchors.d.mts +14 -0
  72. package/dist/reference/rehype-heading-anchors.mjs +116 -17
  73. package/dist/reference/rehype-table-header-labels.d.mts +6 -1
  74. package/dist/reference/rehype-table-header-labels.mjs +21 -3
  75. package/dist/reference/remark-asides.d.mts +3 -0
  76. package/dist/reference/remark-asides.mjs +180 -0
  77. package/dist/reference/remark-memo.d.mts +3 -1
  78. package/dist/reference/remark-memo.mjs +42 -11
  79. package/dist/reference/remark-notation.d.mts +60 -10
  80. package/dist/reference/remark-notation.mjs +387 -36
  81. package/dist/reference/render-lab-math.d.ts +4 -26
  82. package/dist/reference/render-lab-math.js +4 -4
  83. package/dist/reference/sections-validate.d.ts +27 -0
  84. package/dist/reference/sections-validate.js +41 -0
  85. package/dist/reference/sections.d.mts +97 -0
  86. package/dist/reference/sections.mjs +306 -0
  87. package/dist/reference/tables-validate.d.ts +1 -1
  88. package/dist/reference/types.d.ts +17 -1
  89. package/dist/reference/unit-words.d.ts +37 -0
  90. package/dist/reference/unit-words.js +170 -0
  91. package/dist/rules/catalog.d.ts +176 -0
  92. package/dist/rules/catalog.js +983 -0
  93. package/dist/rules/course-rules.d.ts +115 -0
  94. package/dist/rules/course-rules.js +292 -0
  95. package/dist/rules/course-rules.schema.json +138 -0
  96. package/dist/rules/in-effect.d.ts +53 -0
  97. package/dist/rules/in-effect.js +142 -0
  98. package/dist/rules/rules.schema.json +1072 -0
  99. package/dist/rules/settings.d.ts +3 -0
  100. package/dist/rules/settings.js +290 -0
  101. package/dist/rules/waivers.d.ts +53 -0
  102. package/dist/rules/waivers.js +132 -0
  103. package/dist/session/PreferenceStore.d.ts +5 -0
  104. package/dist/session/PreferenceStore.js +9 -0
  105. package/docs/VERSIONING.md +5 -3
  106. package/docs/authoring/README.md +232 -40
  107. package/docs/authoring/agents.md +55 -9
  108. package/docs/authoring/components.md +178 -22
  109. package/docs/authoring/migrating.md +1152 -2
  110. package/docs/authoring/notation.md +576 -322
  111. package/docs/authoring/prose.md +551 -0
  112. package/docs/authoring/retired.md +35 -0
  113. package/docs/authoring/structure-rules.md +422 -40
  114. package/docs/authoring/structure.md +445 -62
  115. package/docs/authoring/versioning.md +20 -28
  116. package/docs/authoring/visualizations.md +142 -20
  117. package/package.json +2 -2
  118. package/src/compiler/cli.ts +116 -6
  119. package/src/compiler/collections.ts +111 -7
  120. package/src/compiler/course-config.ts +273 -34
  121. package/src/compiler/knowledge-version.ts +1 -1
  122. package/src/compiler/lesson-derivation.ts +54 -15
  123. package/src/compiler/logging.ts +8 -2
  124. package/src/compiler/manifest.ts +573 -104
  125. package/src/components/BlockHeading.astro +113 -0
  126. package/src/components/CourseContents.astro +221 -0
  127. package/src/components/CurriculumMap.astro +5 -6
  128. package/src/components/FloatIndex.astro +200 -0
  129. package/src/components/HoverPanel.astro +431 -79
  130. package/src/components/PartBridge.astro +27 -0
  131. package/src/components/PartOverview.astro +401 -0
  132. package/src/components/PartScope.astro +27 -0
  133. package/src/components/abbreviation/AbbreviationLayer.astro +49 -51
  134. package/src/components/assessments/AssessmentRunner.tsx +8 -5
  135. package/src/components/assessments/AssessmentSet.astro +23 -26
  136. package/src/components/block-heading.ts +146 -0
  137. package/src/components/card-arrow.ts +243 -0
  138. package/src/components/card-drag.ts +631 -0
  139. package/src/components/card-jump.ts +45 -0
  140. package/src/components/card-manager.ts +1589 -0
  141. package/src/components/card-memory.ts +177 -0
  142. package/src/components/card-placement.ts +504 -0
  143. package/src/components/card-side.ts +336 -0
  144. package/src/components/card-travel.ts +197 -0
  145. package/src/components/citation/CitationLayer.astro +77 -76
  146. package/src/components/diagram/Diagram.astro +161 -316
  147. package/src/components/disclosure/course-disclosure.ts +24 -0
  148. package/src/components/disclosure/hover-preview.ts +122 -42
  149. package/src/components/disclosure/persisted-disclosure.ts +3 -3
  150. package/src/components/disclosure/print-as-shown.ts +252 -0
  151. package/src/components/examples/CompactExample.astro +3 -1
  152. package/src/components/examples/CompactExamples.astro +101 -94
  153. package/src/components/figure/Figure.astro +174 -274
  154. package/src/components/figure/FloatCaption.astro +87 -0
  155. package/src/components/figure/figure-state.ts +58 -0
  156. package/src/components/figure/figure-tools.ts +87 -0
  157. package/src/components/float-name.ts +101 -0
  158. package/src/components/fold/Fold.astro +125 -0
  159. package/src/components/fold/fold-id.ts +40 -0
  160. package/src/components/memo/Memo.astro +162 -231
  161. package/src/components/memo/ShortNote.astro +6 -6
  162. package/src/components/notation/NotationFormula.astro +5 -1
  163. package/src/components/notation/NotationGlossary.astro +3 -4
  164. package/src/components/notation/NotationLayer.astro +169 -162
  165. package/src/components/notation/NotationMath.astro +10 -1
  166. package/src/components/reference/FloatRefLayer.astro +485 -423
  167. package/src/components/starlight/AboutPanel.astro +5 -4
  168. package/src/components/starlight/CollapsibleSections.astro +207 -93
  169. package/src/components/starlight/DiagramEnhancer.astro +15 -138
  170. package/src/components/starlight/HeadingAnchorEnhancer.astro +193 -1
  171. package/src/components/starlight/KeyedFloatEnhancer.astro +100 -9
  172. package/src/components/starlight/LayoutHeader.astro +298 -19
  173. package/src/components/starlight/LayoutPageSidebar.astro +99 -12
  174. package/src/components/starlight/LayoutPageTitle.astro +120 -0
  175. package/src/components/starlight/LayoutSidebar.astro +229 -11
  176. package/src/components/starlight/LessonExportActions.astro +53 -72
  177. package/src/components/starlight/LessonFooter.astro +26 -4
  178. package/src/components/starlight/section-parts.ts +15 -2
  179. package/src/components/starlight/section-summary.ts +181 -0
  180. package/src/components/table/Table.astro +95 -76
  181. package/src/components/table/TableOverflowExpander.astro +65 -28
  182. package/src/content-config.ts +40 -4
  183. package/src/reference/base-accents.mjs +31 -0
  184. package/src/reference/base-roman-labels.mjs +2 -2
  185. package/src/reference/consistency.ts +176 -5
  186. package/src/reference/delimiter-size.ts +3 -2
  187. package/src/reference/diagrams-validate.ts +17 -1
  188. package/src/reference/equations-validate.ts +10 -5
  189. package/src/reference/equations.mjs +83 -43
  190. package/src/reference/examples-validate.ts +97 -0
  191. package/src/reference/figures-validate.ts +6 -1
  192. package/src/reference/folds-validate.ts +65 -0
  193. package/src/reference/gate-abbreviations.ts +1 -0
  194. package/src/reference/gate-course-rules.ts +134 -0
  195. package/src/reference/gate-heading-length.ts +66 -35
  196. package/src/reference/gate-heading-structure.ts +193 -0
  197. package/src/reference/gate-math-punctuation.ts +381 -0
  198. package/src/reference/gate-math.ts +69 -10
  199. package/src/reference/gate-part-pages.ts +217 -0
  200. package/src/reference/gate-prose-notation.ts +59 -12
  201. package/src/reference/katex-delimiters.mjs +20 -7
  202. package/src/reference/keyed-float-validate.ts +45 -11
  203. package/src/reference/keyed-jsx-floats.mjs +73 -20
  204. package/src/reference/knowledge-checks-validate.ts +1 -1
  205. package/src/reference/knowledge-checks.mjs +9 -4
  206. package/src/reference/lab-math-slot.ts +36 -0
  207. package/src/reference/math-glyphs.mjs +572 -37
  208. package/src/reference/raw-floats-validate.ts +1 -1
  209. package/src/reference/raw-floats.mjs +1 -1
  210. package/src/reference/rehype-heading-anchors.mjs +116 -17
  211. package/src/reference/rehype-table-header-labels.mjs +21 -3
  212. package/src/reference/remark-asides.mjs +180 -0
  213. package/src/reference/remark-memo.mjs +42 -11
  214. package/src/reference/remark-notation.mjs +387 -36
  215. package/src/reference/render-lab-math.ts +16 -31
  216. package/src/reference/sections-validate.ts +81 -0
  217. package/src/reference/sections.mjs +306 -0
  218. package/src/reference/tables-validate.ts +2 -1
  219. package/src/reference/types.ts +17 -0
  220. package/src/reference/unit-words.ts +233 -0
  221. package/src/rules/catalog.ts +1298 -0
  222. package/src/rules/course-rules.ts +456 -0
  223. package/src/rules/in-effect.ts +235 -0
  224. package/src/rules/settings.ts +448 -0
  225. package/src/rules/waivers.ts +249 -0
  226. package/src/session/PreferenceStore.ts +15 -0
  227. package/src/styles/global.css +1467 -163
  228. package/src/styles/surfaces.css +182 -0
  229. package/src/components/hover-panel.ts +0 -339
  230. package/src/components/popup-stack.ts +0 -345
@@ -2,8 +2,8 @@
2
2
 
3
3
  `V` rules govern anything a lesson draws: diagrams, static charts, and hydrated
4
4
  interactive figures. The engine assumes no single plotting approach — these
5
- rules make the choice per figure predictable and keep every figure accessible
6
- and deterministic.
5
+ rules make the choice per figure predictable, keep every figure's math
6
+ gate-checked, and keep its drawing deterministic.
7
7
 
8
8
  See [README.md](README.md) for what "strong recommendation", "Build-enforced",
9
9
  and "Review-only" mean here, and how to amend a rule.
@@ -47,15 +47,21 @@ an independent diagram number, a `#dia-<id>` anchor, and references of the form
47
47
  `[[dia-<id>]]` or `[[lesson/slug#dia-<id>]]`. Diagram numbers do not advance the
48
48
  equation, table, or figure sequences.
49
49
 
50
+ A diagram is named like every float: a long `caption`, shown below the graph
51
+ in the same caption line a figure has (`Diagram 2.1.1`, a chain icon that
52
+ copies the diagram's link, the caption), and an optional `short` caption for
53
+ the `[[dia-…]]` preview card
54
+ ([components.md § The caption pair](components.md#the-caption-pair)).
55
+
50
56
  The component preserves the build-time canvas size and scrolls horizontally
51
- when the work area is narrower. A compact control on the title line can expand
52
- the original diagram toward the full in-page region and shrink it back; it is
53
- not modal, follows the diagram's document position while the page scrolls, and
54
- adds internal vertical scrolling when the viewport is too short. The shrink
55
- control or Escape restores its original width. Clicking the title minimizes/
56
- restores the diagram; this state is remembered per page by default
57
- (`defaultOpen` / `rememberState` configure it).
58
- The title also carries the same chain-link permalink treatment as headings.
57
+ when the work area is narrower. Then a widen button ↔ at the end of the
58
+ caption line — the boxed button a table's caption has — expands the original
59
+ diagram toward the full in-page region and shrinks it back; it is not modal,
60
+ follows the diagram's document position while the page scrolls, and adds
61
+ internal vertical scrolling when the viewport is too short. The button again,
62
+ or Escape, restores its original width. A diagram never folds by itself: it
63
+ is its graph and then its caption line, in every state. To let a reader fold
64
+ it away, put it in a collapsible section.
59
65
 
60
66
  By default a laid-out width above `800px` emits a compile-time warning. Treat
61
67
  that warning as a prompt to try
@@ -103,8 +109,8 @@ _Exceptions._ None — this rule _is_ the exception process.
103
109
 
104
110
  ## V3
105
111
 
106
- **Interactive figures: a hydrated island, the `renderLabMath` seam for
107
- equations, and a text alternative.**
112
+ **Interactive figures: a hydrated island and the `renderLabMath` seam for
113
+ equations.**
108
114
 
109
115
  An interactive figure is a React island, hydrated `client:visible` (or
110
116
  `client:idle`). It carries:
@@ -119,18 +125,60 @@ An interactive figure is a React island, hydrated `client:visible` (or
119
125
  in this page's popup payload. See
120
126
  `playground/content/labs/ProjectileRangeExplorer.tsx` +
121
127
  `ProjectileRangeMath.astro`.
122
- - **An accessible name and description** — `ariaLabel` / `ariaDescription` on
123
- the plot, marks that are decorative set `ariaHidden`.
124
- - **A text alternative.** A `<details>` block with the model's assumptions and a
125
- small table of representative values, so the figure is usable with no JS and
126
- by a screen reader.
127
- - **Labelled controls.** Every control is a labelled `<input>`; where precision
128
+ - **Labelled controls.** Every control is a labelled `<input>` — the visible
129
+ label is how any reader knows what a slider sets; where precision
128
130
  matters, pair a `range` slider with a `number` field (see `RangeField` in
129
131
  `ProjectileRangeExplorer.tsx`).
132
+ - **No title or caption of its own.** The island sits in a `<Figure>`, and the
133
+ figure's `caption` (with an optional `short`) names the whole lab. The lab
134
+ adds no heading, no inner `<figure>` / `<figcaption>`, and no
135
+ `aria-labelledby` pointing at one: one object has one name. A sentence
136
+ that describes the plot ("the dot marks the landing point") belongs in the
137
+ figure's caption.
138
+ - **Picture, then caption, then tools.** A lab is laid out as a LaTeX figure
139
+ with its controls after it (docs/plans/figures-and-short-captions.md,
140
+ decision 3). The plot goes in the `<Figure>`'s default slot — its
141
+ picture — and the controls in its `tools` slot: an instruction line, the
142
+ sliders and fields, the live equation. The two are separate islands that
143
+ share their values. The tools render after the caption line, in a tray of
144
+ their own, and are the only part that folds: the fold button ⌃ / ⌄ ends the
145
+ caption line, and folded tools leave a "Hidden here: 2 sliders, 1
146
+ equation" line that unfolds them. Whether they start open is the course's
147
+ `disclosure.figures`, or `defaultOpen` on the one figure; the reader's
148
+ choice is remembered per page. Print and export show the picture and the
149
+ caption; the tools never print.
150
+ - **A live end to the caption.** The caption ends with a clause stating the
151
+ case shown — "Shown for v₀ = 20 m/s and θ = 30°: range 35.3 m, time of
152
+ flight 2.04 s." The lab writes it with `setFigureState(element, runs)`
153
+ (`explico/components/figure/figure-state.ts`), `element` being any element
154
+ of its own inside the figure and `runs` plain text, `{ text, italic: true }`
155
+ or `{ sub }` — never HTML. The clause prints with the caption; a reader
156
+ of the long caption elsewhere (a preview card, a fold's bar) sees the
157
+ caption alone.
158
+ - **Which text goes where.** The caption names the figure and says what it
159
+ shows — its subject, its marks and symbols, the case shown — and may state
160
+ the one thing to notice. The argument drawn from the figure is lesson
161
+ text, after the figure. Instructions for the controls belong to the
162
+ controls: the tools' first line, not the caption.
163
+ - **A preview shows the plot.** A same-page `[[fig-…]]` reference to a lab
164
+ previews a still copy of its plot as drawn; the tools are never copied. A
165
+ plot not drawn yet — or one on another page, drawn only in the browser —
166
+ leaves the card to the caption and number, so a lab no longer needs
167
+ `previewable={false}`.
168
+
169
+ The figure itself never hides; a lab that should fold away whole, with the
170
+ prose around it, goes in a `<Fold>` or a collapsible section.
171
+
172
+ The lab writes no accessible name, description or text alternative: the
173
+ `<Figure>` renders a real `<figure>` and `<figcaption>`, so its caption is
174
+ already the figure's accessible name
175
+ ([Accessibility](README.md#accessibility)). A plot option such as
176
+ `ariaLabel` / `ariaDescription` / `ariaHidden` may stay where it is written;
177
+ nothing asks for one.
130
178
 
131
179
  _Rationale._ The seam is the only reason interactive math is not a silent
132
- exception to the completeness gate. The text alternative and aria wiring are the
133
- difference between a figure and an inaccessible one.
180
+ exception to the completeness gate. One name per object means the reader, a
181
+ `[[fig-…]]` reference and a screen reader all call the lab the same thing.
134
182
 
135
183
  _How to apply._ Keep the island's job to "patch numbers and redraw". Domain
136
184
  math lives in a plain module (`playground/content/domain/motion.ts`), not in the
@@ -226,3 +274,77 @@ _Enforcement._ Review-only.
226
274
  _Exceptions._ A figure where the meaningful range is a known physical bound
227
275
  (a probability on `[0, 1]`, an angle on `[0°, 360°]`) uses that bound instead —
228
276
  state it in a comment.
277
+
278
+ ---
279
+
280
+ ## V6
281
+
282
+ **Every quantitative axis of a chart shows at least three labelled values.**
283
+
284
+ A chart that draws an x axis and a y axis — a hand-drawn SVG, an Observable
285
+ Plot, an interactive island, a printed pgfplots figure — labels at least three
286
+ tick values on each quantitative axis, as numbers at their positions. An axis
287
+ _name_ (`feature x`, `density`) does not count, and neither does an annotation
288
+ of one special point (`\mu`, `\bar{x}`, a threshold).
289
+
290
+ _Rationale._ Values are what make a chart readable rather than decorative.
291
+ One value pins a single point. Two values give a scale only if the reader
292
+ already knows the axis is linear, and two labels on a log axis look exactly
293
+ like two on a linear one. Three values show where the axis starts, how big a
294
+ unit is, and whether the spacing is linear or logarithmic. Only then can a
295
+ reader read a value off the curve, compare two panels, or check the figure
296
+ against a number in the prose beside it.
297
+
298
+ _How to apply._
299
+
300
+ - Use round values: steps of 1, 2, 2.5, or 5 times a power of ten, inside the
301
+ axis domain. Let the library choose them where it can. Observable Plot's
302
+ automatic ticks satisfy the rule at any ordinary chart size; `ticks: 5`
303
+ pins a count. In pgfplots the automatic ticks satisfy it; never set
304
+ `xtick=\empty` or `ytick=\empty` on a quantitative axis.
305
+ - A hand-drawn SVG computes its values from the domain and falls back to a
306
+ finer step until at least three fit:
307
+
308
+ ```ts
309
+ const NICE_STEPS = [1, 2, 2.5, 5];
310
+
311
+ function axisTicks([lo, hi]: readonly [number, number], target = 5): number[] {
312
+ let exponent = Math.floor(Math.log10((hi - lo) / target));
313
+ let index = NICE_STEPS.findIndex((n) => n * 10 ** exponent >= (hi - lo) / target);
314
+ if (index < 0) [index, exponent] = [0, exponent + 1];
315
+ for (;;) {
316
+ const step = NICE_STEPS[index] * 10 ** exponent;
317
+ const values: number[] = [];
318
+ for (let v = Math.ceil(lo / step - 1e-9) * step; v <= hi + step * 1e-9; v += step) {
319
+ values.push(Number(v.toFixed(10)));
320
+ }
321
+ if (values.length >= 3) return values;
322
+ if (--index < 0) [index, exponent] = [NICE_STEPS.length - 1, exponent - 1];
323
+ }
324
+ }
325
+ ```
326
+
327
+ Draw a short tick mark at each value and the number beside it.
328
+ - The values follow the domain. When a control changes an axis range
329
+ ([V5](#v5)), recompute the values from the new range in the same step.
330
+ - A density or likelihood axis gets values too. "The vertical scale does not
331
+ matter here" is a reason to say so in the caption, not to hide the scale.
332
+ - A log axis labels its decades (`10`, `100`, `1000`).
333
+ - Special-point annotations stay, next to the values rather than instead of
334
+ them.
335
+ - Stacked panels that share an axis, aligned and said to be shared, may
336
+ label it once, on the panel nearest the axis. Every other axis of every
337
+ panel carries its own values.
338
+
339
+ _Enforcement._ Review-only. The engine cannot tell a chart from any other
340
+ drawing. A course can make it build-enforced in its own browser tests by
341
+ marking each axis's value group and counting the values in it.
342
+
343
+ _Exceptions._
344
+
345
+ - A figure without axes has nothing to label: a node-edge diagram, a
346
+ bullseye target, an illustration.
347
+ - A categorical axis shows its categories instead of numbers, even if there
348
+ are only two.
349
+ - A sketch that draws axes is a chart. If the axes are worth drawing, their
350
+ scale is worth labelling.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "explico",
3
- "version": "0.8.0",
3
+ "version": "0.11.1",
4
4
  "description": "Build-time engine for prerequisite-aware, notation-rigorous course content: one validated content model, a semantic notation/citation/equation system with a completeness gate, and a deterministic JSON manifest.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -64,7 +64,7 @@
64
64
  "vitest": "4.1.11"
65
65
  },
66
66
  "scripts": {
67
- "build": "tsup && node scripts/copy-assets.mjs && node scripts/fix-esm-extensions.mjs && tsc -p tsconfig.dts-mjs.json",
67
+ "build": "tsup && node scripts/copy-assets.mjs && node scripts/fix-esm-extensions.mjs && tsc -p tsconfig.dts-mjs.json && node scripts/gen-rules-schema.mjs",
68
68
  "check:release-migration": "node scripts/check-release-migration.mjs",
69
69
  "test": "vitest run",
70
70
  "test:watch": "vitest"
@@ -52,6 +52,13 @@ import type { DiagnosticSeverity } from '../reference/types';
52
52
  import type { EquationLink } from '../reference/gate-math';
53
53
  import type { Manifest, ManifestLesson } from './manifest';
54
54
  import { isLessonSlug, lessonIdFromSlug } from './lesson-derivation';
55
+ import { isCourseRuleId, ownerOf, ELEMENTS, type Element, type Enforcement } from '../rules/catalog';
56
+ import {
57
+ renderRulesInEffect,
58
+ rulesInEffect,
59
+ type RuleInEffect,
60
+ type RulesFilter,
61
+ } from '../rules/in-effect';
55
62
 
56
63
  const REPOSITORY_ROOT = process.cwd();
57
64
  // Content and its course math are the top-level `content/` folder (Phase F).
@@ -86,6 +93,12 @@ export class ContentCliError extends Error {
86
93
  /** One normalized diagnostic. `code` is `<source>:<kind>` and always stable. */
87
94
  export interface CliDiagnostic {
88
95
  readonly code: string;
96
+ /**
97
+ * The catalog entry that owns the code — a rule ID (`N9`) or an invariant
98
+ * slug (`keyed-floats`). Absent only for a code the catalog does not know,
99
+ * which `tests/rules/catalog.test.ts` treats as a bug.
100
+ */
101
+ readonly rule?: string;
89
102
  readonly severity: DiagnosticSeverity;
90
103
  readonly message: string;
91
104
  readonly file?: string;
@@ -104,6 +117,21 @@ function diagnosticSeverity(value: unknown): DiagnosticSeverity {
104
117
  return value === 'info' || value === 'warning' ? value : 'error';
105
118
  }
106
119
 
120
+ /**
121
+ * `{ rule }` for a `<source>:<code>` (or bare) code the catalog owns — or for a
122
+ * course rule, whose code is its own ID (`X1`).
123
+ */
124
+ function ownerFields(code: string): { readonly rule?: string } {
125
+ const bare = code.slice(code.indexOf(':') + 1);
126
+ const owner = ownerOf(bare);
127
+ if (owner !== undefined) return { rule: owner.id };
128
+ return isCourseRuleId(bare) ? { rule: bare } : {};
129
+ }
130
+
131
+ function withOwner(diagnostic: CliDiagnostic): CliDiagnostic {
132
+ return { ...diagnostic, ...ownerFields(diagnostic.code) };
133
+ }
134
+
107
135
  function mentions(message: string, lessonId: string, file: string): boolean {
108
136
  return (
109
137
  message.includes(lessonId) ||
@@ -167,7 +195,7 @@ export function lessonDiagnostics(
167
195
  ));
168
196
  if (!forLesson) continue;
169
197
  out.push({
170
- code: `math:${diagnostic.code ?? diagnostic.context}`,
198
+ code: `math:${diagnostic.code}`,
171
199
  severity: diagnostic.severity,
172
200
  message: diagnostic.message,
173
201
  file: diagnostic.file,
@@ -200,6 +228,8 @@ export function lessonDiagnostics(
200
228
  ...manifest.diagnostics.figures,
201
229
  ...manifest.diagnostics.diagrams,
202
230
  ...manifest.diagnostics.checks,
231
+ ...manifest.diagnostics.examples,
232
+ ...manifest.diagnostics.folds,
203
233
  ...manifest.diagnostics.rawFloats,
204
234
  ]) {
205
235
  if (
@@ -227,10 +257,12 @@ export function lessonDiagnostics(
227
257
  });
228
258
  }
229
259
 
230
- return out.sort(
231
- (a, b) =>
232
- a.code.localeCompare(b.code) || a.message.localeCompare(b.message),
233
- );
260
+ return out
261
+ .map(withOwner)
262
+ .sort(
263
+ (a, b) =>
264
+ a.code.localeCompare(b.code) || a.message.localeCompare(b.message),
265
+ );
234
266
  }
235
267
 
236
268
  // --- renderability -------------------------------------------------------
@@ -512,6 +544,7 @@ export interface StatusReport {
512
544
  readonly keyedTables: number;
513
545
  readonly keyedFigures: number;
514
546
  readonly keyedDiagrams: number;
547
+ readonly keyedExamples: number;
515
548
  };
516
549
  readonly draftDebt: {
517
550
  readonly lessons: readonly string[];
@@ -539,6 +572,7 @@ export function contentStatus(manifest: Manifest): StatusReport {
539
572
  .filter((diagnostic) => diagnostic.code === 'unused-definition')
540
573
  .map((diagnostic) => ({
541
574
  code: `notation:${diagnostic.code}`,
575
+ ...ownerFields(diagnostic.code),
542
576
  severity: diagnosticSeverity(diagnostic.severity),
543
577
  message: diagnostic.message,
544
578
  ...(diagnostic.source?.file === undefined
@@ -557,9 +591,13 @@ export function contentStatus(manifest: Manifest): StatusReport {
557
591
  ...manifest.diagnostics.figures.map((d) => d.severity),
558
592
  ...manifest.diagnostics.diagrams.map((d) => d.severity),
559
593
  ...manifest.diagnostics.checks.map((d) => d.severity),
594
+ ...manifest.diagnostics.examples.map((d) => d.severity),
595
+ ...manifest.diagnostics.folds.map((d) => d.severity),
560
596
  ...manifest.diagnostics.rawFloats.map((d) => d.severity),
561
597
  ...manifest.diagnostics.consistency.map((d) => d.severity),
562
598
  ...manifest.diagnostics.knowledge.map((d) => d.severity),
599
+ ...manifest.diagnostics.config.map((d) => d.severity),
600
+ ...manifest.diagnostics.waivers.map((d) => d.severity),
563
601
  ];
564
602
 
565
603
  const keyedEquations = manifest.lessons.reduce(
@@ -578,6 +616,10 @@ export function contentStatus(manifest: Manifest): StatusReport {
578
616
  (count, lesson) => count + lesson.diagrams.length,
579
617
  0,
580
618
  );
619
+ const keyedExamples = manifest.lessons.reduce(
620
+ (count, lesson) => count + lesson.examples.length,
621
+ 0,
622
+ );
581
623
  const assessments = new Set(
582
624
  manifest.lessons.flatMap((lesson) => lesson.assessments),
583
625
  ).size;
@@ -595,6 +637,7 @@ export function contentStatus(manifest: Manifest): StatusReport {
595
637
  keyedTables,
596
638
  keyedFigures,
597
639
  keyedDiagrams,
640
+ keyedExamples,
598
641
  },
599
642
  draftDebt: { lessons: draftLessons, notation: draftNotation },
600
643
  orphans,
@@ -709,6 +752,7 @@ export function contentDiagnostics(
709
752
  const equation = equationLink(diagnostic.equation);
710
753
  diagnostics.push({
711
754
  code: `${group}:${detail}`,
755
+ ...ownerFields(detail),
712
756
  severity,
713
757
  message: String(diagnostic.message ?? detail),
714
758
  ...(file === undefined ? {} : { file }),
@@ -742,6 +786,61 @@ export function contentWarnings(manifest: Manifest): WarningsReport {
742
786
  return { count: warnings.length, warnings };
743
787
  }
744
788
 
789
+ // --- content rules -------------------------------------------------------
790
+
791
+ export interface RulesReport {
792
+ readonly filter: RulesFilter;
793
+ readonly count: number;
794
+ readonly rules: readonly RuleInEffect[];
795
+ /** The same selection as the page `validate:content` writes. */
796
+ readonly markdown: string;
797
+ }
798
+
799
+ /**
800
+ * Parse `--element a,b` / `--enforcement script` into a filter, throwing a
801
+ * usage error on a value outside the vocabulary.
802
+ */
803
+ export function parseRulesFilter(input: {
804
+ readonly element?: string;
805
+ readonly enforcement?: string;
806
+ }): RulesFilter {
807
+ const filter: { elements?: Element[]; enforcement?: Enforcement | 'locked' } = {};
808
+ if (input.element !== undefined) {
809
+ const elements = input.element.split(',').map((e) => e.trim()).filter(Boolean);
810
+ for (const element of elements) {
811
+ if (!(ELEMENTS as readonly string[]).includes(element)) {
812
+ throw new ContentCliError(
813
+ 'usage',
814
+ `--element ${element}: not an element. One of: ${ELEMENTS.join(', ')}.`,
815
+ );
816
+ }
817
+ }
818
+ filter.elements = elements as Element[];
819
+ }
820
+ if (input.enforcement !== undefined) {
821
+ const kinds = ['script', 'partial', 'guide', 'locked'];
822
+ if (!kinds.includes(input.enforcement)) {
823
+ throw new ContentCliError(
824
+ 'usage',
825
+ `--enforcement ${input.enforcement}: one of ${kinds.join(', ')}.`,
826
+ );
827
+ }
828
+ filter.enforcement = input.enforcement as Enforcement | 'locked';
829
+ }
830
+ return filter;
831
+ }
832
+
833
+ /** The rules this course has in effect, optionally narrowed. */
834
+ export function contentRules(manifest: Manifest, filter: RulesFilter = {}): RulesReport {
835
+ const rules = rulesInEffect(manifest, filter);
836
+ return {
837
+ filter,
838
+ count: rules.length,
839
+ rules,
840
+ markdown: renderRulesInEffect(manifest, filter),
841
+ };
842
+ }
843
+
745
844
  // --- content context ---------------------------------------------------
746
845
 
747
846
  export interface ContextReport {
@@ -772,7 +871,16 @@ export interface ContextReport {
772
871
  readonly tables: readonly { key: string; number: string }[];
773
872
  readonly figures: readonly { key: string; number: string }[];
774
873
  readonly diagrams: readonly { key: string; number: string }[];
775
- readonly knowledgeChecks: readonly { key: string; number: string }[];
874
+ /** Worked-example sets: `short` is the title `[[ex-key]]` names. */
875
+ readonly examples: readonly { key: string; number: string; short?: string }[];
876
+ readonly knowledgeChecks: readonly { key: string; number: string; title?: string }[];
877
+ /** The top-level headings: `slug` is what `[[sec-slug]]` names. */
878
+ readonly sections: readonly {
879
+ slug: string;
880
+ text: string;
881
+ level: number;
882
+ number?: string;
883
+ }[];
776
884
  readonly sources: readonly {
777
885
  id: string;
778
886
  title: string;
@@ -844,7 +952,9 @@ export function contentContext(
844
952
  tables: lesson.tables.map((table) => ({ ...table })),
845
953
  figures: lesson.figures.map((figure) => ({ ...figure })),
846
954
  diagrams: lesson.diagrams.map((diagram) => ({ ...diagram })),
955
+ examples: lesson.examples.map((example) => ({ ...example })),
847
956
  knowledgeChecks: lesson.knowledgeChecks.map((check) => ({ ...check })),
957
+ sections: lesson.headings.map((heading) => ({ ...heading })),
848
958
  sources: lesson.sources.map((id) => {
849
959
  const record = sourceById.get(id) as Record<string, unknown> | undefined;
850
960
  return {
@@ -31,15 +31,21 @@ import type {
31
31
  NotationLessonInput,
32
32
  NotationReferenceInput,
33
33
  NotationRegistryInput,
34
+ NotationSuspension,
34
35
  SharedNotationDefinitionInput,
35
36
  SourceSpan,
36
37
  NotationGlossEntry,
37
38
  } from '../reference/types';
38
39
  import { isSubstantiveMeaning } from '../reference/prose';
39
40
  import { parseEquationRef } from '../reference/equations.mjs';
41
+ import {
42
+ DECORATION_NAMES,
43
+ decorationOfEntry,
44
+ } from '../reference/math-glyphs.mjs';
40
45
  import { parseKeyedFloatRef } from '../reference/keyed-jsx-floats.mjs';
41
46
  import { parseKnowledgeCheckRef } from '../reference/knowledge-checks.mjs';
42
47
  import { parseMemoRef } from '../reference/remark-memo.mjs';
48
+ import { parseSectionRef } from '../reference/sections.mjs';
43
49
  import { resolveLabel } from '../reference/label';
44
50
  import { resolveGlosses } from '../reference/gloss';
45
51
  import {
@@ -263,16 +269,19 @@ export function extractNotationReferences(
263
269
  for (const match of searchable.matchAll(pattern)) {
264
270
  const key = (match[1] ?? '').trim();
265
271
  // `[[eq-key]]` / `[[slug#eq-key]]` are equation references (D7), and
266
- // `[[tab-key]]`/`[[fig-key]]`/`[[dia-key]]` (and their cross-page forms)
267
- // are keyed-float references, `[[check-key]]` targets a lesson's
268
- // assessment list, and `[[memo-key]]` is a page-local memo reference —
269
- // all resolved by the remark pass and the manifest, not notation keys.
272
+ // `[[tab-key]]`/`[[fig-key]]`/`[[dia-key]]`/`[[ex-key]]` (and their
273
+ // cross-page forms) are keyed-float references, `[[check-key]]`
274
+ // targets a lesson's assessment list, `[[sec-slug]]` a heading, and
275
+ // `[[memo-key]]` is a page-local memo reference — all resolved by the
276
+ // remark pass and the manifest, not notation keys.
270
277
  if (
271
278
  parseEquationRef(key) ||
272
279
  parseKeyedFloatRef(key, { prefix: 'tab' }) ||
273
280
  parseKeyedFloatRef(key, { prefix: 'fig' }) ||
274
281
  parseKeyedFloatRef(key, { prefix: 'dia' }) ||
282
+ parseKeyedFloatRef(key, { prefix: 'ex' }) ||
275
283
  parseKnowledgeCheckRef(key) ||
284
+ parseSectionRef(key) ||
276
285
  parseMemoRef(key)
277
286
  ) {
278
287
  continue;
@@ -323,6 +332,57 @@ function lessonCheckIds(lessonPath: string): string[] {
323
332
  : [];
324
333
  }
325
334
 
335
+ /**
336
+ * One part's **overview page** — `content/docs/<dir>/index.mdx`, the page the
337
+ * part's own directory URL resolves to. `isLessonSlug` already excludes any
338
+ * `<dir>/index`, so this page is not a chapter: it carries no competencies, no
339
+ * knowledge check, and no float numbers, and nothing in the curriculum graph
340
+ * changes when a course adds one.
341
+ */
342
+ export interface PartOverviewPage {
343
+ readonly dir: string;
344
+ readonly slug: string;
345
+ readonly file: string;
346
+ readonly title: string;
347
+ readonly description?: string;
348
+ /** MDX after the frontmatter — what the S2 gate (`gate-part-pages.ts`) reads. */
349
+ readonly body: string;
350
+ /** 1-based file line `body` starts on, for diagnostic locations. */
351
+ readonly bodyLine: number;
352
+ }
353
+
354
+ /**
355
+ * The overview page of every part that has one, keyed by the part's directory.
356
+ * A part without one is simply absent — overview pages are opt-in, one part at
357
+ * a time.
358
+ */
359
+ export function loadPartOverviews(
360
+ parts: readonly { readonly dir: string }[],
361
+ ): PartOverviewPage[] {
362
+ const pages: PartOverviewPage[] = [];
363
+ for (const { dir } of parts) {
364
+ for (const extension of ['.mdx', '.md']) {
365
+ const path = join(CONTENT_ROOT, 'docs', dir, `index${extension}`);
366
+ if (!existsSync(path)) continue;
367
+ const { data, file, body, bodyLine } = parseDocument(path);
368
+ const title = typeof data.title === 'string' ? data.title : dir;
369
+ const description =
370
+ typeof data.description === 'string' ? data.description : undefined;
371
+ pages.push({
372
+ dir,
373
+ slug: `${dir}/index`,
374
+ file,
375
+ title,
376
+ ...(description === undefined ? {} : { description }),
377
+ body,
378
+ bodyLine,
379
+ });
380
+ break;
381
+ }
382
+ }
383
+ return pages;
384
+ }
385
+
326
386
  function lessonEntries(
327
387
  prerequisitesById: ReadonlyMap<string, readonly string[]>,
328
388
  ): LessonDefinition[] {
@@ -508,6 +568,48 @@ export async function loadAbbreviationRegistryInput(): Promise<
508
568
  return abbreviationEntries();
509
569
  }
510
570
 
571
+ /**
572
+ * A lesson's `notation.suspend` (rule N22): decorations that are not
573
+ * operators on this page. A name outside `DECORATION_NAMES`, a decoration
574
+ * suspended twice, and a `notation.local` operator entry for a suspended
575
+ * decoration (the page would contradict itself) each fail the load.
576
+ */
577
+ export function notationSuspensions(
578
+ raw: unknown,
579
+ localDefinitions: readonly LocalNotationDefinitionInput[],
580
+ file: string,
581
+ ): NotationSuspension[] {
582
+ if (raw === undefined) return [];
583
+ if (!Array.isArray(raw)) {
584
+ throw new TypeError(`${file} notation.suspend must be an array`);
585
+ }
586
+ const seen = new Set<string>();
587
+ return raw.map((item, index) => {
588
+ const at = `${file} notation.suspend[${index}]`;
589
+ const data = record(item, at);
590
+ const operator = string(data.operator, `${at}.operator`).trim();
591
+ if (!DECORATION_NAMES.has(operator)) {
592
+ throw new Error(
593
+ `${at}.operator ${JSON.stringify(operator)} is not a decoration; use one of ${[...DECORATION_NAMES].join(', ')}`,
594
+ );
595
+ }
596
+ if (seen.has(operator)) {
597
+ throw new Error(`${at} suspends ${JSON.stringify(operator)} a second time`);
598
+ }
599
+ seen.add(operator);
600
+ const local = localDefinitions.find(
601
+ (definition) => decorationOfEntry(definition.notation) === operator,
602
+ );
603
+ if (local) {
604
+ throw new Error(
605
+ `${at} suspends ${JSON.stringify(operator)}, but notation.local entry ${local.key} explains it as an operator (latex ${JSON.stringify(local.notation)}); drop one of them`,
606
+ );
607
+ }
608
+ const reason = optionalString(data.reason, `${at}.reason`);
609
+ return reason === undefined ? { operator } : { operator, reason: reason.trim() };
610
+ });
611
+ }
612
+
511
613
  function notationLessonEntries(): NotationLessonInput[] {
512
614
  const lessons: NotationLessonInput[] = [];
513
615
  for (const path of filesBelow(join(CONTENT_ROOT, 'docs'), ['.md', '.mdx'])) {
@@ -516,12 +618,14 @@ function notationLessonEntries(): NotationLessonInput[] {
516
618
  const { file, data, body, bodyLine } = parseDocument(path);
517
619
  const notation = record(data.notation ?? {}, `${file} notation`);
518
620
  const local = Array.isArray(notation.local) ? notation.local : [];
621
+ const localDefinitions = local.map((entry, index) =>
622
+ localDefinition(entry, file, index),
623
+ );
519
624
  lessons.push({
520
625
  lessonId: lessonIdFromSlug(slug),
521
626
  status: status(data.editorialStatus, `${file} editorialStatus`),
522
- localDefinitions: local.map((entry, index) =>
523
- localDefinition(entry, file, index),
524
- ),
627
+ localDefinitions,
628
+ suspended: notationSuspensions(notation.suspend, localDefinitions, file),
525
629
  references: extractNotationReferences(body, file, undefined),
526
630
  body,
527
631
  bodyLine,