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
@@ -1,5 +1,9 @@
1
1
  import { resolveMathGlyphs, GlyphResolutionError } from "./math-glyphs.mjs";
2
- import { stripEquationLabels } from "./equations.mjs";
2
+ import {
3
+ EQ_LABEL_PATTERN,
4
+ equationAnchorId,
5
+ stripEquationLabels
6
+ } from "./equations.mjs";
3
7
  import { lintDelimiterSizes } from "./delimiter-size.js";
4
8
  import { formulaGlyphScope, unknownFormulaScopeKeys } from "./gloss.js";
5
9
  const SEVERITY = {
@@ -58,7 +62,17 @@ function pageScope(lesson, sharedByKey) {
58
62
  }
59
63
  return scope;
60
64
  }
61
- function resolveInto(latex, scope, context, file, line, column, fix, out) {
65
+ function equationLinkFor(lessonId, latex) {
66
+ EQ_LABEL_PATTERN.lastIndex = 0;
67
+ const match = EQ_LABEL_PATTERN.exec(latex);
68
+ EQ_LABEL_PATTERN.lastIndex = 0;
69
+ if (!match) return void 0;
70
+ const key = match[1];
71
+ const slug = lessonId.replace(/\./g, "/");
72
+ return { key, href: `/${slug}/#${equationAnchorId({ key })}` };
73
+ }
74
+ function resolveInto(latex, scope, context, file, line, column, fix, out, equation) {
75
+ const at = equation === void 0 ? {} : { equation };
62
76
  for (const diagnostic of lintDelimiterSizes(latex)) {
63
77
  const locatedLine = line === void 0 ? void 0 : line + diagnostic.line - 1;
64
78
  const locatedColumn = column === void 0 ? void 0 : diagnostic.line === 1 ? column + diagnostic.column - 1 : diagnostic.column;
@@ -69,6 +83,7 @@ function resolveInto(latex, scope, context, file, line, column, fix, out) {
69
83
  file,
70
84
  ...locatedLine === void 0 ? {} : { line: locatedLine },
71
85
  ...locatedColumn === void 0 ? {} : { column: locatedColumn },
86
+ ...at,
72
87
  message: diagnostic.message
73
88
  });
74
89
  }
@@ -92,6 +107,7 @@ function resolveInto(latex, scope, context, file, line, column, fix, out) {
92
107
  file,
93
108
  line,
94
109
  token,
110
+ ...at,
95
111
  message: `Unresolved notation ${JSON.stringify(token)} in ${context} math. ${fix}`
96
112
  });
97
113
  }
