explico 0.2.0 → 0.4.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 (83) hide show
  1. package/CHANGELOG.md +173 -0
  2. package/README.md +67 -7
  3. package/dist/compiler/astro.d.ts +30 -0
  4. package/dist/compiler/astro.js +79 -0
  5. package/dist/compiler/cli.d.ts +16 -0
  6. package/dist/compiler/cli.js +43 -9
  7. package/dist/compiler/collections.js +4 -1
  8. package/dist/compiler/course-config.d.ts +80 -4
  9. package/dist/compiler/course-config.js +12 -0
  10. package/dist/compiler/knowledge-version.d.ts +58 -0
  11. package/dist/compiler/knowledge-version.js +80 -0
  12. package/dist/compiler/lesson-derivation.d.ts +1 -1
  13. package/dist/compiler/lesson-derivation.js +2 -2
  14. package/dist/compiler/manifest.d.ts +98 -2
  15. package/dist/compiler/manifest.js +153 -24
  16. package/dist/compiler/resolution-report.d.ts +4 -0
  17. package/dist/reference/base-operators.d.mts +38 -0
  18. package/dist/reference/base-operators.mjs +103 -0
  19. package/dist/reference/consistency.d.ts +7 -2
  20. package/dist/reference/consistency.js +51 -2
  21. package/dist/reference/diagram-layout.d.ts +63 -0
  22. package/dist/reference/diagram-layout.js +122 -0
  23. package/dist/reference/equations-validate.d.ts +10 -0
  24. package/dist/reference/equations-validate.js +1 -1
  25. package/dist/reference/equations.d.mts +80 -13
  26. package/dist/reference/equations.mjs +109 -19
  27. package/dist/reference/figures-validate.d.ts +21 -0
  28. package/dist/reference/figures-validate.js +13 -0
  29. package/dist/reference/keyed-float-validate.d.ts +73 -0
  30. package/dist/reference/keyed-float-validate.js +110 -0
  31. package/dist/reference/keyed-jsx-floats.d.mts +64 -0
  32. package/dist/reference/keyed-jsx-floats.mjs +191 -0
  33. package/dist/reference/math-glyphs.d.mts +18 -0
  34. package/dist/reference/math-glyphs.mjs +92 -0
  35. package/dist/reference/remark-notation.d.mts +61 -0
  36. package/dist/reference/remark-notation.mjs +344 -10
  37. package/dist/reference/tables-validate.d.ts +21 -0
  38. package/dist/reference/tables-validate.js +13 -0
  39. package/dist/reference/units-vocab.js +19 -1
  40. package/docs/VERSIONING.md +66 -0
  41. package/docs/authoring/README.md +100 -0
  42. package/docs/authoring/agents.md +63 -0
  43. package/docs/authoring/migrating.md +169 -0
  44. package/docs/authoring/notation.md +297 -0
  45. package/docs/authoring/structure.md +139 -0
  46. package/docs/authoring/versioning.md +116 -0
  47. package/docs/authoring/visualizations.md +195 -0
  48. package/package.json +20 -5
  49. package/src/compiler/astro.ts +127 -0
  50. package/src/compiler/cli.ts +52 -7
  51. package/src/compiler/collections.ts +12 -3
  52. package/src/compiler/course-config.ts +93 -3
  53. package/src/compiler/knowledge-version.ts +157 -0
  54. package/src/compiler/lesson-derivation.ts +2 -2
  55. package/src/compiler/manifest.ts +320 -26
  56. package/src/components/CurriculumMap.astro +27 -0
  57. package/src/components/diagram/Diagram.astro +317 -0
  58. package/src/components/examples/CompactExamples.astro +1 -1
  59. package/src/components/figure/Figure.astro +104 -0
  60. package/src/components/notation/NotationLayer.astro +11 -0
  61. package/src/components/notation/render-notation-math.ts +13 -2
  62. package/src/components/reference/FloatRefLayer.astro +671 -0
  63. package/src/components/starlight/AboutPanel.astro +255 -0
  64. package/src/components/starlight/HeadingAnchorEnhancer.astro +3 -3
  65. package/src/components/starlight/KeyedFloatEnhancer.astro +137 -0
  66. package/src/components/starlight/LayoutSidebar.astro +62 -1
  67. package/src/components/starlight/LayoutSocialIcons.astro +12 -0
  68. package/src/components/starlight/LessonFooter.astro +15 -2
  69. package/src/components/table/Table.astro +105 -0
  70. package/src/reference/base-operators.mjs +103 -0
  71. package/src/reference/consistency.ts +83 -2
  72. package/src/reference/diagram-layout.ts +246 -0
  73. package/src/reference/equations-validate.ts +8 -1
  74. package/src/reference/equations.mjs +109 -19
  75. package/src/reference/figures-validate.ts +40 -0
  76. package/src/reference/keyed-float-validate.ts +210 -0
  77. package/src/reference/keyed-jsx-floats.mjs +191 -0
  78. package/src/reference/math-glyphs.mjs +92 -0
  79. package/src/reference/remark-notation.mjs +344 -10
  80. package/src/reference/tables-validate.ts +40 -0
  81. package/src/reference/units-vocab.ts +18 -0
  82. package/src/styles/global.css +244 -7
  83. package/src/components/starlight/EquationEnhancer.astro +0 -87
