explico 0.7.0 → 0.8.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 (47) hide show
  1. package/CHANGELOG.md +171 -1
  2. package/dist/compiler/cli.d.ts +8 -1
  3. package/dist/compiler/cli.js +10 -2
  4. package/dist/compiler/collections.js +16 -3
  5. package/dist/compiler/logging.d.ts +11 -1
  6. package/dist/compiler/logging.js +2 -1
  7. package/dist/compiler/manifest.d.ts +1 -1
  8. package/dist/compiler/manifest.js +1 -1
  9. package/dist/reference/delimiter-size.d.ts +18 -9
  10. package/dist/reference/delimiter-size.js +15 -39
  11. package/dist/reference/equations.d.mts +9 -3
  12. package/dist/reference/equations.mjs +7 -1
  13. package/dist/reference/gate-abbreviations.js +2 -2
  14. package/dist/reference/gate-heading-length.js +2 -2
  15. package/dist/reference/gate-math.d.ts +23 -1
  16. package/dist/reference/gate-math.js +23 -4
  17. package/dist/reference/gate-prose-notation.d.ts +12 -1
  18. package/dist/reference/gate-prose-notation.js +5 -1
  19. package/dist/reference/katex-delimiters.d.mts +15 -0
  20. package/dist/reference/katex-delimiters.mjs +125 -0
  21. package/dist/reference/katex-options.mjs +4 -0
  22. package/dist/reference/math-glyphs.mjs +16 -2
  23. package/dist/reference/render-lab-math.js +1 -1
  24. package/dist/reference/types.d.ts +7 -0
  25. package/docs/authoring/README.md +1 -1
  26. package/docs/authoring/migrating.md +228 -0
  27. package/docs/authoring/notation.md +80 -62
  28. package/docs/katex-bracket-scaling.md +191 -0
  29. package/package.json +2 -1
  30. package/src/compiler/cli.ts +23 -0
  31. package/src/compiler/collections.ts +33 -3
  32. package/src/compiler/logging.ts +14 -3
  33. package/src/compiler/manifest.ts +1 -1
  34. package/src/components/notation/NotationGlossary.astro +21 -10
  35. package/src/components/notation/render-notation-math.ts +111 -10
  36. package/src/components/starlight/AboutPanel.astro +17 -2
  37. package/src/reference/delimiter-size.ts +37 -59
  38. package/src/reference/equations.mjs +7 -1
  39. package/src/reference/gate-abbreviations.ts +2 -2
  40. package/src/reference/gate-heading-length.ts +2 -2
  41. package/src/reference/gate-math.ts +50 -2
  42. package/src/reference/gate-prose-notation.ts +15 -1
  43. package/src/reference/katex-delimiters.mjs +125 -0
  44. package/src/reference/katex-options.mjs +4 -0
  45. package/src/reference/math-glyphs.mjs +16 -2
  46. package/src/reference/render-lab-math.ts +1 -1
  47. package/src/reference/types.ts +7 -0
@@ -837,15 +837,16 @@ change to that file.
837
837
  ## N15
838
838
 
839
839
  **A nested visible delimiter — `(`, `[`, `\{`, or `|` inside another one of
840
- them — must use a scalable sizing form, not the bare character.**
840
+ them — must use the scalable `\left`/`\right` form, not the bare
841
+ character.**
841
842
 
842
843
  `(`, `[`, `\{` (an escaped, visible brace — bare `{`/`}` is invisible LaTeX
843
844
  grouping and out of scope here), and `|` may be written bare only when they
844
845
  are not nested — not inside one another, and not inside a second instance of
845
846
  themselves. The moment one of them sits inside another, **every level of that
