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
@@ -124,19 +124,31 @@ const notationEntryShape = z.object({
124
124
 
125
125
  const localNotationDefinition = notationEntryShape;
126
126
 
127
+ /**
128
+ * One `notation.suspend` item (rule N22): a decoration (`'`, `\hat`, …) that
129
+ * is not an operator on this page. The loader (`collections.ts`) checks the
130
+ * name against the decorations the resolver knows.
131
+ */
132
+ const notationSuspension = z.object({
133
+ operator: z.string().min(1),
134
+ reason: z.string().min(1).optional(),
135
+ });
136
+
127
137
  const lessonNotation = z
128
138
  .object({
129
139
  // `notation.uses` is retired (D3): a lesson pulls a shared key into scope
130
140
  // by referencing it in prose (`[[key]]`) or math (`\explain`).
131
141
  local: z.array(localNotationDefinition).default([]),
142
+ suspend: z.array(notationSuspension).default([]),
132
143
  })
133
- .default({ local: [] });
144
+ .default({ local: [], suspend: [] });
134
145
 
135
146
  /**
136
147
  * Lesson frontmatter — reduced shape (Phase C1 schema, C2 codemod applied).
137
148
  *
138
149
  * Authored: `title`, `description` (Starlight base), `teaches` (ordered — the
139
150
  * curriculum contract, never derived), `assumptions`,
151
+ * `studyMinutes` (optional reader-facing estimate),
140
152
  * `notation.local` (page-local symbols only).
141
153
  * Artifact: `editorialStatus` (human-set trust flag).
142
154
  * Derived (in `content/collections.ts` + `content/lesson-derivation.ts`, never
@@ -144,10 +156,33 @@ const lessonNotation = z
144
156
  * prerequisites of `teaches`, minus `teaches`), `sources` (`[@…]`
145
157
  * occurrences), `assessments` (colocated `<lesson>.checks.yml`),
146
158
  * `sidebar` order (track order).
147
- * Dropped: `aiAssisted`, `lastReviewed`, `riskTier`, `estimatedMinutes`
148
- * (git history + `editorialStatus` + `NEEDS_SOURCE` carry provenance);
149
- * `notation.uses` (D3 — a shared symbol is imported by using it).
159
+ * Dropped: `aiAssisted`, `lastReviewed`, `riskTier` (git history +
160
+ * `editorialStatus` + `NEEDS_SOURCE` carry provenance);
161
+ * `estimatedMinutes`, whose reader-facing half returned as the
162
+ * optional `studyMinutes` once part pages had somewhere to total
163
+ * it; `notation.uses` (D3 — a shared symbol is imported by using
164
+ * it).
165
+ */
166
+ /**
167
+ * How long this chapter takes to study: `40`, or a `[low, high]` range in
168
+ * minutes. It is a reader-facing estimate the author makes, aggregated by
169
+ * `<PartOverview>` and `<CourseContents>` into a part and course total — not
170
+ * the provenance field `estimatedMinutes` used to be, and nothing derives it.
171
+ *
172
+ * A part only reports a total when every one of its chapters states one, so a
173
+ * half-annotated course shows the chapters it knows about and no misleading
174
+ * sum.
150
175
  */
176
+ const studyMinutes = z
177
+ .union([
178
+ z.number().int().positive(),
179
+ z.tuple([z.number().int().positive(), z.number().int().positive()]),
180
+ ])
181
+ .refine((value) => !Array.isArray(value) || value[0] <= value[1], {
182
+ message:
183
+ 'studyMinutes as a range is [low, high]; the low end cannot exceed the high end.',
184
+ });
185
+
151
186
  const docs = defineCollection({
152
187
  loader: docsGlob,
153
188
  schema: docsSchema({
@@ -155,6 +190,7 @@ const docs = defineCollection({
155
190
  editorialStatus: editorialStatus.default('draft'),
156
191
  teaches: z.array(id).default([]),
157
192
  assumptions: z.array(z.string().min(1)).default([]),
193
+ studyMinutes: studyMinutes.optional(),
158
194
  notation: lessonNotation,
159
195
  }),
160
196
  }),
@@ -0,0 +1,31 @@
1
+ /**
2
+ * The base accent baseline: decorations every reader is assumed to know, so a
3
+ * lesson never has to explain them (rule N22). The named-operator counterpart
4
+ * is `base-operators.mjs` (rule N8).
5
+ *
6
+ * It ships **empty**, because no decoration has one meaning across fields:
7
+ * `\hat` can mean an estimator, a unit vector or a Fourier transform; `\bar`
8
+ * a mean, a conjugate or a closure; `'` a derivative or "another one". A
9
+ * course that uses one consistently says so with `rules.N22.allow` (a physics
10
+ * course might allow `\dot` and `\vec`); anything else is explained by an
11
+ * operator entry (`latex: \hat{}`) or a whole-form entry (`latex:
12
+ * \hat{\beta}`). Names are the canonical ones in `DECORATION_NAMES`
13
+ * (`math-glyphs.mjs`): `\hat`, `\overline`, `'`, `*`, `\dagger`, ….
14
+ */
15
+ export const BASE_ACCENTS = Object.freeze(new Set());
16
+
17
+ /**
18
+ * The decorations a course treats as universal, given its `rules.N22` block:
19
+ * the shipped baseline, plus `allow`, minus `deny`.
20
+ *
21
+ * @param {{ allow?: readonly string[], deny?: readonly string[] } | undefined} accents
22
+ * @returns {Set<string>}
23
+ */
24
+ export function effectiveAccentAllowlist(accents) {
25
+ const allow = accents?.allow ?? [];
26
+ const deny = new Set(accents?.deny ?? []);
27
+ const effective = new Set(BASE_ACCENTS);
28
+ for (const name of allow) effective.add(name);
29
+ for (const name of deny) effective.delete(name);
30
+ return effective;
31
+ }
@@ -5,8 +5,8 @@
5
5
  * (rule N8); this is its `\mathrm{…}` twin (rule N10).
6
6
  *
7
7
  * Rationale: the completeness gate has no opinion about `\mathrm{…}` bodies —
8
- * they are a typographic label to KaTeX, not a bindable symbol, and N3
9
- * requires the format without asking whether the label is ever explained.
8
+ * they are a typographic label to KaTeX, not a bindable symbol, so the
9
+ * format half of rule N10 says nothing about whether the label is explained.
10
10
  * The `roman-label-explained` consistency check fills that gap: a label a
11
11
  * lesson uses is either on this list (or the course's additions to it) or it
12
12
  * carries a notation entry the reader can hover.
@@ -24,12 +24,21 @@
24
24
  * uses is on the shipped/course roman-label
25
25
  * allowlist or carries a notation entry
26
26
  * (rule N10)
27
+ * (j) accent-explained a decoration a lesson composes (`\hat`,
28
+ * `'`, `^*`, …) is on the course accent
29
+ * allowlist, explained by an operator entry,
30
+ * or part of a whole-form entry (rule N22)
31
+ * (k) operator-suspended / a `notation.suspend` item, with or without
32
+ * operator-suspension-reason its reason, and a whole-form entry that
33
+ * / operator-shadowed shadows an operator the page does not
34
+ * suspend (rule N22)
27
35
  *
28
36
  * Check (d) — the committed per-lesson resolution report — lives in
29
37
  * `src/content/resolution-report.ts`.
30
38
  */
31
39
 
32
40
  import type {
41
+ CourseAccents,
33
42
  CourseConventions,
34
43
  CourseOperators,
35
44
  CourseRomanLabels,
@@ -37,9 +46,19 @@ import type {
37
46
  import type { CurriculumCatalog } from '../curriculum/validation';
38
47
  import type { NotationSpec } from '../compiler/collections';
39
48
  import type { LintIgnore } from './lint-ignore';
49
+ import { effectiveAccentAllowlist } from './base-accents.mjs';
40
50
  import { effectiveOperatorAllowlist } from './base-operators.mjs';
41
51
  import { effectiveRomanLabelAllowlist } from './base-roman-labels.mjs';
42
- import { collectOperators, collectRomanLabels } from './math-glyphs.mjs';
52
+ import { stripEquationLabels } from './equations.mjs';
53
+ import { pageScope } from './gate-math';
54
+ import {
55
+ collectDecorations,
56
+ collectOperators,
57
+ collectRomanLabels,
58
+ decorationOfEntry,
59
+ operatorEntryLatex,
60
+ resolveMathGlyphs,
61
+ } from './math-glyphs.mjs';
43
62
  import { offVocabularyTokens } from './units-vocab';
44
63
  import type {
45
64
  NotationDefinitionRecord,
@@ -58,7 +77,11 @@ export type ConsistencyCode =
58
77
  | 'card-wants-demoting'
59
78
  | 'gloss-name-shape'
60
79
  | 'operator-explained'
61
- | 'roman-label-explained';
80
+ | 'roman-label-explained'
81
+ | 'accent-explained'
82
+ | 'operator-suspended'
83
+ | 'operator-suspension-reason'
84
+ | 'operator-shadowed';
62
85
 
63
86
  export interface ConsistencyDiagnostic {
64
87
  readonly code: ConsistencyCode;
@@ -85,6 +108,8 @@ export interface ConsistencyInput {
85
108
  readonly operators?: CourseOperators;
86
109
  /** This course's edits to the roman-label baseline (rule N10). */
87
110
  readonly romanLabels?: CourseRomanLabels;
111
+ /** This course's edits to the (empty) accent baseline (rule N22). */
112
+ readonly accents?: CourseAccents;
88
113
  }
89
114
 
90
115
  const CONVENTION_RE =
@@ -102,11 +127,17 @@ interface MathSpan {
102
127
  readonly latex: string;
103
128
  }
104
129
 
130
+ /**
131
+ * Every `$$…$$` then `$…$` span in `body`, outside code. A `\label{eq:…}`
132
+ * (D7) is stripped: it is an identity marker KaTeX cannot parse, and the
133
+ * scans below treat an unparseable span as having nothing in it — so left in,
134
+ * every labelled display equation was invisible to rules N8, N10 and N22.
135
+ */
105
136
  function mathSpans(body: string): MathSpan[] {
106
137
  const searchable = stripCode(body);
107
138
  const spans: MathSpan[] = [];
108
139
  for (const match of searchable.matchAll(/\$\$([\s\S]+?)\$\$/g)) {
109
- spans.push({ latex: match[1] ?? '' });
140
+ spans.push({ latex: stripEquationLabels(match[1] ?? '') });
110
141
  }
111
142
  for (const match of searchable
112
143
  .replace(/\$\$([\s\S]+?)\$\$/g, (block) => block.replace(/[^\n]/g, ' '))
@@ -508,8 +539,8 @@ function checkOperatorExplained(
508
539
 
509
540
  /**
510
541
  * (i) roman-label-explained (rule N10) — the `\mathrm{…}` twin of (h)
511
- * above: N3 requires the *format* (`\mathrm{KE}`, never bare `KE`) but has
512
- * no opinion on whether the label is ever explained, because the glyph
542
+ * above: N10's format half (`\mathrm{KE}`, never bare `KE`) says nothing
543
+ * about whether the label is ever explained, because the glyph
513
544
  * resolver skips a `\mathrm{…}` body whole. This is the check that looks at
514
545
  * them; it reads `collectRomanLabels`, which parses with the same pinned
515
546
  * KaTeX as the completeness gate.
@@ -566,6 +597,140 @@ function checkRomanLabelExplained(
566
597
  }
567
598
  }
568
599
 
600
+ function sharedScopeByKey(
601
+ input: ConsistencyInput,
602
+ ): Map<string, { key: string; notation: string }> {
603
+ return new Map(
604
+ input.notationInput.sharedDefinitions.map((definition) => [
605
+ definition.key,
606
+ { key: definition.key, notation: definition.notation },
607
+ ]),
608
+ );
609
+ }
610
+
611
+ /**
612
+ * (j) accent-explained (rule N22) — the decoration twin of (h): an accent or
613
+ * superscript mark a lesson *composes* (`\hat{\beta}` read as "hat applied to
614
+ * β") is universal for the course (`rules.N22.allow`; the shipped list is
615
+ * empty), explained by an operator entry in the page's scope (`latex:
616
+ * \hat{}`), or not composing at all because a whole-form entry covers it
617
+ * (`latex: \hat{\beta}`). Only the resolver knows which whole form covered an
618
+ * occurrence (`\theta^*` and `\theta^{*}` are one form), so this runs it over
619
+ * each span with the same page scope as the completeness gate. A span that
620
+ * will not resolve is the gate's to report, not this check's.
621
+ */
622
+ function checkAccentExplained(
623
+ input: ConsistencyInput,
624
+ out: ConsistencyDiagnostic[],
625
+ ): void {
626
+ const allowlist = effectiveAccentAllowlist(input.accents);
627
+ const sharedByKey = sharedScopeByKey(input);
628
+
629
+ for (const lesson of input.notationInput.lessons) {
630
+ const scope = pageScope(lesson, sharedByKey);
631
+ const suspended = (lesson.suspended ?? []).map((item) => item.operator);
632
+ const reported = new Set<string>();
633
+ for (const span of mathSpans(lesson.body)) {
634
+ let decorations;
635
+ try {
636
+ ({ decorations } = resolveMathGlyphs(span.latex, scope, { suspended }));
637
+ } catch {
638
+ continue;
639
+ }
640
+ for (const decoration of decorations) {
641
+ if (decoration.wholeForm !== null || decoration.operator !== null) continue;
642
+ // A suspended decoration never composes; the gate fails it instead.
643
+ if (suspended.includes(decoration.name)) continue;
644
+ if (allowlist.has(decoration.name)) continue;
645
+ if (reported.has(decoration.name)) continue;
646
+ reported.add(decoration.name);
647
+ if (input.lintIgnore.matches('accent-explained', decoration.name)) {
648
+ continue;
649
+ }
650
+ out.push({
651
+ code: 'accent-explained',
652
+ severity: 'warning',
653
+ lessonId: lesson.lessonId,
654
+ file: lesson.source.file,
655
+ glyph: decoration.name,
656
+ message: `${lesson.lessonId}: decoration ${JSON.stringify(decoration.name)} (in ${JSON.stringify(decoration.token)}) is an operator that is neither universal for this course nor explained; give it an operator entry (latex ${JSON.stringify(operatorEntryLatex(decoration.name))}) introduced with [[key]] or in notation.local, give the whole form its own entry (latex ${JSON.stringify(decoration.token)}), or add ${JSON.stringify(decoration.name)} to rules.N22.allow`,
657
+ });
658
+ }
659
+ }
660
+ }
661
+ }
662
+
663
+ /**
664
+ * (k) operator-suspended / operator-suspension-reason / operator-shadowed
665
+ * (rule N22). A `notation.suspend` item is a deliberate departure from N22 on
666
+ * one page: with its `reason` it is recorded (info), without one it is a
667
+ * warning — the reason is what a reviewer needs. And a whole-form entry whose
668
+ * decoration is explained or universal on its page, but not suspended there,
669
+ * is noted (info): the entry's card wins for that form, which is right for a
670
+ * refinement ("β̂ is the OLS estimate") and a smell when the decoration never
671
+ * means the operator on that page.
672
+ */
673
+ function checkOperatorSuspension(
674
+ input: ConsistencyInput,
675
+ out: ConsistencyDiagnostic[],
676
+ ): void {
677
+ const allowlist = effectiveAccentAllowlist(input.accents);
678
+ const sharedByKey = sharedScopeByKey(input);
679
+
680
+ for (const lesson of input.notationInput.lessons) {
681
+ const suspensions = lesson.suspended ?? [];
682
+ for (const { operator, reason } of suspensions) {
683
+ const code =
684
+ reason === undefined ? 'operator-suspension-reason' : 'operator-suspended';
685
+ if (input.lintIgnore.matches(code, operator)) continue;
686
+ out.push({
687
+ code,
688
+ severity: reason === undefined ? 'warning' : 'info',
689
+ lessonId: lesson.lessonId,
690
+ file: lesson.source.file,
691
+ glyph: operator,
692
+ message:
693
+ reason === undefined
694
+ ? `${lesson.lessonId}: notation.suspend suspends ${JSON.stringify(operator)} with no reason; say why it is not an operator on this page (reason: …)`
695
+ : `${lesson.lessonId}: ${JSON.stringify(operator)} is not an operator on this page (notation.suspend): ${reason}`,
696
+ });
697
+ }
698
+
699
+ const suspended = new Set(suspensions.map((item) => item.operator));
700
+ const scope = pageScope(lesson, sharedByKey);
701
+ const explained = new Set(
702
+ scope
703
+ .map((entry) => decorationOfEntry(entry.notation))
704
+ .filter((name): name is string => name !== undefined),
705
+ );
706
+ for (const entry of scope) {
707
+ if (decorationOfEntry(entry.notation) !== undefined) continue;
708
+ const names = new Set(
709
+ collectDecorations(entry.notation).map((decoration) => decoration.name),
710
+ );
711
+ for (const name of names) {
712
+ if (suspended.has(name)) continue;
713
+ const how = explained.has(name)
714
+ ? 'explained by an operator entry'
715
+ : allowlist.has(name)
716
+ ? 'universal for this course'
717
+ : undefined;
718
+ if (how === undefined) continue;
719
+ if (input.lintIgnore.matches('operator-shadowed', entry.key)) continue;
720
+ out.push({
721
+ code: 'operator-shadowed',
722
+ severity: 'info',
723
+ lessonId: lesson.lessonId,
724
+ file: lesson.source.file,
725
+ key: entry.key,
726
+ glyph: name,
727
+ message: `${lesson.lessonId}: ${entry.key} (${entry.notation}) has its own entry, so its card wins over the ${JSON.stringify(name)} operator (${how}) for that form; if ${JSON.stringify(name)} never means the operator on this page, suspend it (notation.suspend)`,
728
+ });
729
+ }
730
+ }
731
+ }
732
+ }
733
+
569
734
  const CHECKS: ((
570
735
  input: ConsistencyInput,
571
736
  out: ConsistencyDiagnostic[],
@@ -580,6 +745,8 @@ const CHECKS: ((
580
745
  checkGlossNameShape,
581
746
  checkOperatorExplained,
582
747
  checkRomanLabelExplained,
748
+ checkAccentExplained,
749
+ checkOperatorSuspension,
583
750
  ];
584
751
 
585
752
  /**
@@ -618,6 +785,10 @@ export function consistencyCounts(
618
785
  'gloss-name-shape': 0,
619
786
  'operator-explained': 0,
620
787
  'roman-label-explained': 0,
788
+ 'accent-explained': 0,
789
+ 'operator-suspended': 0,
790
+ 'operator-suspension-reason': 0,
791
+ 'operator-shadowed': 0,
621
792
  } satisfies Record<ConsistencyCode, number>;
622
793
  for (const diagnostic of diagnostics) counts[diagnostic.code] += 1;
623
794
  return counts;
@@ -9,8 +9,9 @@
9
9
  *
10
10
  * This deliberately parses only sizing syntax. It does not decide whether a
11
11
  * parenthesis, bracket, or brace is semantic, and it never recommends changing
12
- * one delimiter shape into another. N16's alternating-shape preference remains
13
- * a review-only authoring concern.
12
+ * one delimiter shape into another — N17's other half, that a delimiter's shape
13
+ * is fixed by its meaning and never alternates to show depth, stays a
14
+ * review-only authoring concern (which is why N17 is `partial`).
14
15
  */
15
16
 
16
17
  export const DELIMITER_SIZE_DIAGNOSTIC_CODE = 'delimiter-manual-size' as const;
@@ -11,7 +11,8 @@ export type DiagramDiagnosticCode =
11
11
  | 'dia-invalid-key'
12
12
  | 'dia-duplicate-key'
13
13
  | 'dia-ref-resolves'
14
- | 'dia-key-unused';
14
+ | 'dia-key-unused'
15
+ | 'dia-removed-prop';
15
16
 
16
17
  export interface DiagramDiagnostic {
17
18
  readonly code: DiagramDiagnosticCode;
@@ -29,6 +30,13 @@ export interface DiagramValidation extends KeyedFloatValidation {
29
30
  readonly diagnostics: readonly DiagramDiagnostic[];
30
31
  }
31
32
 
33
+ /**
34
+ * The fix for a compaction prop: a diagram is always its graph and then its
35
+ * caption line (docs/plans/figures-and-short-captions.md, decision 7).
36
+ */
37
+ export const DIAGRAM_NO_LONGER_FOLDS =
38
+ 'a diagram no longer folds by itself — delete the prop. To let a reader fold it away, wrap it in a `<Fold>` or put it in a collapsible section.';
39
+
32
40
  export function validateDiagrams(
33
41
  lessons: readonly DiagramLessonInput[],
34
42
  ): DiagramValidation {
@@ -36,5 +44,13 @@ export function validateDiagrams(
36
44
  tagName: 'Diagram',
37
45
  prefix: 'dia',
38
46
  noun: 'diagram',
47
+ removedProps: {
48
+ label:
49
+ 'rename it `caption` (shown below the diagram with its number), and add an optional `short` caption if the caption is long.',
50
+ title:
51
+ 'a diagram is named by its `caption` (shown below it with its number) and an optional `short` caption.',
52
+ defaultOpen: DIAGRAM_NO_LONGER_FOLDS,
53
+ rememberState: DIAGRAM_NO_LONGER_FOLDS,
54
+ },
39
55
  }) as DiagramValidation;
40
56
  }
@@ -15,6 +15,8 @@ import { parseEquationRef, scanEquationLabels } from './equations.mjs';
15
15
  export interface EquationLabelRecord {
16
16
  readonly key: string;
17
17
  readonly number: string;
18
+ /** The equation's `\caption{…}` short caption, when it has one. */
19
+ readonly short?: string;
18
20
  readonly lessonId: string;
19
21
  readonly slug: string;
20
22
  readonly section: number;
@@ -54,12 +56,13 @@ export interface EquationLessonInput {
54
56
  readonly file: string;
55
57
  readonly body: string;
56
58
  /**
57
- * `{ prefix, offset }` — the part's 1-based ordinal among the sidebar groups,
58
- * and how many display equations earlier lessons of that part already used.
59
- * When set, equations are numbered `‹part›.‹offset + n›`; when omitted, the
60
- * legacy `##`-section scheme. See `docs/authoring/structure.md`.
59
+ * `{ prefix, offset }` — the lesson's float-number prefix (`‹part›.‹chapter›`,
60
+ * or `‹part›` under `numbering.floats: "part"`) and how many display
61
+ * equations precede the lesson in that scope. When set, equations are
62
+ * numbered `‹prefix›.‹offset + n›`; when omitted, the legacy `##`-section
63
+ * scheme. See `docs/authoring/structure.md`.
61
64
  */
62
- readonly numbering?: { readonly prefix: number; readonly offset: number };
65
+ readonly numbering?: { readonly prefix: number | string; readonly offset: number };
63
66
  }
64
67
 
65
68
  const BRACKET_REF = /\[\[\s*([^\][]+?)\s*\]\]/g;
@@ -100,6 +103,7 @@ export function validateEquations(
100
103
  for (const raw of scanEquationLabels(lesson.body, lesson.numbering) as {
101
104
  key: string;
102
105
  number: string;
106
+ short: string | null;
103
107
  section: number;
104
108
  indexInSection: number;
105
109
  }[]) {
@@ -119,6 +123,7 @@ export function validateEquations(
119
123
  labels.push({
120
124
  key: raw.key,
121
125
  number: raw.number,
126
+ ...(raw.short ? { short: raw.short } : {}),
122
127
  lessonId: lesson.lessonId,
123
128
  slug: lesson.slug,
124
129
  section: raw.section,