@@ -0,0 +1,103 @@
1
+ /**
2
+ * The base operator library: the named mathematical operators every reader is
3
+ * assumed to know, so a lesson never has to introduce them with a notation
4
+ * entry.
5
+ *
6
+ * Rationale (parallel to `base-library.mjs`): the completeness gate has no
7
+ * opinion about operators — KaTeX parses `\exp`, `\int`, `\sin`, … as operator
8
+ * nodes, and `\operatorname{Var}` as an `operatorname` node, none of which the
9
+ * glyph resolver treats as a bindable symbol. The `operator-explained`
10
+ * consistency check (rule N8) fills that gap: an operator a lesson uses is
11
+ * either on this list (or the course's additions to it) or it carries a
12
+ * notation entry the reader can hover. This set is the "everyone knows these"
13
+ * baseline — trig, the exponential and logarithms, limits, extrema, and the
14
+ * big operators — kept greppable and small like the base library.
15
+ *
16
+ * A domain operator (`Var`, `Cov`, `Corr`, `sgn`, `erf`, `Tr`, `argmax`, …) is
17
+ * written `\operatorname{…}` and is NOT here: it earns a card, a
18
+ * `notation.local` entry, or an explicit line in `course.config.ts`
19
+ * (`operators.allow`).
20
+ *
21
+ * Entries are the KaTeX command without its backslash (`exp`, not `\exp`) for
22
+ * `op` nodes, and the verbatim body for `\operatorname{…}` nodes (`Var`).
23
+ * Matching is case-sensitive.
24
+ */
25
+ export const BASE_OPERATORS = Object.freeze(
26
+ new Set([
27
+ // exponential / logarithms
28
+ 'exp',
29
+ 'log',
30
+ 'ln',
31
+ 'lg',
32
+ // trigonometric
33
+ 'sin',
34
+ 'cos',
35
+ 'tan',
36
+ 'cot',
37
+ 'sec',
38
+ 'csc',
39
+ // inverse trigonometric
40
+ 'arcsin',
41
+ 'arccos',
42
+ 'arctan',
43
+ // hyperbolic
44
+ 'sinh',
45
+ 'cosh',
46
+ 'tanh',
47
+ 'coth',
48
+ // limits
49
+ 'lim',
50
+ 'limsup',
51
+ 'liminf',
52
+ 'injlim',
53
+ 'projlim',
54
+ 'varlimsup',
55
+ 'varliminf',
56
+ 'varinjlim',
57
+ 'varprojlim',
58
+ // extrema
59
+ 'max',
60
+ 'min',
61
+ 'sup',
62
+ 'inf',
63
+ // linear algebra / number theory / misc
64
+ 'arg',
65
+ 'deg',
66
+ 'det',
67
+ 'dim',
68
+ 'gcd',
69
+ 'hom',
70
+ 'ker',
71
+ 'Pr',
72
+ 'mod',
73
+ // big operators
74
+ 'sum',
75
+ 'prod',
76
+ 'coprod',
77
+ 'int',
78
+ 'iint',
79
+ 'iiint',
80
+ 'oint',
81
+ ]),
82
+ );
83
+
84
+ /**
85
+ * The operators a course treats as common, given its `course.config.ts`
86
+ * `operators` block: the shipped baseline, plus `allow`, minus `deny`.
87
+ *
88
+ * @param {{ allow?: readonly string[], deny?: readonly string[] } | undefined} operators
89
+ * @returns {Set<string>}
90
+ */
91
+ export function effectiveOperatorAllowlist(operators) {
92
+ const allow = operators?.allow ?? [];
93
+ const deny = new Set(operators?.deny ?? []);
94
+ const effective = new Set(BASE_OPERATORS);
95
+ for (const name of allow) effective.add(name);
96
+ for (const name of deny) effective.delete(name);
97
+ return effective;
98
+ }
99
+
100
+ /** True when `name` needs no notation entry for this course. */
101
+ export function isBaseOperator(name, operators) {
102
+ return effectiveOperatorAllowlist(operators).has(name);
103
+ }
@@ -19,15 +19,23 @@
19
19
  * (f) weak-local a `notation.local` symbol used only in its
