explico 0.4.0 → 0.6.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 (150) hide show
  1. package/CHANGELOG.md +382 -1
  2. package/README.md +103 -8
  3. package/dist/compiler/astro.d.ts +9 -1
  4. package/dist/compiler/astro.js +182 -3
  5. package/dist/compiler/cli.d.ts +13 -0
  6. package/dist/compiler/cli.js +28 -4
  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 +88 -1
  10. package/dist/compiler/course-config.js +35 -1
  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/manifest.d.ts +98 -6
  14. package/dist/compiler/manifest.js +154 -17
  15. package/dist/compiler/resolution-report.d.ts +4 -0
  16. package/dist/reference/base-abbreviations.d.mts +30 -0
  17. package/dist/reference/base-abbreviations.mjs +60 -0
  18. package/dist/reference/base-operators.d.mts +15 -0
  19. package/dist/reference/base-operators.mjs +40 -0
  20. package/dist/reference/base-roman-labels.d.mts +35 -0
  21. package/dist/reference/base-roman-labels.mjs +43 -0
  22. package/dist/reference/consistency.d.ts +8 -2
  23. package/dist/reference/consistency.js +50 -3
  24. package/dist/reference/diagrams-validate.d.ts +21 -0
  25. package/dist/reference/diagrams-validate.js +13 -0
  26. package/dist/reference/equations.d.mts +10 -15
  27. package/dist/reference/equations.mjs +67 -14
  28. package/dist/reference/gate-abbreviations.d.ts +17 -0
  29. package/dist/reference/gate-abbreviations.js +64 -0
  30. package/dist/reference/gate-heading-length.d.ts +12 -0
  31. package/dist/reference/gate-heading-length.js +79 -0
  32. package/dist/reference/gate-math.d.ts +32 -5
  33. package/dist/reference/gate-math.js +6 -2
  34. package/dist/reference/gate-prose-notation.d.ts +60 -0
  35. package/dist/reference/gate-prose-notation.js +125 -0
  36. package/dist/reference/keyed-float-validate.d.ts +5 -5
  37. package/dist/reference/keyed-jsx-floats.d.mts +7 -12
  38. package/dist/reference/keyed-jsx-floats.mjs +50 -25
  39. package/dist/reference/knowledge-checks-validate.d.ts +35 -0
  40. package/dist/reference/knowledge-checks-validate.js +78 -0
  41. package/dist/reference/knowledge-checks.d.mts +25 -0
  42. package/dist/reference/knowledge-checks.mjs +45 -0
  43. package/dist/reference/lab-math-scope.d.ts +9 -1
  44. package/dist/reference/lab-math-scope.js +32 -4
  45. package/dist/reference/math-glyphs.d.mts +42 -0
  46. package/dist/reference/math-glyphs.mjs +548 -40
  47. package/dist/reference/mdx-attributes.d.mts +13 -0
  48. package/dist/reference/mdx-attributes.mjs +38 -0
  49. package/dist/reference/mixed-content.d.ts +17 -0
  50. package/dist/reference/mixed-content.js +29 -0
  51. package/dist/reference/page-notation.d.ts +52 -0
  52. package/dist/reference/page-notation.js +171 -0
  53. package/dist/reference/raw-floats-validate.d.ts +18 -0
  54. package/dist/reference/raw-floats-validate.js +53 -0
  55. package/dist/reference/raw-floats.d.mts +18 -0
  56. package/dist/reference/raw-floats.mjs +129 -0
  57. package/dist/reference/rehype-heading-anchors.d.mts +1 -1
  58. package/dist/reference/rehype-heading-anchors.mjs +107 -1
  59. package/dist/reference/rehype-table-header-labels.d.mts +26 -0
  60. package/dist/reference/rehype-table-header-labels.mjs +152 -0
  61. package/dist/reference/remark-abbreviations.d.mts +7 -0
  62. package/dist/reference/remark-abbreviations.mjs +174 -0
  63. package/dist/reference/remark-citation.d.mts +5 -1
  64. package/dist/reference/remark-citation.mjs +0 -0
  65. package/dist/reference/remark-memo.d.mts +16 -0
  66. package/dist/reference/remark-memo.mjs +339 -0
  67. package/dist/reference/remark-notation.d.mts +57 -9
  68. package/dist/reference/remark-notation.mjs +354 -42
  69. package/dist/reference/remark-short-note.d.mts +4 -0
  70. package/dist/reference/remark-short-note.mjs +237 -0
  71. package/dist/reference/types.d.ts +16 -1
  72. package/dist/reference/units-vocab.js +4 -0
  73. package/docs/VERSIONING.md +53 -4
  74. package/docs/authoring/README.md +10 -0
  75. package/docs/authoring/agents.md +17 -2
  76. package/docs/authoring/components.md +138 -0
  77. package/docs/authoring/migrating.md +275 -7
  78. package/docs/authoring/notation.md +615 -1
  79. package/docs/authoring/structure-rules.md +69 -0
  80. package/docs/authoring/structure.md +120 -8
  81. package/docs/authoring/visualizations.md +36 -3
  82. package/package.json +5 -2
  83. package/src/compiler/astro.ts +208 -3
  84. package/src/compiler/cli.ts +29 -2
  85. package/src/compiler/collections.ts +45 -4
  86. package/src/compiler/course-config.ts +132 -0
  87. package/src/compiler/exports/standalone-html.ts +963 -0
  88. package/src/compiler/manifest.ts +281 -22
  89. package/src/components/HoverPanel.astro +45 -4
  90. package/src/components/abbreviation/AbbreviationGlossary.astro +70 -0
  91. package/src/components/abbreviation/AbbreviationLayer.astro +127 -0
  92. package/src/components/assessments/AssessmentRunner.tsx +52 -17
  93. package/src/components/assessments/AssessmentSet.astro +98 -14
  94. package/src/components/assessments/RenderedContent.tsx +55 -0
  95. package/src/components/assessments/assessment-randomization.ts +16 -0
  96. package/src/components/diagram/Diagram.astro +409 -103
  97. package/src/components/disclosure/hover-preview.ts +202 -0
  98. package/src/components/disclosure/persisted-disclosure.ts +66 -0
  99. package/src/components/examples/CompactExamples.astro +2 -0
  100. package/src/components/figure/Figure.astro +279 -10
  101. package/src/components/memo/Memo.astro +453 -0
  102. package/src/components/memo/ShortNote.astro +223 -0
  103. package/src/components/notation/NotationFormula.astro +18 -1
  104. package/src/components/notation/NotationLayer.astro +260 -83
  105. package/src/components/notation/render-notation-math.ts +39 -2
  106. package/src/components/notation/safe-math-to-react.ts +50 -0
  107. package/src/components/reference/FloatRefLayer.astro +233 -15
  108. package/src/components/starlight/CollapsibleSections.astro +461 -0
  109. package/src/components/starlight/DiagramEnhancer.astro +385 -0
  110. package/src/components/starlight/HeadingAnchorEnhancer.astro +5 -17
  111. package/src/components/starlight/KeyedFloatEnhancer.astro +81 -3
  112. package/src/components/starlight/LayoutHeader.astro +50 -0
  113. package/src/components/starlight/LayoutSocialIcons.astro +41 -2
  114. package/src/components/starlight/LessonExportActions.astro +436 -0
  115. package/src/components/starlight/LessonFooter.astro +30 -3
  116. package/src/components/starlight/section-parts.ts +31 -0
  117. package/src/components/table/Table.astro +293 -7
  118. package/src/components/table/TableOverflowExpander.astro +489 -0
  119. package/src/content-config.ts +36 -1
  120. package/src/reference/base-abbreviations.mjs +60 -0
  121. package/src/reference/base-operators.mjs +40 -0
  122. package/src/reference/base-roman-labels.mjs +43 -0
  123. package/src/reference/consistency.ts +73 -2
  124. package/src/reference/diagrams-validate.ts +40 -0
  125. package/src/reference/equations.mjs +67 -14
  126. package/src/reference/gate-abbreviations.ts +128 -0
  127. package/src/reference/gate-heading-length.ts +131 -0
  128. package/src/reference/gate-math.ts +32 -7
  129. package/src/reference/gate-prose-notation.ts +224 -0
  130. package/src/reference/keyed-float-validate.ts +5 -5
  131. package/src/reference/keyed-jsx-floats.mjs +50 -25
  132. package/src/reference/knowledge-checks-validate.ts +130 -0
  133. package/src/reference/knowledge-checks.mjs +45 -0
  134. package/src/reference/lab-math-scope.ts +45 -3
  135. package/src/reference/math-glyphs.mjs +548 -40
  136. package/src/reference/mdx-attributes.mjs +38 -0
  137. package/src/reference/mixed-content.ts +69 -0
  138. package/src/reference/page-notation.ts +252 -0
  139. package/src/reference/raw-floats-validate.ts +103 -0
  140. package/src/reference/raw-floats.mjs +129 -0
  141. package/src/reference/rehype-heading-anchors.mjs +107 -1
  142. package/src/reference/rehype-table-header-labels.mjs +152 -0
  143. package/src/reference/remark-abbreviations.mjs +174 -0
  144. package/src/reference/remark-citation.mjs +0 -0
  145. package/src/reference/remark-memo.mjs +339 -0
  146. package/src/reference/remark-notation.mjs +354 -42
  147. package/src/reference/remark-short-note.mjs +237 -0
  148. package/src/reference/types.ts +16 -0
  149. package/src/reference/units-vocab.ts +4 -0
  150. package/src/styles/global.css +996 -50