@@ -156,16 +172,18 @@ function gateContentMath(input) {
156
172
  for (const lesson of notationInput.lessons) {
157
173
  const scope = pageScope(lesson, sharedByKey);
158
174
  const file = lesson.source.file;
175
+ const bodyOffset = (lesson.bodyLine ?? 1) - 1;
159
176
  for (const span of mathSpans(lesson.body)) {
160
177
  resolveInto(
161
178
  span.latex,
162
179
  scope,
163
180
  "body",
164
181
  file,
165
- span.line,
182
+ span.line + bodyOffset,
166
183
  span.column,
167
184
  BODY_FIX,
168
- diagnostics
185
+ diagnostics,
186
+ equationLinkFor(lesson.lessonId, span.latex)
169
187
  );
170
188
  }
171
189
  for (const definition of lesson.localDefinitions) {
@@ -243,6 +261,7 @@ function gateContentMath(input) {
243
261
  return diagnostics;
244
262
  }
245
263
  export {
264
+ equationLinkFor,
246
265
  gateContentMath,
247
266
  pageScope
248
267
  };
@@ -39,6 +39,17 @@ interface AssessmentLike {
39
39
  declare function proseOnly(body: string): string;
40
40
  /** Exported for the same reason as {@link proseOnly}. */
41
41
  declare function lineOf(text: string, index: number): number;
42
+ /**
43
+ * {@link lineOf} corrected to the file the lesson came from. A gate scans
44
+ * `lesson.body`, which starts *after* the frontmatter, so a raw body line is
45
+ * short by the frontmatter's height and points at the wrong source line —
46
+ * silently, because the number is still plausible. Every gate that reports a
47
+ * `line` against `lesson.source.file` must go through this.
48
+ */
49
+ declare function lessonLine(lesson: {
50
+ readonly body: string;
51
+ readonly bodyLine?: number;
52
+ }, index: number): number;
42
53
  /**
43
54
  * Scan every lesson body for plain-text math notation outside `$…$`. Returns
44
55
  * one `MathGateDiagnostic` (context `'prose'`, always an error — N9 has no
@@ -57,4 +68,4 @@ declare function gateProseNotation(lessons: readonly NotationLessonInput[]): Mat
57
68
  */
58
69
  declare function gateProseNotationInAssessments(assessments: readonly AssessmentLike[]): MathGateDiagnostic[];
59
70
 
60
- export { gateProseNotation, gateProseNotationInAssessments, lineOf, proseOnly };
71
+ export { gateProseNotation, gateProseNotationInAssessments, lessonLine, lineOf, proseOnly };
@@ -12,6 +12,9 @@ function proseOnly(body) {
12
12
  function lineOf(text, index) {
13
13
  return text.slice(0, index).split("\n").length;
14
14
  }
15
+ function lessonLine(lesson, index) {
16
+ return lineOf(lesson.body, index) + (lesson.bodyLine ?? 1) - 1;
17
+ }
15
18
  const UNIT = String.raw`m/s²|m/s|km/h|rad/s|m²/s²|J/kg|m²|s⁻¹|kg|N|J|W|Hz|deg|°|rad|m|s`;
16
19
  const NUMERAL_UNIT = new RegExp(
17
20
  String.raw`(?<![\w.])-?\d+(?:\.\d+)?\s?(?:${UNIT})(?![A-Za-z])`,
@@ -89,7 +92,7 @@ function gateProseNotation(lessons) {
89
92
  context: "prose",
90
93
  severity: "error",
91
94
  file: lesson.source.file,
92
- line: lineOf(lesson.body, finding.index),
95
+ line: lessonLine(lesson, finding.index),
93
96
  token: finding.token,
94
97
  message: finding.message
95
98
  });
@@ -120,6 +123,7 @@ function gateProseNotationInAssessments(assessments) {
120
123
  export {
121
124
  gateProseNotation,
122
125
  gateProseNotationInAssessments,
126
+ lessonLine,
123
127
  lineOf,
124
128
  proseOnly
125
129
  };
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Exported for the test that pins both failure messages — a KaTeX upgrade that
3
+ * moves the constant, and a leftover `patches/` file, are the two ways this
4
+ * stops being able to install itself.
5
+ */
6
+ export function rewriteBundle(source: any): any;
7
+ /**
8
+ * Render a known nest and confirm the outer pair really is larger than the
9
+ * inner one. Cheap, and it turns every way this could go wrong — an upstream
10
+ * refactor, a frozen export, an import that beat us to it — into one loud
11
+ * failure at the point of installation.
12
+ */
13
+ export function assertDelimiterGrowth(instance?: typeof katex): void;
14
+ export default katex;
15
+ import katex from 'katex';
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Rule N17's engine half: make `\left`/`\right` grow strictly outward.
3
+ *
4
+ * Importing this module for its side effect installs a KaTeX whose
5
+ * `\delimitershortfall` is `-1pt` instead of the stock `5pt`, so a scalable
6
+ * delimiter pair is always *strictly* taller than what it wraps and nesting
7
+ * depth shows up on its own. See `docs/katex-bracket-scaling.md` for the
8
+ * derivation and `docs/authoring/notation.md#n17` for the authoring rule.
9
+ *
10
+ * ## Why this is not a `patches/` file
11
+ *
12
+ * `delimiterExtend` is a function-local `const` inside `makeLeftRightDelim`.
13
+ * Nothing KaTeX exports reaches it — `__defineMacro`, `__defineFunction`,
14
+ * `__defineSymbol`, `__setFontMetrics` and the settings schema all sit above
15
+ * the layout engine — so the constant can only be changed in the module's own
16
+ * source. A `pnpm patch` does that, but a patch is project-local: it cannot
17
+ * travel inside the published `explico` artifact, so every downstream course
18
+ * would have to reapply it or silently render flat nests.
19
+ *
20
+ * So the engine does it in code instead. It reads KaTeX's own ESM bundle,
21
+ * rewrites that one constant, imports the result as a module, and moves the
22
+ * rendering entry points onto the shared `katex` object every consumer in the
23
+ * process already imported. KaTeX's default export is a plain mutable object
24
+ * and `rehype-katex` reads `katex.renderToString` at call time, so the swap
25
+ * reaches lesson-body math without forking the plugin.
26
+ *
27
+ * ## Why this is safe to rely on
28
+ *
29
+ * The assumptions above (a matching constant, a mutable export, a bundle with
30
+ * no imports of its own) are all checked here, and `assertDelimiterGrowth()`
31
+ * renders a known nest afterwards and throws when the sizes do not actually
32
+ * increase. A course therefore either renders with engine-sized delimiters or
33
+ * fails its build saying so — it cannot quietly get the stock behaviour.
34
+ */
35
+ import { readFileSync } from 'node:fs';
36
+ import { createRequire } from 'node:module';
37
+ import katex from 'katex';
38
+
39
+ /** TeX's `\delimitershortfall`, in points, as KaTeX hard-codes it. */
40
+ const STOCK_SHORTFALL = '5.0';
41
+ /** The negative shortfall that forces strict outward growth. */
42
+ const GROWN_SHORTFALL = '-1.0';
43
+
44
+ const needle = (points) =>
45
+ `var delimiterExtend = ${points} / options.fontMetrics().ptPerEm;`;
46
+
47
+ /** The entry points that build a delimiter and must come from the rewrite. */
48
+ const RENDER_EXPORTS = [
49
+ 'render',
50
+ 'renderToString',
51
+ '__renderToDomTree',
52
+ '__renderToHTMLTree',
53
+ ];
54
+
55
+ /**
56
+ * Exported for the test that pins both failure messages — a KaTeX upgrade that
57
+ * moves the constant, and a leftover `patches/` file, are the two ways this
58
+ * stops being able to install itself.
59
+ */
60
+ export function rewriteBundle(source) {
61
+ const stock = needle(STOCK_SHORTFALL);
62
+ if (!source.includes(stock)) {
63
+ // Either the constant moved or someone already rewrote it. Both mean the
64
+ // assumption this module rests on no longer holds for this KaTeX build.
65
+ const alreadyGrown = source.includes(needle(GROWN_SHORTFALL));
66
+ throw new Error(
67
+ alreadyGrown
68
+ ? `explico: katex ${katex.version} already carries the -1pt delimiter ` +
69
+ 'shortfall (a patches/ file?). Remove that patch — the engine ' +
70
+ 'installs it in code (src/reference/katex-delimiters.mjs).'
71
+ : `explico: cannot install the N17 delimiter sizing into katex ` +
72
+ `${katex.version}: the \\delimitershortfall constant ` +
73
+ `(${stock}) is not in its ESM bundle. Re-derive the one-line ` +
74
+ 'change against the new bundle — see docs/katex-bracket-scaling.md.',
75
+ );
76
+ }
77
+ return source.replace(stock, needle(GROWN_SHORTFALL));
78
+ }
79
+
80
+ async function loadGrownKatex() {
81
+ const require = createRequire(import.meta.url);
82
+ const source = readFileSync(require.resolve('katex/dist/katex.mjs'), 'utf8');
83
+ // The bundle is self-contained, so it can be re-imported from a data URL
84
+ // with no base to resolve relative specifiers against.
85
+ const rewritten = rewriteBundle(source);
86
+ const module = await import(
87
+ `data:text/javascript;base64,${Buffer.from(rewritten).toString('base64')}`
88
+ );
89
+ return module.default;
90
+ }
91
+
92
+ /**
93
+ * Render a known nest and confirm the outer pair really is larger than the
94
+ * inner one. Cheap, and it turns every way this could go wrong — an upstream
95
+ * refactor, a frozen export, an import that beat us to it — into one loud
96
+ * failure at the point of installation.
97
+ */
98
+ export function assertDelimiterGrowth(instance = katex) {
99
+ const html = instance.renderToString(
100
+ String.raw`\left[1+\left(\frac{a}{b}\right)\right]`,
101
+ { displayMode: true, output: 'html' },
102
+ );
103
+ const sizes = [...html.matchAll(/delimsizing size(\d)/g)].map((m) =>
104
+ Number(m[1]),
105
+ );
106
+ // Two pairs, so four glyphs: outer, inner, inner, outer.
107
+ const grew = sizes.length === 4 && sizes[0] > sizes[1];
108
+ if (!grew) {
109
+ throw new Error(
110
+ 'explico: the N17 delimiter sizing did not take effect — a nested ' +
111
+ `\\left…\\right pair rendered as [${sizes.join(', ')}] instead of ` +
112
+ 'growing outward. Something rendered math before ' +
113
+ "'explico/reference/katex-delimiters.mjs' was imported, or katex " +
114
+ 'changed shape. See docs/katex-bracket-scaling.md.',
115
+ );
116
+ }
117
+ }
118
+
119
+ const grown = await loadGrownKatex();
120
+ for (const name of RENDER_EXPORTS) {
121
+ if (typeof grown[name] === 'function') katex[name] = grown[name];
122
+ }
123
+ assertDelimiterGrowth();
124
+
125
+ export default katex;
@@ -1,3 +1,7 @@
1
+ // Side-effect import: installs rule N17's delimiter sizing onto the shared
2
+ // `katex` module before anything renders. A course wires this file into its
3
+ // `astro.config.mjs`, so `rehype-katex` inherits it without a patch.
4
+ import './katex-delimiters.mjs';
1
5
  import { NOTATION_KEY_PATTERN } from './remark-notation.mjs';
2
6
 
3
7
  export const EXPLAIN_MACRO = String.raw`\htmlData{notation-key=#1}{#2}`;
@@ -1,4 +1,4 @@
1
- import katex from 'katex';
1
+ import katex from './katex-delimiters.mjs';
2
2
 
3
3
  import { BASE_LIBRARY } from './base-library.mjs';
4
4
  import { PARENTHESIZED_FUNCTIONS } from './base-operators.mjs';
@@ -386,6 +386,19 @@ function trimTrailingWhitespace(latex, start, end) {
386
386
  return trimmed;
387
387
  }
388
388
 
389
+ /**
390
+ * KaTeX gives a node it produced by expanding a macro a `loc` from the lexer
391
+ * over the *macro body*, not over the source being scanned: `\vdots` expands
392
+ * to `{\varvdots\rule{0pt}{15pt}}`, and its `\varvdots` atom arrives with
393
+ * `loc.start = 1`, `loc.end = 10` — offsets into that 27-character body.
394
+ * Those numbers are valid indices into any longer source, so they read as a
395
+ * real span pointing at unrelated text. Every `loc` therefore has to prove it
396
+ * belongs to the string this pass parsed before its offsets are believed.
397
+ */
398
+ function isAuthoredLocation(loc, latex) {
399
+ return loc?.lexer?.input === latex;
400
+ }
401
+
389
402
  function nodeSpan(node, latex) {
390
403
  let start = Number.POSITIVE_INFINITY;
391
404
  let end = Number.NEGATIVE_INFINITY;
@@ -397,7 +410,8 @@ function nodeSpan(node, latex) {
397
410
  if (
398
411
  value.loc &&
399
412
  Number.isInteger(value.loc.start) &&
400
- Number.isInteger(value.loc.end)
413
+ Number.isInteger(value.loc.end) &&
414
+ isAuthoredLocation(value.loc, latex)
401
415
  ) {
402
416
  start = Math.min(start, value.loc.start);
403
417
  end = Math.max(end, value.loc.end);
@@ -1,4 +1,4 @@
1
- import katex from "katex";
1
+ import katex from "./katex-delimiters.mjs";
2
2
  import { resolveMathGlyphs, stripNotationMarkers } from "./math-glyphs.mjs";
3
3
  import { createNotationKatexOptions } from "./katex-options.mjs";
4
4
  function slotPlaceholder(name) {
@@ -78,6 +78,13 @@ interface NotationLessonInput {
78
78
  readonly references: readonly NotationReferenceInput[];
79
79
  /** Original lesson body, retained for lossless loader adapters. */
80
80
  readonly body: string;
81
+ /**
82
+ * 1-based line in `source.file` where `body` starts. Gates scan the body, so
83
+ * every finding's index is body-relative; this is what turns one into a real
84
+ * location in the file. Optional so a hand-built input (a test, an adapter)
85
+ * stays valid — it then behaves as 1, the pre-0.7.1 behavior.
86
+ */
87
+ readonly bodyLine?: number;
81
88
  readonly source: SourceSpan;
82
89
  }
83
90
  interface NotationRegistryInput {
@@ -106,7 +106,7 @@ file table.
106
106
  | [N14](notation.md#n14) | An abbreviation-shaped word in prose is universal, or explained | Build-enforced (`gate-abbreviations.ts`) |
107
107
  | [N15](notation.md#n15) | A nested `(`, `[`, `{`, or `\|` must use scalable sizing | Build-enforced (`resolveMathGlyphs`) |
108
108
  | [N16](notation.md#n16) | AI-generated stylistic grouping alternates delimiter types | Review-only |
109
- | [N17](notation.md#n17) | Nested scalable delimiters grow strictly from inside to outside | Tier-3 warning (`delimiter-size-hierarchy`) |
109
+ | [N17](notation.md#n17) | A nested delimiter is sized by the engine, never by hand | Tier-3 warning (`delimiter-manual-size`) |
110
110
  | [V1](visualizations.md#v1) | Graphs and flowcharts use the `Diagram` component | Review-only |
111
111
  | [V2](visualizations.md#v2) | Static charts: SVG → D3 → Observable Plot, chosen per figure | Review-only |
112
112
  | [V3](visualizations.md#v3) | Interactive figures: island + `renderLabMath` seam + text alt | Review-only |
@@ -33,6 +33,234 @@ required config block throws with the section of this file to read.
33
33
 
34
34
  ---
35
35
 
36
+ ## 0.7.0 → 0.8.0
37
+
38
+ ### 1. Rule N17 is inverted: delimiters are sized by the engine (`\left`/`\right` everywhere)
39
+
40
+ The engine now installs its own KaTeX delimiter sizing, so a `\left … \right`
41
+ pair is always strictly taller than its content and a nest shows its depth
42
+ without help. Hand-picking `\bigl` → `\Bigl` → `\biggl` → `\Biggl` outward —
43
+ which rule N17 previously *required* — is now the thing the linter warns about.
44
+
45
+ **There is no install step.** `astro.config.mjs` already imports
46
+ `explico/reference/katex-options.mjs`, and that module now imports
47
+ `explico/reference/katex-delimiters.mjs` for its side effect. Confirm the import
48
+ is still there; a course that inlined its own KaTeX options object instead must
49
+ add, as the **first** import in `astro.config.mjs`:
50
+
51
+ ```js
52
+ import 'explico/reference/katex-delimiters.mjs';
53
+ ```
54
+
55
+ Order matters: the installer moves KaTeX's render entry points onto the shared
56
+ `katex` object, so anything that renders math before the import would keep stock
57
+ spacing. It refuses to fail quietly — it renders a known nest at install time and
58
+ throws if the sizes did not grow.
59
+
60
+ Two course-side conditions make the build throw, both with an explicit message:
61
+
62
+ - **A `patches/katex@*.patch` file that already applies a negative
63
+ `\delimitershortfall`.** Delete the patch and its `pnpm.patchedDependencies`
64
+ entry; the engine does this in code now, and a patch is project-local anyway
65
+ so it never reached a consumer.
66
+ - **A `katex` whose bundle no longer holds the constant where the engine looks
67
+ for it.** The rewrite targets one line in KaTeX's ESM bundle, so keep `katex`
68
+ on the declared peer version (`0.16.47`). An upgrade that moves or reformats
69
+ that line fails the build asking you to re-derive the change against
70
+ [`../katex-bracket-scaling.md`](../katex-bracket-scaling.md), which is
71
+ shipped in the package. The failure is loud and immediate, not a silent
72
+ return to stock spacing.
73
+
74
+ ### 2. Diagnostic renamed: `delimiter-size-hierarchy` → `delimiter-manual-size`
75
+
76
+ The old code string no longer exists. Update every place that names it:
77
+
78
+ - `lint-ignore.yml` — a stale line is inert and silently stops suppressing.
79
+ - Course scripts, tests, and type narrowings over the diagnostic union;
80
+ `astro check` flags a comparison against a non-member.
81
+
82
+ Its payload changed shape too. A consumer reading the fields, rather than just
83
+ the `code` and `message`, needs this edit:
84
+
85
+ ```diff
86
+ -// outerSize / nestedSize described the mismatch between two levels
87
+ -const found = diagnostic.outerSize;
88
+ -const suggested = diagnostic.suggestion?.size;
89
+ +// `size` is the hand-picked size found; `suggestion` is always present and is
90
+ +// always a \left/\right pair, so it no longer carries a `size` of its own.
91
+ +const found = diagnostic.size;
92
+ +const suggested = diagnostic.suggestion.latex;
93
+ ```
94
+
95
+ `DelimiterSizeSuggestion` keeps `open`, `close`, and `latex`, and drops `size`.
96
+ `DelimiterSizeDiagnostic` drops `outerSize` and `nestedSize`, gains a required
97
+ `size`, and `suggestion` is no longer optional.
98
+
99
+ ### 3. Content: hand-sized nests now warn
100
+
101
+ Math that was N17-compliant under 0.7.0 reports one `delimiter-manual-size`
102
+ finding per hand-sized level. This is a Tier-3 warning — it does not fail a
103
+ build — so it can be worked through at your own pace, but the findings are real
104
+ and the repair improves the rendering.
105
+
106
+ Find them with:
107
+
108
+ ```
109
+ pnpm content warnings --json
110
+ ```
111
+
112
+ The repair is mechanical: replace the hand-picked pair with `\left`/`\right`,
113
+ keeping the delimiter shapes.
114
+
115
+ ```diff
116
+ -\Biggl[a+\biggl(b+\Bigl[c+\left(d+e\right)\Bigr]\biggr)\Biggr]
117
+ +\left[a+\left(b+\left[c+\left(d+e\right)\right]\right)\right]
118
+ ```
119
+
120
+ A **lone** hand-sized pair that is not part of a nest — `\bigl(x,\ y\bigr)`
121
+ around a tuple, for instance — is a single-level typographic choice with no
122
+ hierarchy to get wrong, and is deliberately left alone. Do not convert those.
123
+
124
+ Expect a small visual change everywhere a nest is rendered: each level is now
125
+ strictly larger than the one inside it, so outer delimiters grow by roughly one
126
+ size step. A course holding screenshot or bounding-box baselines over math
127
+ should re-record them.
128
+
129
+ ### 4. Manifest schema is now v16: a math diagnostic can name its equation
130
+
131
+ `MANIFEST_SCHEMA_VERSION` is 16 (v15 → v16). The bump is **additive** — nothing
132
+ existing changed shape — so the only required edit is to a client that asserts
133
+ the number by exact equality, which now expects 16.
134
+
135
+ What was added: a finding the math gate reports from a `$$…$$` block carrying a
136
+ `\label{eq:…}` gains an optional `equation` field, `{ key, href }`. `href` is
137
+ the rendered page's anchor for that equation
138
+ (`/l1-ar/ar1-basics/#eq-ar1-variance`). `CliDiagnostic` carries the same
139
+ optional field, so a course's own `scripts/content.ts` picks it up with no
140
+ change.
141
+
142
+ `diagnosticLine()` now appends it, so console output, the run log, and
143
+ `pnpm content diagnostics` read:
144
+
145
+ ```
146
+ [code] file:line:column: message (eq:<key> -> <href>)
147
+ ```
148
+
149
+ A course script that parses that line with an anchored regex must tolerate the
150
+ optional trailing ` (eq:… -> …)`. One that just prints the string needs nothing.
151
+
152
+ ### 5. Diagnostic line numbers are corrected — re-record anything that stored them
153
+
154
+ Every gate scans the lesson body, which Astro hands over with the frontmatter
155
+ already stripped, but findings are reported against the lesson *file* — so every
156
+ `file:line:column` the engine has emitted for a lesson was short by the height
157
+ of that lesson's frontmatter. `gate-math` (every `delimiter-manual-size`
158
+ column), `gate-prose-notation` (rule N9), `gate-abbreviations` (rule N14), and
159
+ `gate-heading-length` (S1) are all corrected at once.
160
+
161
+ Nothing to edit in content, but line numbers move, so:
162
+
163
+ - Re-check any suppression, snapshot, or problem matcher keyed to a line number.
164
+ Line numbers were wrong before; they now point at the real line.
165
+ - `pnpm validate:content` (twice) regenerates `build/resolution/*` with the
166
+ corrected positions. Commit the result.
167
+
168
+ ### 6. Malformed `\explain` / `\slot` keys now fail the build
169
+
170
+ Previously a key that did not match the notation key pattern —
171
+ `\explain{Not A Key}{\Omega}` — was not a KaTeX parse error: KaTeX rendered the
172
+ command name in its error color and carried on, so the page shipped red
173
+ `\htmlData` text with a green build. `renderNotationMath` now checks both marker
174
+ macros before KaTeX runs and throws, naming the offending key.
175
+
176
+ **This can newly fail a build that passed on 0.7.0.** If it does, the page was
177
+ already broken for readers. Fix the key to match the notation key pattern
178
+ (lower-case, hyphen-separated) and make sure it resolves to a real entry.
179
+ `renderNotationFormula` still degrades to inert-but-correct math rather than
180
+ costing a reader the equation.
181
+
182
+ ### 7. Glossary entry heading markup: the anchor is now a sibling of the `<h2>`
183
+
184
+ The per-entry anchor link moved out of the `<h2>` and into a new
185
+ `.glossary-heading` flex row wrapping both — the same shape
186
+ `rehype-heading-anchors` gives a lesson heading. This restores the heading's
187
+ accessible name (a screen reader was announcing "Velocity Link to Velocity").
188
+
189
+ Nothing visual changed, but update course-owned CSS and end-to-end tests that
190
+ assumed the nesting:
191
+
192
+ ```diff
193
+ -h2:hover .glossary-anchor,
194
+ -h2:focus-within .glossary-anchor {
195
+ +.glossary-heading:hover .glossary-anchor,
196
+ +.glossary-heading:focus-within .glossary-anchor {
197
+ ```
198
+
199
+ A test locating the anchor as a descendant of the heading must target
200
+ `.glossary-heading .glossary-anchor` instead. A check for the heading *by
201
+ accessible name* now matches the term alone, which is the point of the fix — if
202
+ such a test was written against the broken name ("Velocity Link to Velocity"),
203
+ it needs the term.
204
+
205
+ ### 8. The About panel publishes its versions as data attributes
206
+
207
+ Additive; no course edit is required. `details[data-about-panel]` now carries
208
+ `data-knowledge-version`, `data-knowledge-date` (omitted when the content
209
+ changelog has no date), and `data-engine-version`, and the rendered version line
210
+ is marked `data-knowledge-line`.
211
+
212
+ This exists so a build check does not have to match prose. A course asserting
213
+ the panel's contents by text is pinned to one version string and turns every
214
+ routine bump into a test edit — this repo's own `about.spec.ts` was asserting
215
+ `0.4.0` against a playground long since past it. Prefer:
216
+
217
+ ```diff
218
+ -await expect(panel).toContainText('0.6.0');
219
+ +await expect(panel).toHaveAttribute('data-knowledge-version', knowledgeVersion);
220
+ ```
221
+
222
+ reading the expected value from `content/course.config.ts` rather than hard-coding
223
+ it. A bare version substring is not scoped to the panel either — the engine
224
+ version printed one row below can satisfy it.
225
+
226
+ ### 9. Fixes that need no action
227
+
228
+ These remove build failures; they never require a content edit. Formulas you
229
+ previously worked around may now be written directly.
230
+
231
+ - **A notation `formula` can use a manual delimiter size, an accent, a box, or
232
+ a color.** `render-notation-math.ts`'s attribute allowlist is now KaTeX's
233
+ whole inert presentation surface, so `\Bigl`/`\biggl`/`\Biggl`,
234
+ `\xrightarrow`, `\boxed`, and `\colorbox` no longer throw
235
+ `Unexpected KaTeX property`. `alt`, `href`, `src`, and `title` are explicitly
236
+ denied; no attribute that can carry a URL is allowed through.
237
+ - **`\vec` and every other accent renders.** The style sanitizer no longer
238
+ requires a trailing `;` on the last CSS declaration. `\mathbf` remains the
239
+ house style for a vector; `\vec` simply works now.
240
+ - **A macro in a formula no longer invents a source span.** `\vdots`,
241
+ `\varSigma`, `\argmax`, `\bmod`, `\R`/`\N`/`\Z` and 79 of KaTeX's 282 macros
242
+ produce nodes whose `loc` indexes the macro body, which the glyph resolver
243
+ read as a span over unrelated text — failing with `Unresolved notation ""`,
244
+ or worse, silently splitting a formula mid-command. Fixed. An existing
245
+ explicit `\explain{key}{\varSigma}` annotation still works and is still the
246
+ way to bind a macro-expanded glyph, since it has no authored span for
247
+ canonical matching to see.
248
+ - **`equationAnchorId({ key })` type-checks without a `number`.** Its JSDoc
249
+ required a property the function ignores when a `key` is given.
250
+
251
+ ### 10. Verify
252
+
253
+ ```
254
+ pnpm add -D explico@0.8.0
255
+ pnpm validate:content # twice — the first run regenerates build/resolution/*
256
+ pnpm content check --changed
257
+ pnpm content warnings # expect new delimiter-manual-size findings (item 3)
258
+ astro check
259
+ astro build
260
+ ```
261
+
262
+ Commit the regenerated `build/resolution/*` (items 4 and 5 both change it).
263
+
36
264
  ## 0.6.0 → 0.7.0
37
265
 
38
266
  ### 1. Manifest schema is now v15: diagnostic severity has three levels