20
20
  * own defining equation and prose
21
21
  * (g) notation-source-locator every notation `sources` entry has a locator
22
+ * (h) operator-explained a named operator a lesson uses is on the
23
+ * shipped/course operator allowlist or
24
+ * carries a notation entry (rule N8)
22
25
  *
23
26
  * Check (d) — the committed per-lesson resolution report — lives in
24
27
  * `src/content/resolution-report.ts`.
25
28
  */
26
29
 
27
- import type { CourseConventions } from '../compiler/course-config';
30
+ import type {
31
+ CourseConventions,
32
+ CourseOperators,
33
+ } from '../compiler/course-config';
28
34
  import type { CurriculumCatalog } from '../curriculum/validation';
29
35
  import type { NotationSpec } from '../compiler/collections';
30
36
  import type { LintIgnore } from './lint-ignore';
37
+ import { effectiveOperatorAllowlist } from './base-operators.mjs';
38
+ import { collectOperators } from './math-glyphs.mjs';
31
39
  import { offVocabularyTokens } from './units-vocab';
32
40
  import type {
33
41
  NotationDefinitionRecord,
@@ -45,7 +53,8 @@ export type ConsistencyCode =
45
53
  | 'notation-source-locator'
46
54
  | 'gloss-wants-promoting'
47
55
  | 'card-wants-demoting'
48
- | 'gloss-name-shape';
56
+ | 'gloss-name-shape'
57
+ | 'operator-explained';
49
58
 
50
59
  export interface ConsistencyDiagnostic {
51
60
  readonly code: ConsistencyCode;
@@ -68,6 +77,8 @@ export interface ConsistencyInput {
68
77
  readonly lintIgnore: LintIgnore;
69
78
  /** Convention keys this course anchors on (from `content/course.config.ts`). */
70
79
  readonly conventions: CourseConventions;
80
+ /** This course's edits to the named-operator baseline (rule N8). */
81
+ readonly operators?: CourseOperators;
71
82
  }
72
83
 
73
84
  const CONVENTION_RE =
@@ -472,6 +483,74 @@ function checkGlossNameShape(
472
483
  }
473
484
  }
474
485
 
486
+ /**
487
+ * A named operator a lesson uses is either universal — on the shipped
488
+ * `BASE_OPERATORS` baseline plus this course's `operators.allow`, minus its
489
+ * `operators.deny` — or it is explained: a notation card or `notation.local`
490
+ * entry the lesson binds with `[[key]]` whose `latex` is that operator, so the
491
+ * reader can hover it. Anything else (`\operatorname{Var}` with no entry, or a
492
+ * baseline operator the course chose to `deny`) is a finding. Rule N8.
493
+ *
494
+ * The glyph resolver skips operator bodies whole, so this is the only check
495
+ * that looks at them; it reads `collectOperators`, which parses with the same
496
+ * pinned KaTeX as the completeness gate.
497
+ */
498
+ function checkOperatorExplained(
499
+ input: ConsistencyInput,
500
+ out: ConsistencyDiagnostic[],
501
+ ): void {
502
+ const allowlist = effectiveOperatorAllowlist(input.operators);
503
+ const byId = new Map<string, NotationDefinitionRecord>(
504
+ input.registry.definitions.map((definition) => [definition.id, definition]),
505
+ );
506
+ const bindingsByLesson = new Map(
507
+ input.registry.bundles.map((bundle) => [bundle.lessonId, bundle.bindings]),
508
+ );
509
+
510
+ for (const lesson of input.notationInput.lessons) {
511
+ // Operators the reader is shown on this page: the lesson's own
512
+ // `notation.local` entries, plus every shared card it binds with `[[key]]`.
513
+ const explained = new Set<string>();
514
+ const noteEntryOperators = (latex: string): void => {
515
+ for (const operator of collectOperators(latex)) {
516
+ explained.add(operator.name);
517
+ }
518
+ };
519
+ for (const definition of lesson.localDefinitions) {
520
+ noteEntryOperators(definition.notation);
521
+ }
522
+ for (const binding of bindingsByLesson.get(lesson.lessonId) ?? []) {
523
+ const definition = byId.get(binding.definitionId);
524
+ if (definition) noteEntryOperators(definition.notation);
525
+ }
526
+
527
+ const reported = new Set<string>();
528
+ for (const span of mathSpans(lesson.body)) {
529
+ for (const operator of collectOperators(span.latex)) {
530
+ if (allowlist.has(operator.name)) continue;
531
+ if (explained.has(operator.name)) continue;
532
+ if (reported.has(operator.name)) continue;
533
+ reported.add(operator.name);
534
+ if (input.lintIgnore.matches('operator-explained', operator.name)) {
535
+ continue;
536
+ }
537
+ const display =
538
+ operator.source === 'operatorname'
539
+ ? `\\operatorname{${operator.name}}`
540
+ : `\\${operator.name}`;
541
+ out.push({
542
+ code: 'operator-explained',
543
+ severity: 'warning',
544
+ lessonId: lesson.lessonId,
545
+ file: lesson.source.file,
546
+ glyph: operator.name,
547
+ message: `${lesson.lessonId}: operator ${JSON.stringify(display)} is neither on the course operator allowlist nor explained by a notation entry; give it a card or notation.local entry (latex ${JSON.stringify(display)}) introduced with [[key]], or add ${JSON.stringify(operator.name)} to operators.allow in course.config.ts`,
548
+ });
549
+ }
550
+ }
551
+ }
552
+ }
553
+
475
554
  const CHECKS: ((
476
555
  input: ConsistencyInput,
477
556
  out: ConsistencyDiagnostic[],
@@ -485,6 +564,7 @@ const CHECKS: ((
485
564
  checkGlossWantsPromoting,
486
565
  checkCardWantsDemoting,
487
566
  checkGlossNameShape,
567
+ checkOperatorExplained,
488
568
  ];
489
569
 
490
570
  /**
@@ -522,6 +602,7 @@ export function consistencyCounts(
522
602
  'gloss-wants-promoting': 0,
523
603
  'card-wants-demoting': 0,
524
604
  'gloss-name-shape': 0,
605
+ 'operator-explained': 0,
525
606
  } satisfies Record<ConsistencyCode, number>;
526
607
  for (const diagnostic of diagnostics) counts[diagnostic.code] += 1;
527
608
  return counts;
@@ -0,0 +1,246 @@
1
+ /**
2
+ * Build-time graph layout for the `Diagram` component.
3
+ *
4
+ * Wraps `@dagrejs/dagre` — a pure layout algorithm, no DOM — so node
5
+ * positions and edge routes are computed once at build time, the same way
6
+ * `renderLabMath` and the notation formulas are: static output, no runtime
7
+ * layout library shipped to the reader. `Diagram.astro` turns the result into
8
+ * SVG; this module only computes geometry and is deliberately framework-free
9
+ * so it is unit-testable without KaTeX, Astro, or a browser.
10
+ *
11
+ * Box sizes are not measured from a real render (there is no DOM at build
12
+ * time) — `estimateBoxSize` is a deliberately generous character-count
13
+ * heuristic. It is meant to avoid overlap and clipping in the common case,
14
+ * not to be pixel-accurate; a diagram author can always pass an explicit
15
+ * `width`/`height` on a node for a wide equation.
16
+ */
17
+ import dagre from '@dagrejs/dagre';
18
+ import type {
19
+ EdgeLabel as DagreEdgeLabel,
20
+ GraphLabel as DagreGraphLabel,
21
+ NodeLabel as DagreNodeLabel,
22
+ } from '@dagrejs/dagre';
23
+
24
+ export interface DiagramNodeInput {
25
+ readonly id: string;
26
+ readonly width: number;
27
+ readonly height: number;
28
+ }
29
+
30
+ export interface DiagramEdgeInput {
31
+ readonly from: string;
32
+ readonly to: string;
33
+ }
34
+
35
+ export interface DiagramLayoutOptions {
36
+ /** Top-to-bottom (default) or left-to-right. */
37
+ readonly direction?: 'TB' | 'LR';
38
+ readonly nodeSeparation?: number;
39
+ readonly rankSeparation?: number;
40
+ /** Uniform padding added around the computed bounding box. */
41
+ readonly padding?: number;
42
+ }
43
+
44
+ export interface Point {
45
+ readonly x: number;
46
+ readonly y: number;
47
+ }
48
+
49
+ export interface LaidOutNode {
50
+ readonly id: string;
51
+ /** Top-left corner, not the center dagre itself works in. */
52
+ readonly x: number;
53
+ readonly y: number;
54
+ readonly width: number;
55
+ readonly height: number;
56
+ }
57
+
58
+ export interface LaidOutEdge {
59
+ readonly from: string;
60
+ readonly to: string;
61
+ readonly points: readonly Point[];
62
+ }
63
+
64
+ export interface DiagramLayout {
65
+ readonly nodes: readonly LaidOutNode[];
66
+ readonly edges: readonly LaidOutEdge[];
67
+ /** The SVG canvas size a caller should use as the `viewBox`. */
68
+ readonly width: number;
69
+ readonly height: number;
70
+ }
71
+
72
+ /**
73
+ * Lay out a small directed graph. Node ids must be unique; an edge naming an
74
+ * undeclared node fails loudly rather than silently dropping it (dagre itself
75
+ * would otherwise create a phantom zero-size node).
76
+ */
77
+ export function layoutDiagram(
78
+ nodes: readonly DiagramNodeInput[],
79
+ edges: readonly DiagramEdgeInput[],
80
+ options: DiagramLayoutOptions = {},
81
+ ): DiagramLayout {
82
+ const ids = new Set<string>();
83
+ for (const node of nodes) {
84
+ if (ids.has(node.id)) {
85
+ throw new Error(`Diagram declares node "${node.id}" more than once.`);
86
+ }
87
+ if (!(node.width > 0) || !(node.height > 0)) {
88
+ throw new Error(
89
+ `Diagram node "${node.id}" must have a positive width and height.`,
90
+ );
91
+ }
92
+ ids.add(node.id);
93
+ }
94
+ for (const edge of edges) {
95
+ if (!ids.has(edge.from)) {
96
+ throw new Error(`Diagram edge references unknown node "${edge.from}".`);
97
+ }
98
+ if (!ids.has(edge.to)) {
99
+ throw new Error(`Diagram edge references unknown node "${edge.to}".`);
100
+ }
101
+ }
102
+
103
+ const graph = new dagre.graphlib.Graph<
104
+ DagreGraphLabel,
105
+ DagreNodeLabel,
106
+ DagreEdgeLabel
107
+ >();
108
+ graph.setGraph({
109
+ rankdir: options.direction ?? 'TB',
110
+ nodesep: options.nodeSeparation ?? 24,
111
+ ranksep: options.rankSeparation ?? 48,
112
+ marginx: 0,
113
+ marginy: 0,
114
+ });
115
+ graph.setDefaultEdgeLabel(() => ({}));
116
+
117
+ for (const node of nodes) {
118
+ graph.setNode(node.id, { width: node.width, height: node.height });
119
+ }
120
+ for (const edge of edges) {
121
+ graph.setEdge(edge.from, edge.to);
122
+ }
123
+
124
+ dagre.layout(graph);
125
+
126
+ const centeredNodes = nodes.map((node) => {
127
+ const label = graph.node(node.id);
128
+ const cx = label.x ?? 0;
129
+ const cy = label.y ?? 0;
130
+ return {
131
+ id: node.id,
132
+ x: cx - node.width / 2,
133
+ y: cy - node.height / 2,
134
+ width: node.width,
135
+ height: node.height,
136
+ };
137
+ });
138
+
139
+ const rawEdges = edges.map((edge) => {
140
+ const label = graph.edge(edge.from, edge.to) ?? {};
141
+ return {
142
+ from: edge.from,
143
+ to: edge.to,
144
+ points: (label.points ?? []).map((point) => ({
145
+ x: point.x,
146
+ y: point.y,
147
+ })),
148
+ };
149
+ });
150
+
151
+ // dagre does not guarantee the layout starts at (0, 0); compute the real
152
+ // bounding box from node boxes and edge routing points, then shift
153
+ // everything so the canvas starts at `padding`.
154
+ const padding = options.padding ?? 12;
155
+ const xs = [
156
+ ...centeredNodes.flatMap((node) => [node.x, node.x + node.width]),
157
+ ...rawEdges.flatMap((edge) => edge.points.map((point) => point.x)),
158
+ ];
159
+ const ys = [
160
+ ...centeredNodes.flatMap((node) => [node.y, node.y + node.height]),
161
+ ...rawEdges.flatMap((edge) => edge.points.map((point) => point.y)),
162
+ ];
163
+ const minX = xs.length > 0 ? Math.min(...xs) : 0;
164
+ const minY = ys.length > 0 ? Math.min(...ys) : 0;
165
+ const maxX = xs.length > 0 ? Math.max(...xs) : 0;
166
+ const maxY = ys.length > 0 ? Math.max(...ys) : 0;
167
+ const shiftX = padding - minX;
168
+ const shiftY = padding - minY;
169
+
170
+ return {
171
+ nodes: centeredNodes.map((node) => ({
172
+ ...node,
173
+ x: node.x + shiftX,
174
+ y: node.y + shiftY,
175
+ })),
176
+ edges: rawEdges.map((edge) => ({
177
+ ...edge,
178
+ points: edge.points.map((point) => ({
179
+ x: point.x + shiftX,
180
+ y: point.y + shiftY,
181
+ })),
182
+ })),
183
+ width: maxX - minX + padding * 2,
184
+ height: maxY - minY + padding * 2,
185
+ };
186
+ }
187
+
188
+ // --- box-size estimation (no DOM at build time) --------------------------
189
+
190
+ export interface BoxSizeEstimate {
191
+ readonly width: number;
192
+ readonly height: number;
193
+ }
194
+
195
+ const DEFAULT_MAX_WIDTH = 220;
196
+ const MIN_WIDTH = 90;
197
+ const PADDING_X = 20;
198
+ const PADDING_Y = 16;
199
+ const LINE_HEIGHT = 22;
200
+ /** Generous per-visible-character width estimates, in px, at diagram scale. */
201
+ const CHAR_WIDTH: Record<'text' | 'formula', number> = {
202
+ text: 7.2,
203
+ formula: 11,
204
+ };
205
+
206
+ /**
207
+ * A LaTeX command name (`\theta`, `\cdot`, …) renders as roughly one glyph,
208
+ * not one character per letter of its name; braces are structural, not
209
+ * visible. Collapsing both keeps the estimate from reading a formula as far
210
+ * wider than it will actually render.
211
+ */
212
+ function formulaVisibleLength(latex: string): number {
213
+ const collapsed = latex
214
+ .replace(/\\[a-zA-Z]+/g, 'x')
215
+ .replace(/[{}$]/g, '')
216
+ .trim();
217
+ return Math.max(1, collapsed.length);
218
+ }
219
+
220
+ /**
221
+ * Estimate a diagram node's box from its label text (or raw, pre-resolution
222
+ * LaTeX for a formula node). Deliberately generous: it is meant to avoid
223
+ * clipping, not to match a real render pixel-for-pixel. Pass an explicit
224
+ * `width`/`height` on the node to override for a label this estimate gets
225
+ * badly wrong.
226
+ */
227
+ export function estimateBoxSize(
228
+ content: string,
229
+ kind: 'text' | 'formula',
230
+ options: { readonly maxWidth?: number } = {},
231
+ ): BoxSizeEstimate {
232
+ const maxWidth = options.maxWidth ?? DEFAULT_MAX_WIDTH;
233
+ const visibleLength =
234
+ kind === 'formula' ? formulaVisibleLength(content) : content.trim().length || 1;
235
+ const charWidth = CHAR_WIDTH[kind];
236
+
237
+ const naturalWidth = visibleLength * charWidth + PADDING_X * 2;
238
+ const width = Math.min(maxWidth, Math.max(MIN_WIDTH, naturalWidth));
239
+
240
+ const usableWidth = width - PADDING_X * 2;
241
+ const charsPerLine = Math.max(1, Math.floor(usableWidth / charWidth));
242
+ const lines = Math.max(1, Math.ceil(visibleLength / charsPerLine));
243
+ const height = lines * LINE_HEIGHT + PADDING_Y * 2;
244
+
245
+ return { width: Math.round(width), height: Math.round(height) };
246
+ }
@@ -53,6 +53,13 @@ export interface EquationLessonInput {
53
53
  readonly slug: string;
54
54
  readonly file: string;
55
55
  readonly body: string;
56
+ /**
57
+ * `{ prefix, offset }` — the part's 1-based ordinal among the sidebar groups,
58
+ * and how many display equations earlier lessons of that part already used.
59
+ * When set, equations are numbered `‹part›.‹offset + n›`; when omitted, the
60
+ * legacy `##`-section scheme. See `docs/authoring/structure.md`.
61
+ */
62
+ readonly numbering?: { readonly prefix: number; readonly offset: number };
56
63
  }
57
64
 
58
65
  const BRACKET_REF = /\[\[\s*([^\][]+?)\s*\]\]/g;
@@ -90,7 +97,7 @@ export function validateEquations(
90
97
  )) {
91
98
  const seen = new Set<string>();
92
99
  const table: Record<string, string> = {};
93
- for (const raw of scanEquationLabels(lesson.body) as {
100
+ for (const raw of scanEquationLabels(lesson.body, lesson.numbering) as {
94
101
  key: string;
95
102
  number: string;
96
103
  section: number;