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,139 @@
1
+ # Course structure — the taxonomy
2
+
3
+ > **Status: adopted.** The engine implements these names and this numbering.
4
+ > `courseConfig.parts` is the current field; `sections` still works and is
5
+ > removed in the next MINOR release.
6
+
7
+ A course built on explico has five nested structural levels. Two of them are
8
+ currently called by more than one name, and two different levels are both
9
+ called "section". This file fixes one name per level so the words stop
10
+ overlapping.
11
+
12
+ ## The five levels
13
+
14
+ | Level | What it physically is | Where it is declared | Names it used to carry |
15
+ | --- | --- | --- | --- |
16
+ | 1 | The whole material | `courseConfig.title` | course |
17
+ | 2 | **Part** — a collapsible group in the left sidebar; one content directory | `courseConfig.parts[] = { dir, label }` | `CourseSection`, **"section"**, **"chapter"** (sidebar UI) |
18
+ | 3 | **Chapter** — one `.mdx` file = one page = one URL | `content/docs/<dir>/<name>.mdx` | `lesson`, **"subchapter"** (sidebar UI) |
19
+ | 4 | **Section** — an `##` heading inside a lesson | the `.mdx` body | — |
20
+ | 5 | **Subsection** — a `###` heading inside a lesson | the `.mdx` body | *(unnamed)* |
21
+
22
+ A **track** (`content/tracks/*.json`) is not one of these levels. It is an
23
+ ordered learning path over lesson ids; it feeds the order lessons appear in
24
+ inside their sidebar group, and it may span groups. It is never displayed as a
25
+ container.
26
+
27
+ ### The two collisions this resolved
28
+
29
+ **"Section" meant both level 2 and level 4.** `courseConfig.sections` was a
30
+ sidebar group; the `section` counter in `src/reference/equations.mjs` and
31
+ `src/reference/keyed-jsx-floats.mjs` counts `##` headings inside one lesson.
32
+ They are two levels apart and nothing in the names says so.
33
+
34
+ **"Chapter" meant both level 2 and level 3.** The numbering code said a chapter
35
+ was a lesson — `manifest.lessons[].chapter` was the lesson's 1-based ordinal read
36
+ straight down the sidebar, and it was the number before the dot in `(4.5)`. The
37
+ sidebar UI said a chapter is a *group*: from the engine changelog, "Chapter
38
+ groups in the left sidebar now start collapsed, retain compact **subchapter**
39
+ icons … and expand to the full lesson names when the chapter title is opened."
40
+ So the reader was shown "chapter = group, subchapter = lesson" while the numbers
41
+ were built on "chapter = lesson". A number could not be read without knowing
42
+ which of the two vocabularies the writer had in mind.
43
+
44
+ ## The names
45
+
46
+ One name per level, borrowed from the book metaphor a reader already has:
47
+
48
+ | Level | Proposed name | Playground example | time-series example |
49
+ | --- | --- | --- | --- |
50
+ | 1 | **Course** | Explico Playground | MTH 9893 Time Series Analysis |
51
+ | 2 | **Part** | "Motion in the plane" (`motion/`) | "L1 · Foundations & evidence" |
52
+ | 3 | **Chapter** — one lesson, one page, one URL | "Position and Coordinate Systems" | "Dependence: ACF and PACF" |
53
+ | 4 | **Section** — an `##` heading | "Two ways to name the same point" | — |
54
+ | 5 | **Subsection** — a `###` heading | "From Cartesian to polar" | — |
55
+
56
+ Consequences:
57
+
58
+ - `courseConfig.sections` is renamed `courseConfig.parts`. "Section" now only
59
+ ever means an `##` heading. `sections` is still accepted, and is removed in
60
+ the next MINOR release.
61
+ - The sidebar UI no longer says "chapter" for a group or "subchapter" for a
62
+ lesson; a group is a **part**, and the thing inside it is a **chapter**.
63
+ - "Lesson" and "chapter" stay as two words for level 3 on purpose: *lesson* is
64
+ the pedagogical unit (it teaches competencies, carries assessments), *chapter*
65
+ is that same unit's position in the reading order. A file is a lesson; its
66
+ ordinal is its chapter number.
67
+
68
+ ## What the playground demonstrates
69
+
70
+ The playground is the worked example for every level. It was deliberately
71
+ extended for this: until now it had a single sidebar group, which made levels 2
72
+ and 3 indistinguishable — one group with four lessons numbers identically
73
+ whether you count groups or lessons, which is why a numbering bug and this
74
+ naming collision both stayed invisible.
75
+
76
+ ```
77
+ Course Explico Playground
78
+ ├── Part 1 Motion in the plane content/docs/motion/
79
+ │ ├── Ch 1 Position and Coordinate Systems position-and-coordinates.mdx
80
+ │ │ ├── § Locating a point in the plane ##
81
+ │ │ ├── § Two ways to name the same point ##
82
+ │ │ │ ├── §§ From Cartesian to polar ###
83
+ │ │ │ └── §§ From polar to Cartesian ###
84
+ │ │ ├── § Velocity and acceleration as derivatives ##
85
+ │ │ └── § The special case: constant acceleration ##
86
+ │ │ ├── §§ Integrating once: velocity ###
87
+ │ │ └── §§ Integrating twice: position ###
88
+ │ └── Ch 2 Velocity Under Constant Acceleration constant-acceleration.mdx
89
+ └── Part 2 Projectile motion content/docs/projectiles/
90
+ ├── Ch 1 Projectile Range projectile-range.mdx
91
+ └── Ch 2 Optimal Launch Angle optimal-launch-angle.mdx
92
+ ```
93
+
94
+ Which numbers every float like this — the part supplies the prefix, and the
95
+ index carries on across the part's chapters:
96
+
97
+ ```
98
+ Part 1 Motion in the plane
99
+ Ch 1 Position and Coordinate Systems (1.1) (1.2) (1.3) (1.4) (1.5) (1.6) (1.7)
100
+ Ch 2 Velocity Under Constant Accel. (1.8)
101
+ Part 2 Projectile motion
102
+ Ch 1 Projectile Range (2.1) (2.2)
103
+ Ch 2 Optimal Launch Angle (2.3) (2.4) (2.5) (2.6)
104
+ ```
105
+
106
+ ## Numbering
107
+
108
+ A display equation, `<Table>`, or `<Figure>` is numbered **`‹part›.‹n›`**:
109
+
110
+ - **`part`** is the part's 1-based position in `courseConfig.parts`.
111
+ - **`n`** counts that kind of float across the *whole part*, in document order,
112
+ running unbroken from one chapter into the next. Each kind counts on its own,
113
+ so a `<Table>` never advances the `<Figure>` sequence.
114
+ - **Sections and subsections do not participate.** Neither `##` nor `###`
115
+ resets `n` or appears in the number.
116
+ - **Keyed or not, everything counts.** An equation with no `\label{eq:…}` still
117
+ takes a number and a positional anchor (`#eq-2-4`); it just cannot be
118
+ referenced by name.
119
+
120
+ In the playground: part 1 runs `1.1` … `1.7` through chapter 1 and continues
121
+ `1.8` in chapter 2; part 2 opens at `2.1`.
122
+
123
+ `manifest.lessons[]` carries what the render path needs to reproduce this:
124
+ `part`, `chapter`, and `numbering.{equations,tables,figures}` — the running
125
+ count each chapter starts from.
126
+
127
+ ### The invariant, and why it is build-enforced
128
+
129
+ The number a page prints beside an equation and the number a cross-page
130
+ `[[slug#eq-key]]` prints for it are computed by two code paths from the same
131
+ content. `remarkNotation` cross-checks them for every keyed float and fails the
132
+ build on any disagreement, naming the key and both numbers.
133
+
134
+ This exists because the disagreement is otherwise silent and shipped: a course
135
+ config that passed the manifest number tables without the placement map
136
+ numbered its pages by `##` section while every reference to them read the
137
+ manifest, so one equation was `(2.2)` on its own page and `(4.5)` everywhere
138
+ else. Wire the plugin by handing it the whole manifest — `[remarkNotation,
139
+ { definitions: manifest.notation.raw, manifest }]` — and the two cannot drift.
@@ -0,0 +1,116 @@
1
+ # Versioning rules
2
+
3
+ `C` rules govern the course's **knowledge version** — semver over the
4
+ learning-facing content, independent of the `explico` engine's own version. The
5
+ declared value is `about.version` in `content/course.config.ts`; the history and
6
+ the reader-facing notes are in `content/CHANGELOG.md`. Both are surfaced to
7
+ readers by the About `[?]` panel.
8
+
9
+ See [README.md](README.md) for what "Build-enforced" and "Review-only" mean here.
10
+ The engine's own versioning is a separate policy —
11
+ [`docs/VERSIONING.md`](../VERSIONING.md).
12
+
13
+ ---
14
+
15
+ ## C1
16
+
17
+ **Every content change lands with a `content/CHANGELOG.md` entry under
18
+ `## [Unreleased]`.**
19
+
20
+ A change to any lesson `.mdx`, notation entry, assessment, competency, source,
21
+ `content/domain/` file, or `course.config.ts` gets a bullet under
22
+ `## [Unreleased]` in the same commit.
23
+
24
+ _Rationale._ The knowledge version is meaningless without a record of what moved
25
+ between two values of it. A reader who sees "knowledge v1.4.0" in the `[?]` panel
26
+ can follow `changelogHref` to find out what 1.4.0 means.
27
+
28
+ _How to apply._ Keep a Changelog shape. One bullet per user-visible change,
29
+ written for a reader, not a commit log:
30
+
31
+ ```markdown
32
+ ## [Unreleased]
33
+
34
+ - Reworked the "Optimal Launch Angle" derivation to use the identity
35
+ `sin(2θ) = 2 sinθ cosθ` explicitly.
36
+ - New lesson: "Projectile Range with Drag" (motion.range-drag).
37
+ ```
38
+
39
+ _Enforcement._ Review-only. Rule [C3](#c3) partially enforces it: a content
40
+ change with no matching version bump eventually trips the mismatch warning.
41
+
42
+ _Exceptions._ A pure typo fix in prose that changes no rendered symbol, no
43
+ number, and no outcome. When in doubt, add the bullet.
44
+
45
+ ---
46
+
47
+ ## C2
48
+
49
+ **The knowledge version is semver over learning-facing content.**
50
+
51
+ Pick the bump by the largest change in the release:
52
+
53
+ - **MAJOR** — a lesson, competency, or notation `key` is removed or renamed; a
54
+ stated learning outcome (`teaches`, a competency `outcome`) changes; a result
55
+ or convention a reader may have memorised changes.
56
+ - **MINOR** — something new and backward-compatible: a new lesson, section,
57
+ notation entry, assessment, or worked example.
58
+ - **PATCH** — a fix or clarification: corrected arithmetic, better prose, a added
59
+ or fixed `[@source]`, a non-semantic notation tweak.
60
+
61
+ _Rationale._ A downstream consumer of the content (an LMS import, a printed
62
+ reader, a course that forks this one) needs the same "did this break me?" signal
63
+ from the knowledge version that a library gets from a package version.
64
+
65
+ _How to apply._ Decide the bump when you move `## [Unreleased]` to a dated
66
+ heading ([C4](#c4)); set `about.version` to match.
67
+
68
+ _Enforcement._ Review-only.
69
+
70
+ _Exceptions._ None. If a change does not fit a tier, it is PATCH.
71
+
72
+ ---
73
+
74
+ ## C3
75
+
76
+ **`about.version` equals the newest `## [x.y.z]` heading in
77
+ `content/CHANGELOG.md`.**
78
+
79
+ _Rationale._ Two sources of the same fact must not drift. The manifest — and so
80
+ the `[?]` panel — reads `about.version`; a reader chasing "what changed" reads
81
+ the changelog. If they disagree, one is lying.
82
+
83
+ _How to apply._ On a release, edit both in the same commit ([C4](#c4)). Between
84
+ releases, `about.version` stays at the last released value while
85
+ `## [Unreleased]` accumulates bullets.
86
+
87
+ _Enforcement._ Build-enforced (Tier-3). `pnpm validate:content` emits
88
+ `knowledge-version-mismatch` when they differ, `knowledge-changelog-missing`
89
+ when there is no released heading, and `knowledge-version-invalid` when
90
+ `about.version` is not `x.y.z`. All are warnings — they never fail the build, but
91
+ CI may choose to.
92
+
93
+ _Exceptions._ None.
94
+
95
+ ---
96
+
97
+ ## C4
98
+
99
+ **On a release, move `## [Unreleased]` to `## [x.y.z] - YYYY-MM-DD` and bump
100
+ `about.version` in the same commit.**
101
+
102
+ _Rationale._ A release is the atomic moment the two records agree again
103
+ ([C3](#c3)). Splitting it across commits leaves a window where the build warns
104
+ and the panel is wrong.
105
+
106
+ _How to apply._
107
+
108
+ 1. Choose `x.y.z` from the accumulated `## [Unreleased]` bullets and [C2](#c2).
109
+ 2. Rename the heading to `## [x.y.z] - <today>`; add a fresh empty
110
+ `## [Unreleased]` above it.
111
+ 3. Set `about.version` in `content/course.config.ts` to `x.y.z`.
112
+ 4. `pnpm validate:content` — no `knowledge-*` warning.
113
+
114
+ _Enforcement._ Review-only (the end state is checked by [C3](#c3)).
115
+
116
+ _Exceptions._ None.
@@ -0,0 +1,195 @@
1
+ # Visualization rules
2
+
3
+ `V` rules govern anything a lesson draws: diagrams, static charts, and hydrated
4
+ interactive figures. The engine assumes no single plotting approach — these
5
+ rules make the choice per figure predictable and keep every figure accessible
6
+ and deterministic.
7
+
8
+ See [README.md](README.md) for what "strong recommendation", "Build-enforced",
9
+ and "Review-only" mean here, and how to amend a rule.
10
+
11
+ ---
12
+
13
+ ## V1
14
+
15
+ **Graphs and flowcharts use the `Diagram` component.**
16
+
17
+ Any node-and-edge picture — a derivation pipeline, a dependency graph, a state
18
+ machine, a decision tree — is built with
19
+ `explico/components/diagram/Diagram.astro`. It does build-time layout with
20
+ `@dagrejs/dagre` and renders KaTeX-labelled nodes as static SVG through the same
21
+ trust boundary as lesson math.
22
+
23
+ Do not add Mermaid, cytoscape, react-flow, elkjs, or any other runtime graph or
24
+ layout library. Nothing client-side lays out a graph.
25
+
26
+ _Rationale._ One diagram primitive means node math is gate-checked and
27
+ hover-explained exactly like prose math (an unresolved glyph fails the build
28
+ with the node id), zero runtime layout code ships, and diagrams render
29
+ identically in print and with JS off.
30
+
31
+ _How to apply._ Pass `nodes` / `edges` and, if any node has a `formula`, a
32
+ `scope` from `labMathScope` — see
33
+ `playground/content/labs/DerivationDiagram.astro`. A `formula` node's symbols
34
+ must resolve against the page glyph table, same as `NotationFormula`. If
35
+ `Diagram` genuinely cannot express the picture, raise it as an engine gap
36
+ rather than reaching for another library.
37
+
38
+ _Enforcement._ Review-only.
39
+
40
+ _Exceptions._ A one-off illustrative schematic that is not a graph (a labelled
41
+ photo, a geometric figure) is a [V2](#v2) figure, not a `Diagram`.
42
+
43
+ ---
44
+
45
+ ## V2
46
+
47
+ **Static charts: hand-rolled SVG first, then D3, then Observable Plot — chosen
48
+ per figure.**
49
+
50
+ - **Hand-rolled inline SVG** is the default: zero dependency, covers a single
51
+ bespoke figure.
52
+ - **D3** once a figure needs real tick-labelled axes, log scales, or several
53
+ series sharing a scale.
54
+ - **Observable Plot** (`@observablehq/plot`) once a course is authoring many
55
+ charts per lecture and the per-chart boilerplate of D3 is the bottleneck.
56
+
57
+ Decide per figure. A lesson may hold an SVG sketch and a Plot chart side by
58
+ side. Whatever the choice, the chart is produced at build time or inside a
59
+ `client:*` island — never a CDN `<script>`.
60
+
61
+ _Rationale._ The cost curve is real: SVG is free but unscalable, D3 is powerful
62
+ but verbose, Plot is concise but a dependency and a bundle. Matching the tool to
63
+ the figure keeps both the bundle and the authoring effort down.
64
+
65
+ _How to apply._ For a static (non-interactive) chart, prefer rendering in an
66
+ `.astro` component so nothing hydrates. Reach for an island only when the figure
67
+ responds to input — then it is a [V3](#v3) figure.
68
+
69
+ _Enforcement._ Review-only.
70
+
71
+ _Exceptions._ None — this rule _is_ the exception process.
72
+
73
+ ---
74
+
75
+ ## V3
76
+
77
+ **Interactive figures: a hydrated island, the `renderLabMath` seam for
78
+ equations, and a text alternative.**
79
+
80
+ An interactive figure is a React island, hydrated `client:visible` (or
81
+ `client:idle`). It carries:
82
+
83
+ - **Gate-checked math.** Any equation shown in the figure goes through the
84
+ `labMathScope` / `renderLabMath` / `\slot{}` seam so its symbols are resolved
85
+ against the page's real notation at build time and the island only patches
86
+ numbers into pre-rendered KaTeX. See
87
+ `playground/content/labs/ProjectileRangeExplorer.tsx` +
88
+ `ProjectileRangeMath.astro`.
89
+ - **An accessible name and description** — `ariaLabel` / `ariaDescription` on
90
+ the plot, marks that are decorative set `ariaHidden`.
91
+ - **A text alternative.** A `<details>` block with the model's assumptions and a
92
+ small table of representative values, so the figure is usable with no JS and
93
+ by a screen reader.
94
+ - **Labelled controls.** Every control is a labelled `<input>`; where precision
95
+ matters, pair a `range` slider with a `number` field (see `RangeField` in
96
+ `ProjectileRangeExplorer.tsx`).
97
+
98
+ _Rationale._ The seam is the only reason interactive math is not a silent
99
+ exception to the completeness gate. The text alternative and aria wiring are the
100
+ difference between a figure and an inaccessible one.
101
+
102
+ _How to apply._ Keep the island's job to "patch numbers and redraw". Domain
103
+ math lives in a plain module (`playground/content/domain/motion.ts`), not in the
104
+ component.
105
+
106
+ _Enforcement._ Review-only.
107
+
108
+ _Exceptions._ None.
109
+
110
+ ---
111
+
112
+ ## V4
113
+
114
+ **A randomised figure exposes an adjustable, deterministic seed.**
115
+
116
+ Any figure that draws random numbers has a visible **seed** control — an
117
+ integer, default `0` — shown near the figure. The same seed always produces the
118
+ same figure.
119
+
120
+ _Rationale._ A figure that reshuffles on every render can't be pointed at ("look
121
+ at the outlier near x = 3"), can't be reproduced in a question, and can't be
122
+ regression-tested. A seed makes randomness a parameter the reader controls.
123
+
124
+ _How to apply._
125
+
126
+ - Thread the seed through a real PRNG (e.g. a small `mulberry32` /
127
+ `splitmix32`), never `Math.random()`.
128
+ - The seed is a control like any other — a `number` field, or a slider over a
129
+ small range plus a field.
130
+ - Label it plainly ("Random seed") and show its current value.
131
+
132
+ _Enforcement._ Review-only.
133
+
134
+ _Exceptions._ None. If the figure is deterministic, there is no seed to show.
135
+
136
+ ---
137
+
138
+ ## V5
139
+
140
+ **Axis ranges span the first 10 seeds, evaluated at the current values of every
141
+ other control.**
142
+
143
+ Fix each axis `domain` explicitly. Compute it as the min and max of the plotted
144
+ quantity over **seeds 0–9**, with every non-seed control at its current value.
145
+ Then:
146
+
147
+ - **Some points, for some seeds, will fall outside the drawn range.** That is
148
+ expected and fine — clip them. Do **not** autoscale the axis per seed: a
149
+ domain that jumps as the seed changes defeats the comparison the seed control
150
+ exists for.
151
+ - **Recompute the domain when any non-seed control changes** (a slider, a
152
+ toggle). Its old range no longer describes the new family of curves.
153
+ - **Do not recompute the domain when only the seed changes.** The range is a
154
+ property of the control setting, not of the current draw.
155
+ - Pad the computed range by a small fixed fraction (≈ 5%) so extremes don't sit
156
+ on the frame.
157
+ - Keep the sample count (`10`) a named constant so it can be tuned in one place.
158
+
159
+ _Rationale._ A reader steps through seeds to see how much the outcome varies. A
160
+ fixed frame makes that variation legible — points move, the axes don't. Ten
161
+ seeds is enough to size the frame for the typical spread without chasing every
162
+ tail.
163
+
164
+ _How to apply._ A `useMemo` keyed on all non-seed inputs (not the seed) builds
165
+ the domain:
166
+
167
+ ```ts
168
+ const SEED_SAMPLE_COUNT = 10;
169
+ const RANGE_PAD = 0.05;
170
+
171
+ const yDomain = useMemo(() => {
172
+ let lo = Infinity;
173
+ let hi = -Infinity;
174
+ for (let seed = 0; seed < SEED_SAMPLE_COUNT; seed++) {
175
+ for (const point of series({ ...controls, seed })) {
176
+ if (point.y < lo) lo = point.y;
177
+ if (point.y > hi) hi = point.y;
178
+ }
179
+ }
180
+ const pad = (hi - lo) * RANGE_PAD || 1;
181
+ return [lo - pad, hi + pad] as const;
182
+ }, [controls.a, controls.b /* every non-seed control, not seed */]);
183
+
184
+ // Plot.plot({ y: { domain: yDomain, ... }, ... })
185
+ ```
186
+
187
+ Both axes get this treatment if both carry random spread. If one axis is a
188
+ deterministic sweep (angle 0–90°), give it its natural domain and apply the rule
189
+ only to the random axis.
190
+
191
+ _Enforcement._ Review-only.
192
+
193
+ _Exceptions._ A figure where the meaningful range is a known physical bound
194
+ (a probability on `[0, 1]`, an angle on `[0°, 360°]`) uses that bound instead —
195
+ state it in a comment.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "explico",
3
- "version": "0.2.0",
3
+ "version": "0.4.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",
@@ -27,8 +27,14 @@
27
27
  "files": [
28
28
  "dist",
29
29
  "src",
30
- "README.md"
30
+ "README.md",
31
+ "CHANGELOG.md",
32
+ "docs/VERSIONING.md",
33
+ "docs/authoring"
31
34
  ],
35
+ "dependencies": {
36
+ "@dagrejs/dagre": "3.1.1"
37
+ },
32
38
  "peerDependencies": {
33
39
  "@astrojs/markdown-remark": "7.2.4",
34
40
  "@astrojs/react": "6.0.4",
@@ -38,16 +44,25 @@
38
44
  "react": "19.2.8",
39
45
  "react-dom": "19.2.8",
40
46
  "rehype-katex": "7.0.1",
41
- "remark-math": "6.0.0"
47
+ "remark-math": "6.0.0",
48
+ "remark-mdx": "3.1.1"
42
49
  },
43
50
  "devDependencies": {
51
+ "@astrojs/markdown-remark": "7.2.4",
44
52
  "@types/node": "24.13.3",
45
53
  "@types/react": "19.2.18",
46
54
  "@types/react-dom": "19.2.5",
55
+ "katex": "0.16.47",
56
+ "rehype-katex": "7.0.1",
57
+ "remark-math": "6.0.0",
58
+ "remark-mdx": "3.1.1",
47
59
  "tsup": "8.5.1",
48
- "typescript": "6.0.2"
60
+ "typescript": "6.0.2",
61
+ "vitest": "4.1.11"
49
62
  },
50
63
  "scripts": {
51
- "build": "tsup && node scripts/copy-assets.mjs && node scripts/fix-esm-extensions.mjs && tsc -p tsconfig.dts-mjs.json"
64
+ "build": "tsup && node scripts/copy-assets.mjs && node scripts/fix-esm-extensions.mjs && tsc -p tsconfig.dts-mjs.json",
65
+ "test": "vitest run",
66
+ "test:watch": "vitest"
52
67
  }
53
68
  }
@@ -0,0 +1,127 @@
1
+ import { resolve, sep } from 'node:path';
2
+
3
+ import type { AstroIntegration } from 'astro';
4
+ import type { CourseConfig } from './course-config';
5
+ import {
6
+ compileManifest,
7
+ type Manifest,
8
+ writeManifest,
9
+ } from './manifest';
10
+
11
+ // Vite keeps its server object across a restart. Do not accumulate watcher
12
+ // callbacks each time Astro reloads its configuration.
13
+ const watchedServers = new WeakSet<object>();
14
+
15
+ /** Fail loudly instead of rendering math triggers whose local cards are stale. */
16
+ export function assertLocalNotationParity(
17
+ lessonId: string,
18
+ authoredKeys: readonly string[],
19
+ renderedKeys: readonly string[],
20
+ ): void {
21
+ const authored = [...new Set(authoredKeys)].sort();
22
+ const rendered = [...new Set(renderedKeys)].sort();
23
+ if (
24
+ authored.length === rendered.length &&
25
+ authored.every((key, index) => key === rendered[index])
26
+ ) {
27
+ return;
28
+ }
29
+ throw new Error(
30
+ `Stale Explico manifest for ${lessonId}: lesson notation.local has ` +
31
+ `[${authored.join(', ')}], but the page notation bundle has ` +
32
+ `[${rendered.join(', ')}]. Pass courseConfig to loadManifest() and add ` +
33
+ `explicoContentRefresh() to the Astro integrations.`,
34
+ );
35
+ }
36
+
37
+ function replaceInPlace(target: unknown, source: unknown): void {
38
+ if (Array.isArray(target) && Array.isArray(source)) {
39
+ target.splice(0, target.length, ...source);
40
+ return;
41
+ }
42
+ if (
43
+ !target ||
44
+ !source ||
45
+ typeof target !== 'object' ||
46
+ typeof source !== 'object'
47
+ ) {
48
+ return;
49
+ }
50
+ const mutable = target as Record<string, unknown>;
51
+ const fresh = source as Record<string, unknown>;
52
+ for (const key of Object.keys(mutable)) {
53
+ if (!(key in fresh)) delete mutable[key];
54
+ }
55
+ for (const [key, value] of Object.entries(fresh)) {
56
+ const current = mutable[key];
57
+ if (
58
+ (Array.isArray(current) && Array.isArray(value)) ||
59
+ (current &&
60
+ value &&
61
+ typeof current === 'object' &&
62
+ typeof value === 'object' &&
63
+ !Array.isArray(current) &&
64
+ !Array.isArray(value))
65
+ ) {
66
+ replaceInPlace(current, value);
67
+ } else {
68
+ mutable[key] = value;
69
+ }
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Restart Astro when an authored content file changes. Astro reloads MDX on
75
+ * its own, but Explico's manifest is created by `astro.config.mjs`; a restart
76
+ * is what makes the remark plugins and page UI consume the same fresh model.
77
+ *
78
+ * Add this integration once to `integrations` in `astro.config.mjs`. The
79
+ * restart is automatic and editor-agnostic (change/add/unlink all work).
80
+ */
81
+ export function explicoContentRefresh(
82
+ courseConfig: CourseConfig,
83
+ manifest: Manifest,
84
+ ): AstroIntegration {
85
+ return {
86
+ name: 'explico-content-refresh',
87
+ hooks: {
88
+ 'astro:server:setup': ({ server }) => {
89
+ if (watchedServers.has(server)) return;
90
+ watchedServers.add(server);
91
+ const contentRoot = `${resolve(process.cwd(), 'content')}${sep}`;
92
+ let timer: ReturnType<typeof setTimeout> | undefined;
93
+ let restarting = false;
94
+
95
+ const refresh = (path: string) => {
96
+ const absolute = resolve(path);
97
+ if (!absolute.startsWith(contentRoot) || restarting) return;
98
+ if (timer) clearTimeout(timer);
99
+ timer = setTimeout(async () => {
100
+ restarting = true;
101
+ try {
102
+ const fresh = await compileManifest(courseConfig);
103
+ writeManifest(fresh);
104
+ // Astro/Vite retains the configuration object during a server
105
+ // restart. Update it recursively so remark plugin option arrays
106
+ // and maps captured from `manifest` also see the new model.
107
+ replaceInPlace(manifest, fresh);
108
+ await server.restart();
109
+ } catch (error) {
110
+ server.config.logger.error(
111
+ `Explico could not refresh the content manifest: ${
112
+ error instanceof Error ? error.message : String(error)
113
+ }`,
114
+ );
115
+ } finally {
116
+ restarting = false;
117
+ }
118
+ }, 50);
119
+ };
120
+
121
+ server.watcher.on('add', refresh);
122
+ server.watcher.on('change', refresh);
123
+ server.watcher.on('unlink', refresh);
124
+ },
125
+ },
126
+ };
127
+ }