846
- nest** uses either the auto-sizing form (`\left(`/`\right)`,
847
- `\left[`/`\right]`, `\left\{`/`\right\}`, `\left|`/`\right|`) or an explicit
848
- manual pair from [N17](#n17).
847
+ nest** uses the auto-sizing form (`\left(`/`\right)`, `\left[`/`\right]`,
848
+ `\left\{`/`\right\}`, `\left|`/`\right|`) — and, per [N17](#n17), only
849
+ that form.
849
850
 
850
851
  ```text
851
852
  \sin(2(90\text{°}-\theta)) ✗ two bare ( nested
@@ -874,10 +875,10 @@ author on a case that can render invisibly wrong.
874
875
  _How to apply._
875
876
 
876
877
  - Reach for `\left(`/`\right)` (or the bracket/brace/pipe equivalents) the
877
- moment a second one of these four glyphs would end up inside the first, then
878
- apply [N17](#n17)'s manual sizes where necessary. Sizing the outer pair does
879
- not exempt an inner bare one; both levels of a two-level nest need a sizing
880
- command.
878
+ moment a second one of these four glyphs would end up inside the first.
879
+ Sizing the outer pair does not exempt an inner bare one; both levels of a
880
+ two-level nest need a sizing command. [N17](#n17) then leaves every size to
881
+ the engine.
881
882
  - Mixing types counts as nesting too: `(a + [b])`, `\{a \mid (b)\}`, and
882
883
  `(|a|)` all need every level sized, exactly like nesting the same character
883
884
  twice.
@@ -949,35 +950,35 @@ mark from notation whose shape carries meaning. That judgment belongs to the
949
950
  authoring agent or reviewer, not a syntax linter.
950
951
 
951
952
  _How to apply._ Decide whether each pair is stylistic before choosing its
952
- shape. Alternate only the pairs that pass that test. Apply [N17](#n17)
953
- separately so the visual size still increases from the inside out; shape and
954
- size are independent decisions.
953
+ shape. Alternate only the pairs that pass that test. Sizes are not your
954
+ concern: [N17](#n17) hands every nested pair to the engine, which grows each
955
+ level automatically. Shape is an authoring decision; size is not.
955
956
 
956
957
  ```text
957
- \Biggl[
958
+ \left[
958
959
  a+
959
- \biggl(
960
+ \left(
960
961
  b+
961
- \Bigl[
962
+ \left[
962
963
  c+
963
964
  \left(d+e\right)
964
- \Bigr]
965
- \biggr)
966
- \Biggr] ✓
965
+ \right]
966
+ \right)
967
+ \right] ✓
967
968
  ```
968
969
 
969
970
  Semantic notation stays intact even when stylistic grouping surrounds it:
970
971
 
971
972
  ```text
972
- \Biggl[
973
- \mathbb{E}\!\Bigl[
973
+ \left[
974
+ \mathbb{E}\!\left[
974
975
  f\!\left(X\right)
975
- \Bigr]
976
+ \right]
976
977
  +
977
- \biggl(
978
+ \left(
978
979
  a+b
979
- \biggr)
980
- \Biggr] ✓
980
+ \right)
981
+ \right] ✓
981
982
  ```
982
983
 
983
984
  In particular, never turn `\mathbb{E}[X]` into another delimiter type merely
@@ -985,7 +986,7 @@ to alternate shapes.
985
986
 
986
987
  _Enforcement._ Review-only. Delimiter semantics cannot be inferred reliably
987
988
  from LaTeX syntax, so no automated check enforces alternation. The N17 linter
988
- checks sizes only and never recommends changing a delimiter type.
989
+ checks sizing syntax only and never recommends changing a delimiter type.
989
990
 
990
991
  _Exceptions._ Every conventional or semantically meaningful delimiter is
991
992
  outside the alternation preference. Preserving established mathematical
@@ -995,49 +996,66 @@ notation takes precedence over the stylistic cycle.
995
996
 
996
997
  ## N17
997
998
 
998
- **Every outer pair in a nest of scalable `()`, `[]`, or `\{\}` delimiters must
999
- be visibly larger than every pair inside it.**
999
+ **A nest of scalable `()`, `[]`, or `\{\}` delimiters is sized by the engine:
1000
+ use `\left ... \right` at every level, and never hand-size a pair inside a nest
1001
+ with `\bigl`, `\Bigl`, `\biggl`, or `\Biggl`.**
1000
1002
 
1001
- Use progressively larger standard manual sizes to make grouping depth visible
1002
- without producing unnecessarily oversized marks:
1003
-
1004
- 1. innermost baseline: `\left ... \right` (or `\bigl ... \bigr`);
1005
- 2. next: `\Bigl ... \Bigr`;
1006
- 3. next: `\biggl ... \biggr`;
1007
- 4. outermost: `\Biggl ... \Biggr`.
1008
-
1009
- The opening and closing commands change size only. Preserve each delimiter's
1010
- shape: `[` remains `[`, `(` remains `(`, and `\{` remains `\{`.
1003
+ ```text
1004
+ \left[a+\left(b+\left[c+\left(d+e\right)\right]\right)\right] ✓
1005
+ \Biggl[a+\biggl(b+\Bigl[c+\left(d+e\right)\Bigr]\biggr)\Biggr] ✗ hand-sized
1006
+ ```
1011
1007
 
1012
- _Rationale._ Two nested `\left...\right` pairs around similarly short content
1013
- can render at the same apparent size, hiding the nesting that scalable
1014
- delimiters were meant to clarify. A strict inside-to-outside hierarchy makes
1015
- the depth legible and has an objective syntax-level repair through the four
1016
- standard manual size levels.
1008
+ _Rationale._ In stock TeX and stock KaTeX a `\left ... \right` pair is allowed
1009
+ to fall up to `\delimitershortfall` (5pt) short of its content, so two pairs at
1010
+ different depths around similar content round to the *same* glyph and the
1011
+ nesting disappears. The historical repair was to hand-pick a larger size at
1012
+ each level, which trades one defect for another: `\bigl`–`\Biggl` are
1013
+ *fixed* glyphs, so they stop tracking the content they wrap, they run out after
1014
+ four levels, and an edit to the inner expression silently invalidates every
1015
+ size above it.
1016
+
1017
+ This engine removes the cause instead. `src/reference/katex-delimiters.mjs`
1018
+ installs a KaTeX whose `\delimitershortfall` is `-1pt` — the same one-constant
1019
+ change an author would make in a LaTeX preamble — see
1020
+ [`docs/katex-bracket-scaling.md`](../katex-bracket-scaling.md) for the
1021
+ derivation. A pair is then always *strictly* taller than what it wraps, so each
1022
+ enclosing level grows on its own, by exactly as much as its content needs, at
1023
+ any depth. Hand-sizing is no longer a repair; it is an override that throws the
1024
+ automatic hierarchy away.
1017
1025
 
1018
- _How to apply._ Start at the innermost scalable pair and assign each enclosing
1019
- pair the next larger level. Four or fewer levels must have a strict increase at
1020
- every level. If a valid expression needs more levels than the standard sizes
1021
- can distinguish, restructure the expression rather than inventing a fifth
1022
- size. This rule is independent of N16: semantic shapes stay semantic, but their
1023
- sizes still increase when their scalable pairs are nested.
1026
+ _How to apply._
1024
1027
 
1025
- _Enforcement._ Tier-3 warning `delimiter-size-hierarchy`, emitted by
1028
+ - Write `\left ... \right` at every level of a nest and leave the sizes alone.
1029
+ With the patched engine there is nothing further to do.
1030
+ - Do not "help" the engine with a manual size. If a nest still looks wrong,
1031
+ the expression is too deep to read — restructure it, or pull an inner group
1032
+ into its own display, rather than hand-sizing it.
1033
+ - This rule is independent of [N16](#n16): shapes still alternate where they
1034
+ are stylistic, and semantic shapes are still preserved. Only the *size*
1035
+ decision moved to the engine.
1036
+ - A *lone* hand-sized pair that is not part of a nest is untouched — a tuple
1037
+ written `\bigl(x(t),\ y(t)\bigr)` has no hierarchy to get wrong and stays a
1038
+ deliberate typographic choice.
1039
+ - Nothing to install. The sizing rides along with the engine: a course's
1040
+ `astro.config.mjs` already imports `explico/reference/katex-options.mjs`,
1041
+ which installs it before anything renders. If it ever fails to install, the
1042
+ build stops with an explicit error rather than quietly falling back to stock
1043
+ KaTeX.
1044
+
1045
+ _Enforcement._ Tier-3 warning `delimiter-manual-size`, emitted by
1026
1046
  `lintDelimiterSizes` (`src/reference/delimiter-size.ts`) through the normal
1027
1047
  math gate for lesson math, notation formulas, and assessment math. The linter
1028
- uses a stack to parse arbitrarily deep `\left...\right`,
1029
- `\bigl...\bigr`, `\Bigl...\Bigr`, `\biggl...\biggr`, and
1030
- `\Biggl...\Biggr` pairs for `()`, `[]`, and `\{\}`. It reports the outer
1031
- pair's source line and column and suggests the required larger manual size when
1032
- one exists. Malformed pairs remain KaTeX syntax errors; the size linter does not
1033
- guess at their structure.
1048
+ uses a stack to parse arbitrarily deep `\left...\right`, `\bigl...\bigr`,
1049
+ `\Bigl...\Bigr`, `\biggl...\biggr`, and `\Biggl...\Biggr` pairs for `()`, `[]`,
1050
+ and `\{\}`, and warns for each hand-sized pair that contains, or sits inside,
1051
+ another pair it tracks. It reports that pair's source line and column and
1052
+ suggests the `\left`/`\right` replacement, repeating the exact opening and
1053
+ closing delimiter shapes it found. Malformed pairs remain KaTeX syntax errors;
1054
+ the linter does not guess at their structure.
1034
1055
 
1035
1056
  The automated check deliberately does **not** infer mathematical semantics or
1036
- lint delimiter-type alternation. Its suggestion repeats the exact opening and
1037
- closing delimiter shapes it found.
1038
-
1039
- _Exceptions._ None for a valid nest of scalable pairs with four or fewer
1040
- levels. Beyond four levels, the warning remains but has no automatic size
1041
- replacement; restructure the expression. Bare, non-nested semantic notation
1042
- such as `f(x)` or `\mathbb{E}[X]` is outside this linter and remains governed by
1043
- the mathematical convention and [N15](#n15)'s separate bare-nesting rule.
1057
+ lint delimiter-type alternation.
1058
+
1059
+ _Exceptions._ None inside a nest. Bare, non-nested semantic notation such as
1060
+ `f(x)` or `\mathbb{E}[X]` is outside this linter and remains governed by the
1061
+ mathematical convention and [N15](#n15)'s separate bare-nesting rule.
@@ -0,0 +1,191 @@
1
+ # Auto-scaling brackets in KaTeX (and MathJax)
2
+
3
+ > **Status in this repo.** Solution 2 is implemented, but as *code* rather than
4
+ > as a `patches/` file: [`src/reference/katex-delimiters.mjs`](../src/reference/katex-delimiters.mjs)
5
+ > applies the same one-constant change at import time, so it ships inside the
6
+ > published `explico` package and a downstream course inherits it with no
7
+ > install step. Rule [N17](authoring/notation.md#n17) depends on it. The
8
+ > sections below are the derivation; see
9
+ > [How this repo applies it](#how-this-repo-applies-it).
10
+
11
+ ## The problem
12
+
13
+ In LaTeX, `\left[ ... \right]` sizes a bracket to fit its content — but nested brackets often come out **the same size**, because the sizing rule only requires the delimiter to be *close enough* to the content's height, not strictly bigger.
14
+
15
+ Example:
16
+
17
+ ```latex
18
+ $$\left[ \left[ \sqrt{\frac{\int\limits_0^{10}\sqrt{a^2+b^2}}{2}} \right] + 1 \right]$$
19
+ ```
20
+
21
+ The outer `[` and inner `[` render at the same size, even though the outer one wraps strictly more content. The alternative — manually choosing `\bigl`, `\Bigl`, `\biggl`, `\Biggl` — fixes the sizing but abandons `\left`/`\right`'s automatic "match the height of everything inside" behavior, since those are fixed-size delimiters that don't adapt to content.
22
+
23
+ So there are two properties you want simultaneously, and the two standard tools each give you only one:
24
+
25
+ 1. **Strict growth outward** — each nesting level's bracket must be visibly bigger than the one inside it.
26
+ 2. **Full coverage** — every bracket must span the full height of its content, with no gap.
27
+
28
+ `\bigl`/`\Bigl`/`\biggl`/`\Biggl` give you (1) but not (2) (fixed sizes don't track arbitrary content). `\left`/`\right` give you (2) but not (1) (the sizing rule allows equal-looking sizes at different nesting depths).
29
+
30
+ ## Why it happens: the underlying rule
31
+
32
+ TeX sizes a `\left`/`\right` pair using this rule (TeXbook, Appendix G, rule 19):
33
+
34
+ ```
35
+ s_min = max( 2·δ₀ · f / 1000 , 2·δ₀ − l )
36
+ ```
37
+
38
+ TeX then picks the smallest available delimiter glyph whose total size `s` satisfies `s ≥ s_min`.
39
+
40
+ | Symbol | Meaning |
41
+ |---|---|
42
+ | `h` | height of the content: how far it reaches above the baseline |
43
+ | `d` | depth of the content: how far it reaches below the baseline |
44
+ | `a` | axis height — the line fraction bars and minus signs sit on; delimiters are centered on this line |
45
+ | `δ₀` | `max(h − a, d + a)` — how far the content reaches from the axis, on whichever side is farther |
46
+ | `f` | `\delimiterfactor`, default **901** |
47
+ | `l` | `\delimitershortfall`, default **5pt** |
48
+ | `s_min` | the smallest total bracket size TeX will accept |
49
+ | `s` | the size of the delimiter TeX actually picks |
50
+
51
+ With the default `l = 5pt`, `s_min` can be *smaller* than the content's own height (since `901/1000 ≈ 0.9`), so a delimiter that already matches the inner bracket's height passes the test for the outer bracket too — no forced growth. The same slack also means a delimiter can legally fall up to 5pt short of the content, i.e. requirement (2) isn't fully guaranteed by default either.
52
+
53
+ **The fix in real LaTeX:** set `\delimitershortfall` to a *negative* value (e.g. `-1pt`). Then `s_min = 2δ₀ + 1pt`, which is always larger than the content's own extent — forcing outward growth at every level, automatically, with no per-formula work. This is a document-wide setting, not something you write per equation:
54
+
55
+ ```latex
56
+ \delimitershortfall=-1pt
57
+ \left[ \left[ \sqrt{\frac{\int\limits_0^{10}\sqrt{a^2+b^2}}{2}} \right] + 1 \right]
58
+ ```
59
+
60
+ Verified by rendering with `pdflatex`: default nesting produces equal-sized brackets; the same source with `\delimitershortfall=-1pt` produces strictly increasing sizes at every depth, and small content (`(x)`, `((x))`, `(((x)))`) is unaffected since the correction is tiny relative to normal delimiter jumps.
61
+
62
+ ## Why it doesn't carry over to the web
63
+
64
+ `\delimitershortfall` is a TeX engine primitive. **Neither KaTeX nor MathJax expose it or honor it** — I tested both directly:
65
+
66
+ - KaTeX: `\delimitershortfall=-1pt` throws `ParseError: Expected group after '\delimitershortfall'` (undefined control sequence).
67
+ - MathJax v3: the command is silently dropped/renders as an error span; no effect on sizing.
68
+
69
+ So the web renderers need a different fix.
70
+
71
+ ## Solution 1: a `\grow` macro (portable, works in *any* KaTeX/MathJax page)
72
+
73
+ Since neither engine's *rule* can be changed from user-level TeX, the workaround changes the **content** being measured instead. Wrap everything inside a bracket pair (except the innermost level) in an invisible padding macro that makes the content measure as slightly taller and deeper than it visually is. Then the normal `\left`/`\right` sizing — which *does* work correctly in both engines — is forced to pick a bigger glyph at each level, because the content it measures really is bigger.
74
+
75
+ **Mechanism:** for content `#1`, stack a raised zero-width phantom copy 0.5em above it and a lowered zero-width phantom copy 0.5em below it. `\vphantom{#1}` takes zero horizontal space and is invisible, but claims the same height/depth as `#1`. Raising and lowering copies of it extends the *measured* bounding box by 0.5em on each side — 1em of forced growth per wrap — without changing what's visually drawn.
76
+
77
+ **KaTeX** (verified — `\raisebox` only accepts a text-mode argument, so the phantom must be re-entered as math and forced to `\displaystyle`; without that it silently shrinks to inline size and the trick does nothing):
78
+
79
+ ```latex
80
+ \newcommand{\grow}[1]{#1\raisebox{0.5em}{$\displaystyle\vphantom{#1}$}\raisebox{-0.5em}{$\displaystyle\vphantom{#1}$}}
81
+ ```
82
+
83
+ **MathJax** (verified — `\raisebox` doesn't exist here; plain-TeX `\raise`/`\lower` take a math argument directly and preserve the current style):
84
+
85
+ ```latex
86
+ \newcommand{\grow}[1]{#1\raise{0.5em}{\vphantom{#1}}\lower{0.5em}{\vphantom{#1}}}
87
+ ```
88
+
89
+ **Usage** — wrap the content of every bracket pair except the innermost:
90
+
91
+ ```latex
92
+ \left[ \grow{ \left[ \sqrt{\frac{\int\limits_0^{10}\sqrt{a^2+b^2}}{2}} \right] + 1 } \right]
93
+ ```
94
+
95
+ Verified by rendering with real KaTeX 0.18.7 and MathJax 3.2.2 (headless Chromium screenshots): both show strictly increasing bracket sizes across 2–3 nesting levels, matching the pdfLaTeX reference.
96
+
97
+ **Trade-offs:**
98
+ - Manual — you place `\grow{}` yourself at each level; it isn't automatic like the LaTeX-engine setting.
99
+ - The two spellings aren't interchangeable: `\raisebox` errors out in MathJax, `\raise`/`\lower` don't exist in KaTeX. Pick the one matching your renderer.
100
+ - Can be defined once (in MathJax's `tex.macros` config, or KaTeX's `macros` render option) so you only write `\grow{...}` per formula, not the full definition each time.
101
+
102
+ ## Solution 2: patch KaTeX directly (better fit for a site you build/host yourself)
103
+
104
+ Since you control the build (an Astro site rendering KaTeX server-side), you don't need the macro workaround at all — you can restore the actual `\delimitershortfall` behavior by editing the one place KaTeX hard-codes it.
105
+
106
+ **The code.** KaTeX's delimiter-sizing function (`makeLeftRightDelim`, in the compiled bundle since no per-file TS source ships in the npm package) contains:
107
+
108
+ ```js
109
+ const delimiterFactor = 901; // TeX's \delimiterfactor
110
+ const delimiterExtend = 5.0 / options.fontMetrics().ptPerEm; // TeX's \delimitershortfall = 5pt
111
+ ```
112
+
113
+ This is the exact same formula from the TeXbook rule above, with the same two constants. Changing `5.0` to `-1.0` is precisely `\delimitershortfall=-1pt` — `delimiterFactor` doesn't need to change, since a negative shortfall always makes the second term of the `max(...)` dominate anyway.
114
+
115
+ **Verified:** I applied this one-line edit to the installed `katex` package and rendered plain, unmodified `\left[...\right]` source (no `\grow` macro anywhere) through the patched build. Result: strict growth across 4 nesting levels, small content (`(x)`, `((x))`, `(((x)))`) unaffected, and — critically — the LaTeX source embedded in KaTeX's output for copy/paste (`<annotation encoding="application/x-tex">`) stays exactly what you typed, since only the *sizing*, not the *content*, is touched.
116
+
117
+ **Where the line lives** (npm package, KaTeX 0.16.47):
118
+
119
+ | File | Used by | Patched here |
120
+ |---|---|---|
121
+ | `src/delimiter.ts` | the readable source the bundles are built from | yes |
122
+ | `dist/katex.mjs` | ESM import — what an Astro/Vite build resolves to | yes |
123
+ | `dist/katex.js` | CJS `require` | yes |
124
+ | `dist/katex.min.js` | only if KaTeX is also loaded client-side via `<script>` | **no** — see below |
125
+
126
+ `dist/katex.min.js` is left alone on purpose. explico renders every formula at
127
+ build time and ships only `katex.min.css` plus fonts to the browser, so nothing
128
+ here loads that bundle — and because it is a single ~270 KB line, including it
129
+ turns a 2 KB patch into a 550 KB one that no reviewer can read and no KaTeX
130
+ upgrade can rebase. A course that *does* render KaTeX in the browser has to
131
+ patch it too; the minified form of the same line is
132
+ `l=5/n.fontMetrics().ptPerEm` inside `makeLeftRightDelim`.
133
+
134
+ ## How this repo applies it
135
+
136
+ A `pnpm patch` would work, and is what the section above describes, but it is
137
+ **project-local**: pnpm records it in the consuming repo's
138
+ `pnpm-workspace.yaml`, so it cannot travel inside the published `explico`
139
+ package. Every downstream course would have to reapply it, and a course that
140
+ forgot would render flat nests while its linter told authors the engine handled
141
+ sizing. Documenting that in a migration guide only narrows the window for the
142
+ mistake; it does not close it.
143
+
144
+ So the engine does the same edit in code, at import time, in
145
+ [`src/reference/katex-delimiters.mjs`](../src/reference/katex-delimiters.mjs):
146
+
147
+ 1. read KaTeX's own ESM bundle off disk (`katex/dist/katex.mjs`);
148
+ 2. rewrite that one constant, throwing if it is not found verbatim;
149
+ 3. `import()` the rewritten source as a `data:` URL — the bundle has no imports
150
+ of its own, so it needs no base to resolve against;
151
+ 4. move `render`, `renderToString`, `__renderToDomTree` and
152
+ `__renderToHTMLTree` onto the shared `katex` object every consumer in the
153
+ process already imported;
154
+ 5. render a known nest and throw unless the sizes actually grew.
155
+
156
+ Step 4 is what makes it reach lesson-body math. `rehype-katex` does its own
157
+ `import katex from 'katex'`, but it calls `katex.renderToString(...)` — a
158
+ property read at call time — and KaTeX's default export is a plain mutable
159
+ object. Replacing the function on that object is therefore enough; the plugin
160
+ does not have to be forked or aliased.
161
+
162
+ **A course inherits this with no changes.** `astro.config.mjs` already imports
163
+ `createNotationKatexOptions` from `explico/reference/katex-options.mjs`, and
164
+ that module imports the installer for its side effect, so the sizing is in
165
+ place before Astro renders anything. The engine's own non-Astro paths
166
+ (`math-glyphs.mjs`, `render-lab-math.ts`, and therefore `validate:content`)
167
+ import it directly.
168
+
169
+ **It cannot fail quietly.** Steps 2 and 5 throw. A KaTeX upgrade that moves the
170
+ constant, or anything that renders before the installer runs, stops the build
171
+ with an explicit message instead of silently reverting to stock spacing — which
172
+ is the failure mode a patch file plus a migration note could not rule out.
173
+
174
+ **Trade-offs.** It is a monkey-patch of a third-party module object, so it
175
+ rests on three upstream properties: the constant is a literal in the shipped
176
+ bundle, the bundle is self-contained, and the default export is mutable. All
177
+ three are asserted at install time rather than assumed. It also holds a second
178
+ copy of the KaTeX module in memory (~600 KB of source, ~50 ms to load) for the
179
+ duration of a build, which is not worth optimising away.
180
+
181
+ ## Why the patch is the better fit here
182
+
183
+ Your original idea — keep writing `\left[` everywhere, run a build-time preprocessing step that inserts `\grow{}` at each nesting level, then swap in the original source for a "copy" button — would work, but has two costs the patch avoids:
184
+
185
+ 1. **The preprocessor needs a real TeX parser**, not a regex. It has to track bracket nesting depth correctly across `\text{...}`, comments, `\right.` (null right delimiters), and — critically — macros. If you ever define something like `\norm{...}` that *expands* to `\left\lVert...\right\rVert`, a source-level preprocessor can't see the nesting hidden inside the macro and will miscount levels. The patch operates after macro expansion, inside KaTeX's own layout engine, so it can't miss anything.
186
+ 2. **The copy-button problem disappears.** KaTeX already embeds your literal input in the hidden MathML `<annotation>` tag — that's what the standard `copy-tex` extension reads. With preprocessing, that annotation would contain the `\grow`-polluted string, so you'd have to intercept the copy handler and substitute the original text yourself. With the patch, the *displayed* math and the *embedded source* never diverge in the first place, because nothing was ever inserted into the source.
187
+
188
+ ## Caveats
189
+
190
+ - If your pipeline actually renders to **SVG** rather than HTML+CSS, you're using MathJax's `tex-svg` output, not KaTeX (KaTeX renders to HTML spans, not SVG). MathJax's stretchy-delimiter logic lives in the output stage rather than one constants block, so the equivalent patch would be far more invasive there — the `\grow` macro is the practical option for MathJax.
191
+ - On an Astro build, KaTeX runs at build time (via `remark-math`/`rehype-katex` or similar) and only sends finished HTML + `katex.css` + fonts to the browser — the patched JS never ships to visitors, it only needs to be present in your build environment.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "explico",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
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",
@@ -30,6 +30,7 @@
30
30
  "README.md",
31
31
  "CHANGELOG.md",
32
32
  "docs/VERSIONING.md",
33
+ "docs/katex-bracket-scaling.md",
33
34
  "docs/authoring"
34
35
  ],
35
36
  "dependencies": {
@@ -49,6 +49,7 @@ import remarkNotation, {
49
49
  } from '../reference/remark-notation.mjs';
50
50
  import remarkShortNote from '../reference/remark-short-note.mjs';
51
51
  import type { DiagnosticSeverity } from '../reference/types';
52
+ import type { EquationLink } from '../reference/gate-math';
52
53
  import type { Manifest, ManifestLesson } from './manifest';
53
54
  import { isLessonSlug, lessonIdFromSlug } from './lesson-derivation';
54
55
 
@@ -90,6 +91,13 @@ export interface CliDiagnostic {
90
91
  readonly file?: string;
91
92
  readonly line?: number;
92
93
  readonly column?: number;
94
+ /**
95
+ * The keyed equation the finding sits in, when the math carries a
96
+ * `\label{eq:…}` — `{ key, href }`, the href being the anchor on the
97
+ * rendered page. A size-only finding inside a long `aligned` block is
98
+ * otherwise anonymous.
99
+ */
100
+ readonly equation?: EquationLink;
93
101
  }
94
102
 
95
103
  function diagnosticSeverity(value: unknown): DiagnosticSeverity {
@@ -165,6 +173,9 @@ export function lessonDiagnostics(
165
173
  file: diagnostic.file,
166
174
  ...(diagnostic.line === undefined ? {} : { line: diagnostic.line }),
167
175
  ...(diagnostic.column === undefined ? {} : { column: diagnostic.column }),
176
+ ...(diagnostic.equation === undefined
177
+ ? {}
178
+ : { equation: diagnostic.equation }),
168
179
  });
169
180
  }
170
181
 
@@ -621,8 +632,18 @@ type DiagnosticRecord = Record<string, unknown> & {
621
632
  readonly line?: unknown;
622
633
  readonly column?: unknown;
623
634
  readonly source?: unknown;
635
+ readonly equation?: unknown;
624
636
  };
625
637
 
638
+ /** `{ key, href }` as it survives the untyped manifest walk above. */
639
+ function equationLink(value: unknown): EquationLink | undefined {
640
+ if (value === null || typeof value !== 'object') return undefined;
641
+ const { key, href } = value as Record<string, unknown>;
642
+ return typeof key === 'string' && typeof href === 'string'
643
+ ? { key, href }
644
+ : undefined;
645
+ }
646
+
626
647
  function numericLocation(value: unknown): number | undefined {
627
648
  return typeof value === 'number' && Number.isInteger(value) && value > 0
628
649
  ? value
@@ -685,6 +706,7 @@ export function contentDiagnostics(
685
706
  : typeof diagnostic.kind === 'string'
686
707
  ? diagnostic.kind
687
708
  : 'diagnostic';
709
+ const equation = equationLink(diagnostic.equation);
688
710
  diagnostics.push({
689
711
  code: `${group}:${detail}`,
690
712
  severity,
@@ -692,6 +714,7 @@ export function contentDiagnostics(
692
714
  ...(file === undefined ? {} : { file }),
693
715
  ...(line === undefined ? {} : { line }),
694
716
  ...(column === undefined ? {} : { column }),
717
+ ...(equation === undefined ? {} : { equation }),
695
718
  });
696
719
  }
697
720
  }
@@ -80,6 +80,32 @@ interface ParsedDocument {
80
80
  readonly data: Record<string, unknown>;
81
81
  /** Markdown body, byte-identical to the previous `gray-matter` output. */
82
82
  readonly body: string;
83
+ /**
84
+ * 1-based line *in the file* where {@link body} starts — the frontmatter
85
+ * block's height plus one. Every gate scans the body, so a finding's index
86
+ * is body-relative while the `file` it is reported against is not; without
87
+ * this offset a `file:line:column` diagnostic points at the wrong line (by
88
+ * the height of the frontmatter) and no editor can jump to it.
89
+ */
90
+ readonly bodyLine: number;
91
+ }
92
+
93
+ /**
94
+ * The line `body` starts on in `raw`. `parseFrontmatter` returns the body as a
95
+ * suffix of the file, so the offset is what precedes it; the `indexOf` branch
96
+ * is a guard for a future parser that normalizes the text rather than slicing.
97
+ */
98
+ function bodyStartLine(raw: string, body: string): number {
99
+ const offset = raw.endsWith(body)
100
+ ? raw.length - body.length
101
+ : raw.indexOf(body);
102
+ if (offset <= 0) return 1;
103
+
104
+ let line = 1;
105
+ for (let index = 0; index < offset; index += 1) {
106
+ if (raw.charCodeAt(index) === 10) line += 1;
107
+ }
108
+ return line;
83
109
  }
84
110
 
85
111
  /**
@@ -87,11 +113,14 @@ interface ParsedDocument {
87
113
  * did not; strip it so body offsets in diagnostics are unchanged.
88
114
  */
89
115
  function parseDocument(path: string): ParsedDocument {
90
- const parsed = parseFrontmatter(readFileSync(path, 'utf8'));
116
+ const raw = readFileSync(path, 'utf8');
117
+ const parsed = parseFrontmatter(raw);
118
+ const body = parsed.content.replace(/^\n/, '');
91
119
  return {
92
120
  file: relative(REPOSITORY_ROOT, path),
93
121
  data: (parsed.frontmatter ?? {}) as Record<string, unknown>,
94
- body: parsed.content.replace(/^\n/, ''),
122
+ body,
123
+ bodyLine: bodyStartLine(raw, body),
95
124
  };
96
125
  }
97
126
 
@@ -484,7 +513,7 @@ function notationLessonEntries(): NotationLessonInput[] {
484
513
  for (const path of filesBelow(join(CONTENT_ROOT, 'docs'), ['.md', '.mdx'])) {
485
514
  const slug = docSlug(path);
486
515
  if (!isLessonSlug(slug)) continue;
487
- const { file, data, body } = parseDocument(path);
516
+ const { file, data, body, bodyLine } = parseDocument(path);
488
517
  const notation = record(data.notation ?? {}, `${file} notation`);
489
518
  const local = Array.isArray(notation.local) ? notation.local : [];
490
519
  lessons.push({
@@ -495,6 +524,7 @@ function notationLessonEntries(): NotationLessonInput[] {
495
524
  ),
496
525
  references: extractNotationReferences(body, file, undefined),
497
526
  body,
527
+ bodyLine,
498
528
  source: { file },
499
529
  });
500
530
  }
@@ -130,6 +130,8 @@ export interface AnyDiagnostic {
130
130
  readonly file?: string;
131
131
  readonly line?: number;
132
132
  readonly column?: number;
133
+ /** `{ key, href }` when the finding belongs to a `\label{eq:…}` equation. */
134
+ readonly equation?: { readonly key: string; readonly href: string };
133
135
  }
134
136
 
135
137
  /** `manifest.diagnostics`, structurally — one array per subsystem. */
@@ -157,16 +159,25 @@ export interface RunLog {
157
159
  close(): RunLogSummary;
158
160
  }
159
161
 
160
- /** `[code] message`, matching what the console has always printed. */
162
+ /**
163
+ * `[code] file:line:column: message`, matching what the console has always
164
+ * printed, with ` (eq:<key> -> <href>)` appended when the finding belongs to a
165
+ * keyed equation — so a size-only finding inside a long `aligned` block names
166
+ * the result it is about, not just its coordinates.
167
+ */
161
168
  export function diagnosticLine(diagnostic: AnyDiagnostic): string {
162
169
  const code = diagnostic.code ?? diagnostic.kind;
163
170
  const at = [diagnostic.file, diagnostic.line, diagnostic.column]
164
171
  .filter((part) => part !== undefined)
165
172
  .join(':');
166
173
  const prefix = code === undefined ? '' : `[${code}] `;
174
+ const equation =
175
+ diagnostic.equation === undefined
176
+ ? ''
177
+ : ` (eq:${diagnostic.equation.key} -> ${diagnostic.equation.href})`;
167
178
  return at === ''
168
- ? `${prefix}${diagnostic.message}`
169
- : `${prefix}${at}: ${diagnostic.message}`;
179
+ ? `${prefix}${diagnostic.message}${equation}`
180
+ : `${prefix}${at}: ${diagnostic.message}${equation}`;
170
181
  }
171
182
 
172
183
  export interface RunLogOptions {
@@ -191,7 +191,7 @@ import {
191
191
  * from `warning` to `info`; their diagnostic codes and container fields stay
192
192
  * unchanged.
193
193
  */
194
- export const MANIFEST_SCHEMA_VERSION = 15;
194
+ export const MANIFEST_SCHEMA_VERSION = 16;
195
195
 
196
196
  const REPOSITORY_ROOT = process.cwd();
197
197
  // Content is the top-level `content/` folder; the build output is `build/`
@@ -159,13 +159,19 @@ const panelDefinitions: NotationLayerDefinition[] = notationEntries.map(
159
159
  <NotationMath class="glossary-symbol" latex={entry.data.latex} />
160
160
  </span>
161
161
  <div>
162
- <h2>
163
- <a
164
- class="glossary-term-link"
165
- href={`#notation-${entry.data.key}`}
166
- >
167
- {labelOf(entry.data)}
168
- </a>
162
+ {/* The anchor is a sibling of the heading, not a child — the
163
+ same shape `rehype-heading-anchors` gives a lesson heading.
164
+ Inside the `h2` its `aria-label` joins the heading's
165
+ accessible name ("Velocity Link to “Velocity”"). */}
166
+ <div class="glossary-heading">
167
+ <h2>
168
+ <a
169
+ class="glossary-term-link"
170
+ href={`#notation-${entry.data.key}`}
171
+ >
172
+ {labelOf(entry.data)}
173
+ </a>
174
+ </h2>
169
175
  <a
170
176
  class="glossary-anchor"
171
177
  href={`#notation-${entry.data.key}`}
@@ -177,7 +183,7 @@ const panelDefinitions: NotationLayerDefinition[] = notationEntries.map(
177
183
  </svg>
178
184
  </span>
179
185
  </a>
180
- </h2>
186
+ </div>
181
187
  <code>{entry.data.key}</code>
182
188
  </div>
183
189
  <span class="glossary-status">{entry.data.editorialStatus}</span>
@@ -330,6 +336,11 @@ const panelDefinitions: NotationLayerDefinition[] = notationEntries.map(
330
336
  font-size: var(--sl-text-xl);
331
337
  }
332
338
 
339
+ .glossary-heading {
340
+ display: flex;
341
+ align-items: center;
342
+ }
343
+
333
344
  .glossary-term-link {
334
345
  color: inherit;
335
346
  text-decoration: none;
@@ -396,8 +407,8 @@ const panelDefinitions: NotationLayerDefinition[] = notationEntries.map(
396
407
  opacity: 0;
397
408
  }
398
409
 
399
- h2:hover .glossary-anchor,
400
- h2:focus-within .glossary-anchor,
410
+ .glossary-heading:hover .glossary-anchor,
411
+ .glossary-heading:focus-within .glossary-anchor,
401
412
  .glossary-anchor:focus-visible {
402
413
  opacity: 1;
403
414
  }