@@ -131,8 +131,87 @@ _How to apply._ See the engine README's notation section and the `notation`
131
131
  collection schema. A letter that exists only so one entry's own `formula` can
132
132
  be stated is a **gloss**, not a card — it creates no curriculum edge.
133
133
 
134
+ Do not satisfy this rule by dumping every `[[key]]` a page uses into one
135
+ trailing "Notation used on this page" (or similarly named) section near the
136
+ end of the lesson. `LessonFooter.astro` already injects an interactive
137
+ "Page notation" panel — built from the page's actual notation manifest, with
138
+ hover/click cards — into every lesson's footer automatically; a hand-written
139
+ list repeats the same term set as flat, non-interactive prose immediately
140
+ above it. Introduce each shared symbol inline, in the sentence where it is
141
+ first defined or used, the way `[[eq-key]]` and other cross-references are
142
+ already used inline elsewhere in these lessons. The completeness gate only
143
+ checks that a `[[key]]`/`\explain{key}` appears *somewhere* in the page body
144
+ (document order does not matter to it), but inline-at-first-use is what keeps
145
+ the rendered page free of a redundant list — and matches what a reader
146
+ actually wants: the symbol explained where it shows up, not gathered into an
147
+ appendix.
148
+
149
+ A page's own `notation.local` entries (declared in frontmatter) are already in
150
+ scope everywhere on that page for the *math completeness gate* — a bare
151
+ occurrence of a local symbol never trips "Unresolved notation" even with zero
152
+ `[[key]]`/`\explain` references. But a **different** check,
153
+ `assertLocalNotationParity` (run at `astro build`, not `pnpm validate:content`),
154
+ still requires every `notation.local` entry to be *referenced* somewhere in
155
+ the body — otherwise the build fails with "Stale Explico manifest for
156
+ ‹lesson›: lesson notation.local has […], but the page notation bundle has
157
+ […]." So every local entry still needs one `\explain{key}{…}` (or `[[key]]`)
158
+ mention, even though the math gate alone wouldn't ask for it.
159
+
160
+ The auto-annotator (`resolveMathGlyphs` in `math-glyphs.mjs`) re-derives every
161
+ `\explain{…}` marker's insertion point from KaTeX's own parsed offsets, then
162
+ re-applies those offsets to the raw source. Three shapes used to corrupt that
163
+ offset math badly enough to either silently drop a symbol (no tooltip, no
164
+ error) or emit unparseable output: a bare roman-label symbol (`\mathrm{SSR}`)
165
+ could never auto-resolve at all regardless of where it sat on the page; a bare
166
+ symbol immediately adjacent to an explicit `\explain{…}{…}` call could have
167
+ its span swallow the whole following command; and a canonical whole-form
168
+ match could be found _through_ an explicit call's own wrapping and extend
169
+ past its span. All three are fixed at the resolver, not just detected — a
170
+ bare `\mathrm{SST}`, `\epsilon\explain{feature-function}{h}`, and similar
171
+ shapes now resolve correctly with no special authoring care required, and
172
+ `gate-math.ts` exercises the same resolver at `pnpm validate:content` time
173
+ (it reuses `resolveMathGlyphs` directly), so a regression here fails the
174
+ build immediately rather than passing silently through to a render-time
175
+ surprise.
176
+
177
+ One related shape is still worth knowing, because it is a genuine authoring
178
+ mistake rather than a resolver bug: a partial `\explain{key}{…}` wrap that
179
+ covers only part of the key's declared `latex`. If `latex: Z_N` and you write
180
+ `\explain{z-statistic}{Z}_N` (base wrapped, subscript left outside), the
181
+ resolver no longer corrupts the output — but the uncovered remainder (`N`)
182
+ has no binding of its own and correctly fails loudly as "Unresolved
183
+ notation," at `pnpm validate:content` time. Wrap the whole declared form —
184
+ `\explain{z-statistic}{Z_N}` — instead; bare `Z_N` elsewhere on the page then
185
+ resolves automatically. The same applies to accent-wrapped forms
186
+ (`latex: \widehat{\theta}`): wrap the accent and its argument together, not
187
+ just the inner letter.
188
+
189
+ Math authored inside a nested Astro component is outside the lesson MDX scan,
190
+ so the component must declare the formula text at render time. `Diagram` and
191
+ `NotationFormula` do this automatically from the notation keys they actually
192
+ emit. A lab wrapper must pass its formula/template source and request context to
193
+ `labMathScope`:
194
+
195
+ ```astro
196
+ ---
197
+ const formula = String.raw`\explain{motion.launch-speed}{v_0}`;
198
+ const scope = await labMathScope(undefined, formula, {
199
+ locals: Astro.locals,
200
+ context: 'LaunchSummary fixed-speed formula',
201
+ });
202
+ const math = renderLabMath(formula, scope);
203
+ ---
204
+ ```
205
+
206
+ The registration is request-local: it adds only keys used by that component
207
+ (and their normal definition dependencies) to that lesson's popup payload. It
208
+ does not make the global registry page-wide. An unknown key fails with the
209
+ lesson and supplied component/formula context; a final Astro build check also
210
+ rejects any `data-notation-key` in generated HTML that has no matching
211
+ `data-notation-entry` source.
212
+
134
213
  _Enforcement._ Build-enforced (completeness gate + one-meaning-per-glyph
135
- consistency check).
214
+ consistency check + final rendered-trigger/payload parity check).
136
215
 
137
216
  _Exceptions._ None. Genuinely different meanings need different glyphs or
138
217
  different pages.
@@ -295,3 +374,538 @@ reviewed line in `lint-ignore.yml` (`operator-explained:Var — <reason>`). Like
295
374
 
296
375
  _Exceptions._ Quoting a source that uses an operator glyph **and** showing what
297
376
  it means in the same passage.
377
+
378
+ ---
379
+
380
+ ## N9
381
+
382
+ **A physical quantity, unit, or math operator is typeset in `$…$`, never as
383
+ plain text — in prose, a table cell, an assessment `prompt` / `explanation`,
384
+ or a caption that itself lives outside math.**
385
+
386
+ If it would be written in italics or a special symbol in a printed textbook —
387
+ a variable, a subscript or superscript, a unit abbreviation, an absolute-value
388
+ bar, a division of two symbols — it goes in `$…$` (or `$$…$$`), full stop, no
389
+ matter where on the page it sits. `45 m/s`, `t (s)`, `vx`, `\|v\|`, `KE/m` are
390
+ all the same mistake: notation typed as if the reader's browser were a plain
391
+ text file instead of the KaTeX pipeline this engine already ships.
392
+
393
+ A unit inside math is still upright roman, never italic — SI symbols are
394
+ upright in every printed textbook too (`\text{m/s}`, not a bare italic `m/s`,
395
+ which KaTeX would render as the product of two variables `m` and `s`). Wrap
396
+ the unit portion in `\text{}` and fold it into the _same_ math span as the
397
+ symbol it modifies (`$t\ \text{(s)}$`, not two adjacent spans `$t$
398
+ $\text{(s)}$`) — and prefer the short symbol over the spelled-out word even
399
+ inside `\text{}` (`\text{(s)}`, not `\text{(seconds)}`), the same way a
400
+ variable is `v_0` and not "initial velocity" once it's introduced. This does
401
+ **not** cover a fully spelled-out unit used as an ordinary English noun in a
402
+ sentence — "the ball leaves at 10 m/s, 30 **degrees** above the horizontal"
403
+ keeps "degrees" as a word; that carve-out is about flowing prose, not a
404
+ compact data label like a table header or a computed answer.
405
+
406
+ This also does not apply inside a JSX component prop (`<Figure caption="…">`,
407
+ `<Table caption="…">`, `title="…"`) — a prop string is handed to the DOM as
408
+ plain text with no math rendering at all, so `$…$` there would show up as
409
+ broken literal dollar signs instead of rendering; write the unit as a word or
410
+ the symbol as plain Unicode there instead ("peaks at 45°" in a caption is
411
+ fine).
412
+
413
+ _Rationale._ A reader who can hover `$v_0$` for its notation card gets nothing
414
+ from the same quantity spelled `v0` in a table header — the two look
415
+ interchangeable to a drafter but only one is inside the notation system this
416
+ course is built on — hover cards, one meaning per glyph, the completeness
417
+ gate. A table of sampled values is exactly where this slips in, because the
418
+ completeness gate ([N4](#n4)) only ever looks _inside_ `$…$` — text sitting
419
+ next to it was invisible to every check until this rule.
420
+
421
+ _How to apply._
422
+
423
+ - A table header cell that names a quantity uses the same bound symbol the
424
+ lesson already introduced, `\explain{key}{…}`-wrapped if the column needs a
425
+ component or a different style than the card's own notation (`v_x` for
426
+ `[[motion.velocity]]`'s `v(t)`, exactly as `motion.acceleration`'s own
427
+ `formula:` already does for `\explain{motion.velocity}{\mathbf{v}}`), with
428
+ its unit folded into the same span as a `\text{}` suffix:
429
+
430
+ ```text
431
+ | $t\ \text{(s)}$ | $\explain{motion.velocity}{v_x}\ \text{(m/s)}$ |
432
+ ```
433
+
434
+ - An absolute value or norm is `\lvert…\rvert`, never a literal `|` — a raw
435
+ `|` inside a Markdown table cell also collides with the table's own column
436
+ delimiter, which is why it shows up escaped (`\|v\|`) instead of rendering
437
+ as anything.
438
+
439
+ - A specific (per-unit) quantity like kinetic energy per unit mass is
440
+ `\mathrm{KE}/m`, not `KE/m` — multi-letter roman labels still take
441
+ `\mathrm{}` ([N3](#n3)) inside the math span.
442
+
443
+ - A unit exponent is a real LaTeX superscript (`\text{m/s}^2`), never the
444
+ Unicode "²"/"³" character — KaTeX's strict mode rejects those as unknown
445
+ symbols (`src/reference/math-glyphs.mjs`'s `parseMath` calls
446
+ `katex.__parse(latex, { strict: 'error' })` on every math span the
447
+ completeness gate collects, table headers included), so this is already
448
+ build-enforced via [N4](#n4)'s existing gate, not a separate check N9 needs
449
+ of its own. This is exactly why a unit has to be _inside_ `$…$` in the
450
+ first place: outside math, nothing parses the LaTeX at all, so a stray "²"
451
+ typed as plain Unicode text would sail through unchecked.
452
+
453
+ - A header cell with **no plain-language word next to its symbol** — a bare
454
+ `$t$`, `$x$`, `$v_x$` column, not `Launch speed $\text{(m/s)}$` — ends up
455
+ 100% KaTeX output, which axe-core's `empty-table-header` check flags as
456
+ having no text "visible to screen readers" (it does not read into `<math>`
457
+ for the accessible-name computation, so a header that is entirely `$…$`
458
+ reads as empty to it even though a sighted or MathML-supporting reader sees
459
+ the symbol fine). This is handled automatically, not by writing anything
460
+ differently: `rehype-table-header-labels.mjs` (wired into
461
+ `playground/astro.config.mjs`'s `rehypePlugins`, right after
462
+ `rehypeKatex`) finds any `<th>` with no visible text, reads the
463
+ `\explain{key}{…}` key(s) its LaTeX names, and inserts a visually-hidden
464
+ (`sr-only`) text node from that key's own `label` / `units` notation
465
+ metadata — a fuller, more descriptive accessible name than the visible
466
+ abbreviation ever showed, generated for free from data already authored.
467
+ It has to be real (`sr-only`-clipped, not `display:none`) content, not
468
+ just an `aria-label` attribute: axe-core's `empty-table-header` check
469
+ specifically runs `has-visible-text` (real text content), with no
470
+ `aria-label`/`aria-labelledby` alternative the way most of its other
471
+ name-checking rules allow — confirmed by reading the rule itself in
472
+ `node_modules/axe-core/axe.js`. The one thing this means for an author: a
473
+ header's symbol has to actually resolve to a key for this to have anything
474
+ to work with. A bare `$t$`/`$x$` already does, via the ordinary `[[key]]`
475
+ intro + auto-resolution every other bound glyph on the page uses; the one
476
+ case that doesn't is a `\mathrm{…}` compound like `\mathrm{KE}/m`, because
477
+ the glyph resolver never looks inside a bare `\mathrm{}` body ([N3](#n3),
478
+ [N10](#n10)) — explicitly `\explain{}`-wrap each side there
479
+ (`\explain{kinetic-energy}{\mathrm{KE}}/\explain{mass}{m}`) so the plugin
480
+ has something to read; a header with more than one bound key composes them
481
+ joined by `" / "`.
482
+
483
+ _Enforcement._ Build-enforced (`gate-prose-notation.ts`, wired into the same
484
+ `pnpm validate:content` / `astro build` completeness gate as [N4](#n4), context
485
+ `prose`). `gateProseNotation` scans lesson prose and table cells — with code
486
+ spans, `$…$` math, `[[key]]` references, `import` lines, and JSX tags already
487
+ blanked out; `gateProseNotationInAssessments` runs the same checks over every
488
+ assessment item's `prompt` / `explanation` string, since `AssessmentRunner`
489
+ renders both through the same KaTeX pipeline as lesson body math
490
+ (`manifest.assessmentContent`, schema v8) — a plain-text quantity there is
491
+ just as invisible to a hover card as one in a lesson. Both look for: an
492
+ escaped `\|`; a numeral glued to an abbreviated unit (`45 m/s`); a bare unit
493
+ in parens (`(s)`, `(m/s²)`); a hand-typed `_`/`^` between alphanumerics; and a
494
+ `/` between two short alphabetic tokens that isn't on a small reviewed idiom
495
+ allowlist (`and/or`, `his/her`, …) in the check's own source. Unlike every
496
+ other build-enforced rule here, N9 ships as a hard error
497
+ with no Tier-3 warming-up period and no `lint-ignore.yml` entry, because it
498
+ landed in the same change that brought the whole corpus to zero — see the
499
+ check's file for the exact patterns and the allowlist.
500
+
501
+ _Exceptions._ None acknowledged through `lint-ignore.yml` — that registry is
502
+ for corpus-level warnings a reviewer accepts, and N9 has no warning tier to
503
+ accept into. A genuinely non-mathematical slash idiom (`input/output`, …) is
504
+ added to the allowlist in `gate-prose-notation.ts` itself, reviewed like any
505
+ other change to this file.
506
+
507
+ ---
508
+
509
+ ## N10
510
+
511
+ **A `\mathrm{…}` multi-letter label is universal, or it is explained.**
512
+
513
+ Every multi-letter roman label a lesson uses in math — `\mathrm{KE}`,
514
+ `\mathrm{PV}`, `\mathrm{opt}` ([N3](#n3)) — is either on the shipped
515
+ roman-label baseline (`src/reference/base-roman-labels.mjs` plus this
516
+ course's `romanLabels.allow` in `course.config.ts`) or it carries a notation
517
+ entry the reader can hover.
518
+
519
+ _Rationale._ N3 fixes the _format_ (`\mathrm{}`, never bare letters) but is
520
+ silent on whether the label is ever explained, because — same as an
521
+ `\operatorname{}` body ([N8](#n8)) — the glyph resolver skips a `\mathrm{}`
522
+ body whole; nothing about satisfying N3 requires a definition to exist. N9's
523
+ enforcement fixed one direction of this exact page (`KE`/`PE` typed as plain
524
+ text); this rule closes the other: `KE`/`PE` typed correctly as `\mathrm{}`
525
+ but with no card at all, which N9 cannot see because the notation is already
526
+ inside `$…$`.
527
+
528
+ _How to apply._
529
+
530
+ - A generic qualifier that functions like an operator's extremum subscript —
531
+ `x_{\mathrm{opt}}`, an "optimal" reading with no quantity of its own — just
532
+ works. The full baseline is the greppable set in `base-roman-labels.mjs`,
533
+ kept deliberately small: unlike a trig function, an upright label is almost
534
+ always a named quantity specific to one course, so it defaults to needing a
535
+ card rather than defaulting to universal.
536
+
537
+ - A named quantity gets a notation entry, exactly like a glyph ([N4](#n4)) —
538
+ a shared card if the course teaches it broadly, otherwise `notation.local`,
539
+ same shape as [N9](#n9)'s `mass` example:
540
+
541
+ ```yaml
542
+ key: kinetic-energy
543
+ latex: \mathrm{KE}
544
+ label: Kinetic energy
545
+ meaning: The energy an object has from its motion.
546
+ ```
547
+
548
+ Then introduce it in prose with `[[kinetic-energy]]` before its first
549
+ appearance in math.
550
+
551
+ - A course whose domain treats a label as common lists it once in
552
+ `course.config.ts` instead:
553
+
554
+ ```ts
555
+ romanLabels: { allow: ['PV', 'NPV'] }
556
+ ```
557
+
558
+ Names are the `\mathrm{…}` body. `romanLabels.deny` forces a baseline label
559
+ to be explained anyway.
560
+
561
+ _Enforcement._ Tier-3 warning (`roman-label-explained`), surfaced by
562
+ `pnpm validate:content`, recorded in the manifest, and silenced only by a
563
+ reviewed line in `lint-ignore.yml` (`roman-label-explained:KE — <reason>`).
564
+ Same rollout shape as [N8](#n8): the tier can be promoted once a corpus is
565
+ clean.
566
+
567
+ _Exceptions._ Quoting a source that uses a roman-label glyph **and** showing
568
+ what it means in the same passage. A single-letter `\mathrm{…}` body (`d`,
569
+ [N7](#n7)) is out of scope entirely — that is a fixed typographic operator,
570
+ not a named quantity.
571
+
572
+ ---
573
+
574
+ ## N11
575
+
576
+ **A worked example never drops a unit from a substituted value, and its
577
+ result is dimensionally consistent.**
578
+
579
+ The moment a symbol in an equation is replaced by a number — in a
580
+ `CompactExample`, an assessment `explanation`, a derivation step, anywhere —
581
+ that number keeps the unit its symbol carried. Never substitute bare numbers
582
+ and attach a unit only to the final answer:
583
+
584
+ ```text
585
+ v(4) = 5 + 2 \times 4 = 13\ \text{m/s} ✗ 5 and 2 have silently lost their units
586
+ v(4) = 5\ \text{m/s} + 2\ \text{m/s}^2 \times 4\ \text{s} = 13\ \text{m/s} ✓
587
+ ```
588
+
589
+ The mirror failure is a result whose unit does not actually follow from the
590
+ arithmetic — copy-pasting the previous line's unit onto a different quantity.
591
+ `v = s/t = 10/5 = 2\ \text{s}` is wrong twice over: the substitution dropped
592
+ $s$'s and $t$'s units, which is what let `2` pick up `\text{s}` (a time unit)
593
+ for what is actually a velocity — the arithmetic $10/5$ alone is just the
594
+ number 2, and nothing about it says "seconds". Carried through, the units
595
+ themselves catch the mistake: $10\ \text{m} / 5\ \text{s} = 2\ \text{m/s}$
596
+ leaves no room to mislabel the answer, which is the entire point of the rule.
597
+
598
+ _Rationale._ A worked example is the one place a reader checks their own
599
+ understanding by redoing the arithmetic. If the displayed steps drop units,
600
+ the example teaches "plug in the numbers" instead of "the units are part of
601
+ the equation" — and it stops being able to catch the author's own dimensional
602
+ mistakes, since a number has no unit to contradict. N9 already forces every
603
+ quantity into `$…$` with its unit properly typeset; this rule is the
604
+ complementary content requirement that the _substitution steps themselves_
605
+ keep carrying those units all the way to the answer, not just the input
606
+ values and the final line.
607
+
608
+ _How to apply._
609
+
610
+ - Attach each value's unit with `\text{}` at the moment it replaces its
611
+ symbol, exactly as [N9](#n9) already typesets a bound quantity — folded
612
+ into the same math term as its number, not a separate span
613
+ (`2\ \text{m/s}^2`, not `2` and `\text{m/s}^2` as two adjacent `$…$`).
614
+ - Carry the units through every intermediate line a reader can see, not just
615
+ the first substitution and the last answer. If the full chain would be
616
+ unreasonably long, it is fine to skip straight from substitution to answer
617
+ for a single-step calculation — the rule is about not _losing_ units
618
+ mid-derivation, not about spelling out unit algebra for its own sake.
619
+ - A ratio of two same-unit quantities (`\arctan(y/x)` for two lengths) is
620
+ dimensionless by cancellation; leaving it unitless is correct, not an
621
+ omission — write the units only where they actually survive to the result.
622
+ - Squaring or otherwise combining a substituted value carries its unit
623
+ through the operation: `(3\ \text{m})^{2} = 9\ \text{m}^2`, not `3^2 =
624
+ 9\ \text{m}^2` with the unit reattached after the fact.
625
+
626
+ _Enforcement._ Review-only. Checking that a substitution step's units are
627
+ _present_ is straightforward text matching, but checking that they are
628
+ _dimensionally correct_ — that `m/s²` times `s` really does collapse to
629
+ `m/s` — is a small computer-algebra problem, not a pattern match; no gate in
630
+ this repo attempts it. A reviewer reads the worked example the way a student
631
+ would: redo the substitution and confirm every number's unit, then confirm
632
+ the arithmetic on the units themselves lands on the stated result's unit.
633
+
634
+ _Exceptions._ A dimensionless ratio or angle carries no unit to begin with
635
+ (see "how to apply" above) — that is not an exception, just correctly having
636
+ nothing to attach.
637
+
638
+ ---
639
+
640
+ ## N12
641
+
642
+ **Math syntax never carries the sentence's own trailing punctuation.**
643
+
644
+ A period that ends a sentence, or a comma that joins it to the next clause,
645
+ stays in the prose — outside `$…$` / `$$…$$` — even when the math is the last
646
+ thing before it. Never let it drift inside the span, and never bury it inside
647
+ a trailing `\text{}` unit:
648
+
649
+ ```text
650
+ $$
651
+ \rho = \sqrt{3^2 + 4^2} = 5\ \text{m,} ✗ the comma is now part of the LaTeX
652
+ $$
653
+
654
+ $$
655
+ \rho = \sqrt{3^2 + 4^2} = 5\ \text{m} ✓ drop it — the next sentence needs no connector here
656
+ $$
657
+ ```
658
+
659
+ This is about the sentence's _own_ punctuation riding along with the math by
660
+ accident, not about punctuation that is itself part of the mathematical
661
+ notation — a comma separating two side-by-side equations in one display block
662
+ (`x = \ldots, \qquad y = \ldots`) is a list separator inside the math, not
663
+ prose leaking in, and is unaffected by this rule.
664
+
665
+ _Rationale._ A trailing period or comma inside `$…$` becomes part of the
666
+ LaTeX source: KaTeX renders it in math italic/spacing rather than as
667
+ punctuation, it is included in whatever `\text{}` it landed inside (turning a
668
+ unit label `\text{m}` into the unrelated string `\text{m,}`), and a reader
669
+ who copies the equation out of the rendered page copies the stray mark with
670
+ it. A display equation followed by a `\label{eq:…}` and then a fresh
671
+ paragraph — the pattern most of this corpus already uses — never has this
672
+ problem in the first place, which is the simplest fix in practice.
673
+
674
+ _How to apply._ Omit the connector. A display equation does not need a
675
+ trailing comma to link it to the sentence that continues below it — the
676
+ paragraph break already does that job, and every rule-following equation in
677
+ this corpus (see [N5](#n5)'s `\label{eq:…}` pattern) already ends bare. For
678
+ inline math (`$x = 5$` mid-sentence), put the period or comma after the
679
+ closing `$`, where it has always belonged.
680
+
681
+ _Enforcement._ Review-only. A gate could, in principle, flag `.` or `,` as
682
+ the last character before a closing `$`/`$$`, but it cannot tell a stray
683
+ sentence-ending mark from a legitimate mathematical use of the same
684
+ character (a decimal point, a `\ldots`, a matrix row separator) without
685
+ false positives outweighing the catches — left to review, the same as
686
+ [N5](#n5).
687
+
688
+ _Exceptions._ A comma or period that is itself notation — a decimal point in
689
+ a number, `\ldots`, a matrix/vector component separator — is not this rule's
690
+ target and stays exactly where the math needs it.
691
+
692
+ ---
693
+
694
+ ## N13
695
+
696
+ **A nonlinear function always takes a parenthesized argument, even a single
697
+ symbol.**
698
+
699
+ Every use of a trigonometric, inverse-trigonometric, or hyperbolic function,
700
+ and every logarithm or exponential, wraps its argument in a visible
701
+ parenthesis (or bracket) — never a bare symbol or number sitting directly
702
+ after the function name:
703
+
704
+ ```text
705
+ \sin x = \sin y ✗
706
+ \sin(x) = \sin(y) ✓
707
+
708
+ log x ✗
709
+ \log(x) ✓
710
+
711
+ \sin^2 x ✗ — the exponent doesn't excuse the missing parens
712
+ \sin^2(x) ✓
713
+ ```
714
+
715
+ _Rationale._ `\sin x` leaves how far the argument extends ambiguous the
716
+ instant anything follows it — `\sin x + 1` visually reads as "sine of x,
717
+ plus one" but a reader has to already know the convention to be sure it
718
+ isn't "sine of (x + 1)". `\sin(x)` is unambiguous regardless of what
719
+ surrounds it, at the cost of nothing when the argument really is a single
720
+ symbol. Textbooks vary on this; this course picks the form that never
721
+ requires the reader to know the convention.
722
+
723
+ _How to apply._
724
+
725
+ - Wrap the argument in `(...)` — or `\left( ... \right)` when it needs to
726
+ grow with its contents, or `[...]`/`\left[ ... \right]` when an outer
727
+ pair of parentheses is already in use for something else on the same
728
+ line. A bare `{...}` does not count: LaTeX braces are invisible grouping,
729
+ so `\sin{x}` renders identically to the forbidden `\sin x`.
730
+ - An exponent between the function and its argument does not exempt it —
731
+ `\sin^{2}(x)`, `\log_{2}(x)`, `\sin^{-1}(x)` all still need the
732
+ parenthesis after the modifier.
733
+ - The function covered by this rule is exactly
734
+ [`PARENTHESIZED_FUNCTIONS`](../../src/reference/base-operators.mjs): the
735
+ six trigonometric functions, `arcsin`/`arccos`/`arctan`, the four
736
+ hyperbolic functions in the base operator library, and `exp`/`log`/`ln`/
737
+ `lg`. A different named operator ([N8](#n8)'s `\operatorname{Var}` and
738
+ friends) is unaffected — this rule is scoped to the functions whose
739
+ argument's extent is genuinely ambiguous without a mark, not every named
740
+ operator.
741
+
742
+ _Enforcement._ Build-enforced, with no partial-corpus warm-up tier — like
743
+ [N9](#n9), it ships as a hard error. `resolveMathGlyphs`
744
+ (`src/reference/math-glyphs.mjs`) walks the parsed KaTeX tree for every
745
+ rendered-math context this engine has — the same single resolver [N1](#n1)
746
+ through [N10](#n10) already rely on — and fails the moment a
747
+ `PARENTHESIZED_FUNCTIONS` name is not immediately followed by a visible
748
+ opening delimiter. Because every context (lesson body, `notation.formula`,
749
+ assessment `prompt`/`explanation`, a `Diagram` node's `formula`, interactive
750
+ lab math) calls this one function, the check needs no separate wiring per
751
+ content type: `pnpm validate:content` catches it in lesson body, formula, and
752
+ assessment math; `astro build` catches a `Diagram` or lab-math violation
753
+ (these are authored outside any manifest-scanned MDX body, in a component's
754
+ own `nodes={[...]}` or lab template) as a hard render failure, since neither
755
+ `Diagram` nor lab math has a code path that tolerates degrading a formula
756
+ silently for this rule the way an ambiguous-glyph error can.
757
+
758
+ _Exceptions._ None. A quoted source that uses `\sin x` still gets
759
+ parenthesized in this course's rendering — the parenthesis is a typesetting
760
+ choice, not a claim about the source's own notation, so quoting accurately
761
+ does not require reproducing it.
762
+
763
+ ---
764
+
765
+ ## N14
766
+
767
+ **An abbreviation-shaped word in prose is universal, or it is explained.**
768
+
769
+ Every all-caps acronym (`OLS`, `MSE`, `SSR`) or dotted abbreviation (`w.r.t.`,
770
+ `i.i.d.`) a lesson uses in ordinary prose — outside any `$…$` — is either on
771
+ the shipped abbreviation baseline (`src/reference/base-abbreviations.mjs`
772
+ plus this course's `abbreviations.allow` in `course.config.ts`), carries an
773
+ entry in the `abbreviations` content collection
774
+ (`content/abbreviations/*.md`), or is already explained as a math quantity on
775
+ the same page ([N8](#n8) / [N10](#n10)). A term resolved by the table is not
776
+ just build-checked: `remarkAbbreviations` wraps its first occurrence on the
777
+ page in the same hover/click card the math-notation and citation systems
778
+ already use, linking to its entry on the glossary page.
779
+
780
+ _Rationale._ [N9](#n9) already forces a _math_ quantity or unit into `$…$` so
781
+ it gets a hover card; this rule closes the parallel gap for a term that reads
782
+ as English, not math — a reader who knows `\mathrm{MSE}` has a card two
783
+ sections later gets nothing from "the MSE is minimized" if that sentence
784
+ never binds to it. An abbreviation is exactly the kind of thing an
785
+ experienced writer stops noticing they haven't defined.
786
+
787
+ _How to apply._
788
+
789
+ - An abbreviation that names no quantity of its own — `OLS`, `w.r.t.`,
790
+ `i.i.d.` — gets an entry in `content/abbreviations/*.md`:
791
+
792
+ ```yaml
793
+ key: ols
794
+ abbr: OLS
795
+ expansion: Ordinary least squares — the estimator that minimizes the sum of squared residuals.
796
+ ```
797
+
798
+ This is a lighter tier than a notation card: a name resolution only, no
799
+ `formula`, `units`, or curriculum `alignment`. It has no authoring syntax
800
+ of its own in prose (no `[[key]]` needed) — the gate below recognizes the
801
+ literal written form (`abbr`, or one of its `aliases`) anywhere on the
802
+ page.
803
+
804
+ - An abbreviation that _is_ a math quantity — `MSE`, `SSR`, kinetic energy's
805
+ `KE` — is introduced the normal [N8](#n8)/[N10](#n10) way instead: a
806
+ notation entry whose `latex` is `\mathrm{MSE}` (or an `\operatorname{…}`),
807
+ bound with `[[key]]` or `\explain{key}{…}`. Once that entry is in scope on
808
+ a page, this rule already treats the bare word `MSE` in that page's prose
809
+ as explained — it does **not** also need an `abbreviations` table entry.
810
+
811
+ - A course whose readers already know a term — `AI`, `GDP`, whatever the
812
+ domain treats as common — lists it once in `course.config.ts` instead:
813
+
814
+ ```ts
815
+ abbreviations: { allow: ['GDP', 'CPU'] }
816
+ ```
817
+
818
+ `abbreviations.deny` forces a shipped baseline entry (`e.g.`, `i.e.`, …) to
819
+ be explained anyway.
820
+
821
+ _Enforcement._ Build-enforced (`gate-abbreviations.ts`), wired into the same
822
+ `pnpm validate:content` pass as [N9](#n9), context `abbreviation`. It scans
823
+ the exact same "prose-only" surface N9 does (code, math, `[[key]]`
824
+ references, and JSX tags already blanked out), looking for an all-caps run of
825
+ 2+ letters or a run of 2+ single-letter-then-period groups. Like N9, it ships
826
+ as a hard error with no Tier-3 warm-up period and no `lint-ignore.yml` entry
827
+ — the corpus had no prior instances of either shape in prose when the check
828
+ was added.
829
+
830
+ _Exceptions._ None acknowledged through `lint-ignore.yml`. A genuinely
831
+ universal abbreviation this course's readers cannot be expected to look up is
832
+ added to `abbreviations.allow` in `course.config.ts`, reviewed like any other
833
+ change to that file.
834
+
835
+ ---
836
+
837
+ ## N15
838
+
839
+ **A nested visible delimiter — `(`, `[`, `\{`, or `|` inside another one of
840
+ them — must use its `\left`/`\right` sizing form, not the bare character.**
841
+
842
+ `(`, `[`, `\{` (an escaped, visible brace — bare `{`/`}` is invisible LaTeX
843
+ grouping and out of scope here), and `|` may be written bare only when they
844
+ are not nested — not inside one another, and not inside a second instance of
845
+ themselves. The moment one of them sits inside another, **every level of that
846
+ nest** switches to the auto-sizing form: `\left(`/`\right)`, `\left[`/`\right]`,
847
+ `\left\{`/`\right\}`, `\left|`/`\right|`.
848
+
849
+ ```text
850
+ \sin(2(90\text{°}-\theta)) ✗ two bare ( nested
851
+ \sin\left(2\left(90\text{°}-\theta\right)\right) ✓
852
+
853
+ \left(1 - \sin^{2}(\theta)\right) ✗ bare ( one level inside \left(
854
+ \left(1 - \sin^{2}\left(\theta\right)\right) ✓ — sizing the outer pair
855
+ does not size the inner one
856
+
857
+ (a + [b - c]) ✗ nested across types too
858
+ \left(a + \left[b - c\right]\right) ✓
859
+
860
+ (x) ✓ — not nested, bare is fine
861
+ ```
862
+
863
+ _Rationale._ A bare delimiter is a fixed-height glyph; `\left`/`\right` grows
864
+ to match whatever it wraps. Two bare pairs nested inside each other render at
865
+ the _same_ fixed size regardless of which one's content is actually taller,
866
+ so the inner and outer marks visually collide or look like a typo the moment
867
+ either side holds anything but a single short symbol. Requiring the sizing
868
+ form at every level of a nest — rather than trying to judge, pair by pair,
869
+ whether _this particular_ nesting happens to look fine — is the simplest rule
870
+ that is actually checkable by machine and never leaves a judgment call to the
871
+ author on a case that can render invisibly wrong.
872
+
873
+ _How to apply._
874
+
875
+ - Reach for `\left(`/`\right)` (or the bracket/brace/pipe equivalents) the
876
+ moment a second one of these four glyphs would end up inside the first —
877
+ regardless of which of the four it is, and regardless of whether the outer
878
+ one is already sized. Sizing the outer pair does not exempt an inner bare
879
+ one; both levels of a two-level nest need their own `\left`/`\right`.
880
+ - Mixing types counts as nesting too: `(a + [b])`, `\{a \mid (b)\}`, and
881
+ `(|a|)` all need every level sized, exactly like nesting the same character
882
+ twice.
883
+ - A `|` has no distinct open/close glyph, so a lone pair — `\left|x\right|`,
884
+ or bare `|x|` when it is not nested in anything — reads as one absolute
885
+ value/norm, matched left to right. Conditional probability still prefers
886
+ `\mid` over a bare `|` regardless of nesting ([N6](#n6)).
887
+ - A delimiter inside `\text{…}` is display text, not a sizing mark — a unit
888
+ written `\text{(s)}` ([N9](#n9)) is untouched by this rule even when the
889
+ math around it also uses `(`.
890
+ - This is unrelated to [N13](#n13)'s requirement that a nonlinear function's
891
+ argument always be visibly parenthesized: N13 is satisfied by a bare
892
+ `\sin(x)` just as well as `\sin\left(x\right)`; N15 only fires once a
893
+ second one of these four glyphs nests inside the first.
894
+
895
+ _Enforcement._ Build-enforced, with no partial-corpus warm-up tier — like
896
+ [N9](#n9)/[N13](#n13), it ships as a hard error, and the corpus's two prior
897
+ instances (`playground/content/docs/projectiles/optimal-launch-angle.mdx`)
898
+ were fixed in the same change that added the check.
899
+ `findNestedBareDelimiters` (`src/reference/math-glyphs.mjs`) walks the same
900
+ parsed KaTeX tree `resolveMathGlyphs` and [N13](#n13) already build, carrying
901
+ one stack through the whole walk: pushing a tracked bare or `\left`-sized
902
+ delimiter, and flagging the enclosing entry (if bare) and the new one (if
903
+ bare) the moment a second one opens while the first is still open. Because
904
+ every rendered-math context calls `resolveMathGlyphs` — lesson body,
905
+ `notation.formula`, assessment `prompt`/`explanation`, `Diagram`/lab math —
906
+ the check needs no separate wiring per content type, the same story as N13.
907
+
908
+ _Exceptions._ None. A quoted source nesting bare delimiters still renders
909
+ sized in this course, the same way N13 reparenthesizes a quoted `\sin x` —
910
+ the sizing is a typesetting choice, not a claim about the source's own
911
+ notation.