explico 0.13.1 → 0.14.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 (239) hide show
  1. package/CHANGELOG.md +879 -1
  2. package/README.md +200 -30
  3. package/dist/compiler/astro.d.ts +15 -2
  4. package/dist/compiler/astro.js +40 -11
  5. package/dist/compiler/cli-main.d.ts +43 -0
  6. package/dist/compiler/cli-main.js +392 -0
  7. package/dist/compiler/cli.d.ts +424 -9
  8. package/dist/compiler/cli.js +1289 -8
  9. package/dist/compiler/collections.d.ts +88 -1
  10. package/dist/compiler/collections.js +170 -25
  11. package/dist/compiler/course-config.d.ts +1 -1
  12. package/dist/compiler/course-config.js +105 -1
  13. package/dist/compiler/decks.d.ts +161 -0
  14. package/dist/compiler/decks.js +235 -0
  15. package/dist/compiler/exports/combined-html.d.ts +5 -1
  16. package/dist/compiler/exports/deck-fit.d.ts +55 -0
  17. package/dist/compiler/exports/deck-fit.js +90 -0
  18. package/dist/compiler/exports/export-chapters.d.ts +37 -6
  19. package/dist/compiler/exports/export-chapters.js +64 -5
  20. package/dist/compiler/exports/export-pages.d.ts +30 -3
  21. package/dist/compiler/exports/export-pages.js +24 -1
  22. package/dist/compiler/exports/latex/beamerthemeexplico.sty +124 -0
  23. package/dist/compiler/exports/latex/cache.d.ts +78 -0
  24. package/dist/compiler/exports/latex/cache.js +129 -0
  25. package/dist/compiler/exports/latex/capture.d.ts +10 -1
  26. package/dist/compiler/exports/latex/capture.js +3 -1
  27. package/dist/compiler/exports/latex/compile.d.ts +13 -2
  28. package/dist/compiler/exports/latex/compile.js +12 -1
  29. package/dist/compiler/exports/latex/deck.d.ts +92 -0
  30. package/dist/compiler/exports/latex/deck.js +349 -0
  31. package/dist/compiler/exports/latex/document.d.ts +51 -6
  32. package/dist/compiler/exports/latex/document.js +71 -12
  33. package/dist/compiler/exports/latex/explico-common.sty +198 -0
  34. package/dist/compiler/exports/latex/explico.cls +13 -183
  35. package/dist/compiler/exports/latex/serialize.d.ts +60 -4
  36. package/dist/compiler/exports/latex/serialize.js +4 -2
  37. package/dist/compiler/exports/latex-export.d.ts +24 -2
  38. package/dist/compiler/exports/latex-export.js +107 -26
  39. package/dist/compiler/exports/markdown/serialize.d.ts +24 -4
  40. package/dist/compiler/exports/markdown/serialize.js +85 -8
  41. package/dist/compiler/exports/markdown-export.d.ts +6 -1
  42. package/dist/compiler/exports/markdown-export.js +50 -11
  43. package/dist/compiler/exports/standalone-html.d.ts +10 -5
  44. package/dist/compiler/exports/standalone-html.js +3 -3
  45. package/dist/compiler/exports/static-tree.d.ts +38 -1
  46. package/dist/compiler/exports/static-tree.js +120 -4
  47. package/dist/compiler/knowledge-version.d.ts +26 -2
  48. package/dist/compiler/knowledge-version.js +27 -17
  49. package/dist/compiler/lesson-derivation.d.ts +143 -6
  50. package/dist/compiler/lesson-derivation.js +145 -13
  51. package/dist/compiler/manifest.d.ts +133 -4
  52. package/dist/compiler/manifest.js +359 -58
  53. package/dist/compiler/page-versions.d.ts +160 -0
  54. package/dist/compiler/page-versions.js +198 -0
  55. package/dist/compiler/render-diagnostics.d.ts +5 -1
  56. package/dist/compiler/resolution-report.d.ts +24 -6
  57. package/dist/compiler/resolution-report.js +22 -4
  58. package/dist/compiler/validate-content.d.ts +7 -0
  59. package/dist/compiler/validate-content.js +160 -0
  60. package/dist/{course-config-rRtME5UV.d.ts → course-config-BLtIdkei.d.ts} +164 -1
  61. package/dist/curriculum/competency-graph.d.ts +54 -0
  62. package/dist/curriculum/competency-graph.js +41 -0
  63. package/dist/reference/base-library.d.mts +8 -0
  64. package/dist/reference/base-library.mjs +19 -0
  65. package/dist/reference/consistency.d.ts +8 -4
  66. package/dist/reference/consistency.js +76 -26
  67. package/dist/reference/deck-remark.d.mts +123 -0
  68. package/dist/reference/deck-remark.mjs +600 -0
  69. package/dist/reference/deck-source.d.ts +107 -0
  70. package/dist/reference/deck-source.js +140 -0
  71. package/dist/reference/decks-validate.d.ts +133 -0
  72. package/dist/reference/decks-validate.js +359 -0
  73. package/dist/reference/derived-entries.d.ts +5 -1
  74. package/dist/reference/diagram-fit.d.ts +1 -1
  75. package/dist/reference/figure-symbols.d.ts +23 -6
  76. package/dist/reference/figure-symbols.js +51 -15
  77. package/dist/reference/float-name.d.ts +43 -0
  78. package/dist/reference/float-name.js +58 -0
  79. package/dist/reference/folds-validate.d.ts +1 -1
  80. package/dist/reference/gate-abbreviations.d.ts +1 -1
  81. package/dist/reference/gate-indices.js +5 -4
  82. package/dist/reference/gate-math.d.ts +29 -2
  83. package/dist/reference/gate-math.js +99 -18
  84. package/dist/reference/gate-part-pages.d.ts +1 -1
  85. package/dist/reference/lab-katex-validate.d.ts +10 -6
  86. package/dist/reference/lab-katex-validate.js +17 -9
  87. package/dist/reference/lab-math-scope.js +29 -4
  88. package/dist/reference/math-glyphs.d.mts +25 -0
  89. package/dist/reference/math-glyphs.mjs +189 -25
  90. package/dist/reference/page-notation.d.ts +22 -3
  91. package/dist/reference/page-notation.js +32 -10
  92. package/dist/reference/references.d.ts +14 -1
  93. package/dist/reference/references.js +13 -0
  94. package/dist/reference/registry-scope.d.mts +33 -0
  95. package/dist/reference/registry-scope.mjs +34 -0
  96. package/dist/reference/registry.js +29 -10
  97. package/dist/reference/rehype-deck.d.mts +6 -0
  98. package/dist/reference/rehype-deck.mjs +286 -0
  99. package/dist/reference/rehype-heading-anchors.d.mts +20 -0
  100. package/dist/reference/rehype-heading-anchors.mjs +48 -6
  101. package/dist/reference/remark-notation.mjs +249 -40
  102. package/dist/reference/source-lines.d.mts +12 -0
  103. package/dist/reference/source-lines.mjs +65 -0
  104. package/dist/reference/types.d.ts +28 -0
  105. package/dist/rules/catalog.d.ts +21 -4
  106. package/dist/rules/catalog.js +221 -2
  107. package/dist/rules/course-rules.d.ts +5 -1
  108. package/dist/rules/course-rules.js +17 -3
  109. package/dist/rules/course-rules.schema.json +12 -0
  110. package/dist/rules/in-effect.d.ts +13 -3
  111. package/dist/rules/in-effect.js +30 -7
  112. package/dist/rules/rules.schema.json +259 -1
  113. package/dist/rules/settings.d.ts +1 -1
  114. package/dist/session/PreferenceStore.d.ts +6 -0
  115. package/dist/session/PreferenceStore.js +18 -1
  116. package/dist/session/deck-memory.d.ts +116 -0
  117. package/dist/session/deck-memory.js +143 -0
  118. package/dist/session/reading-memory.d.ts +96 -0
  119. package/dist/session/reading-memory.js +133 -0
  120. package/docs/VERSIONING.md +21 -18
  121. package/docs/authoring/README.md +29 -9
  122. package/docs/authoring/agents.md +63 -16
  123. package/docs/authoring/components.md +15 -0
  124. package/docs/authoring/deck.md +588 -0
  125. package/docs/authoring/migrating.md +1045 -16
  126. package/docs/authoring/notation.md +49 -6
  127. package/docs/authoring/structure.md +80 -1
  128. package/docs/authoring/versioning.md +54 -2
  129. package/docs/authoring/visualizations.md +7 -2
  130. package/docs/course-agent/AGENTS.md +37 -0
  131. package/docs/course-agent/agents/rules-reviewer.md +33 -0
  132. package/docs/course-agent/skills/draft-lesson/SKILL.md +37 -0
  133. package/docs/course-agent/skills/migrate-course/SKILL.md +31 -0
  134. package/docs/course-agent/skills/release-content/SKILL.md +24 -0
  135. package/docs/diagram-scaling.md +148 -0
  136. package/docs/privacy.md +59 -0
  137. package/package.json +8 -2
  138. package/src/compiler/astro.ts +60 -21
  139. package/src/compiler/cli-main.ts +469 -0
  140. package/src/compiler/cli.ts +1905 -16
  141. package/src/compiler/collections.ts +284 -25
  142. package/src/compiler/course-config.ts +267 -0
  143. package/src/compiler/decks.ts +455 -0
  144. package/src/compiler/exports/deck-fit.ts +127 -0
  145. package/src/compiler/exports/export-chapters.ts +105 -6
  146. package/src/compiler/exports/export-pages.ts +46 -2
  147. package/src/compiler/exports/latex/beamerthemeexplico.sty +124 -0
  148. package/src/compiler/exports/latex/cache.ts +183 -0
  149. package/src/compiler/exports/latex/capture.ts +5 -2
  150. package/src/compiler/exports/latex/compile.ts +18 -0
  151. package/src/compiler/exports/latex/deck.ts +419 -0
  152. package/src/compiler/exports/latex/document.ts +117 -21
  153. package/src/compiler/exports/latex/explico-common.sty +198 -0
  154. package/src/compiler/exports/latex/explico.cls +13 -183
  155. package/src/compiler/exports/latex/serialize.ts +25 -21
  156. package/src/compiler/exports/latex/texlive-packages.txt +6 -2
  157. package/src/compiler/exports/latex-export.ts +187 -45
  158. package/src/compiler/exports/markdown/serialize.ts +118 -9
  159. package/src/compiler/exports/markdown-export.ts +56 -11
  160. package/src/compiler/exports/standalone-html.ts +8 -7
  161. package/src/compiler/exports/static-tree.ts +175 -3
  162. package/src/compiler/knowledge-version.ts +56 -21
  163. package/src/compiler/lesson-derivation.ts +282 -16
  164. package/src/compiler/manifest.ts +558 -63
  165. package/src/compiler/page-versions.ts +359 -0
  166. package/src/compiler/resolution-report.ts +38 -7
  167. package/src/compiler/validate-content.ts +212 -0
  168. package/src/components/CourseContents.astro +124 -6
  169. package/src/components/CurriculumMap.astro +49 -1
  170. package/src/components/FloatIndex.astro +1 -1
  171. package/src/components/PartOverview.astro +51 -5
  172. package/src/components/assessments/AssessmentSet.astro +41 -15
  173. package/src/components/block-heading.ts +15 -6
  174. package/src/components/card-carry.ts +7 -4
  175. package/src/components/card-manager.ts +14 -2
  176. package/src/components/card-memory.ts +13 -9
  177. package/src/components/deck/DeckEnhancer.astro +13 -0
  178. package/src/components/deck/deck-context.ts +104 -0
  179. package/src/components/deck/deck-controller.ts +1287 -0
  180. package/src/components/deck/deck-map.ts +91 -0
  181. package/src/components/deck-lane.ts +163 -0
  182. package/src/components/diagram/Diagram.astro +131 -86
  183. package/src/components/disclosure/persisted-disclosure.ts +9 -1
  184. package/src/components/figure/Figure.astro +7 -2
  185. package/src/components/figure/FloatCaption.astro +5 -3
  186. package/src/components/fold/Fold.astro +2 -2
  187. package/src/components/notation/NotationFormula.astro +81 -11
  188. package/src/components/notation/NotationGlossary.astro +132 -1
  189. package/src/components/notation/NotationLayer.astro +15 -7
  190. package/src/components/starlight/AboutPanel.astro +186 -3
  191. package/src/components/starlight/CollapsibleSections.astro +24 -3
  192. package/src/components/starlight/DiagramEnhancer.astro +5 -1
  193. package/src/components/starlight/HeadingAnchorEnhancer.astro +1 -1
  194. package/src/components/starlight/LayoutHeader.astro +17 -0
  195. package/src/components/starlight/LayoutPageTitle.astro +110 -0
  196. package/src/components/starlight/LayoutSidebar.astro +807 -21
  197. package/src/components/starlight/LayoutSocialIcons.astro +62 -6
  198. package/src/components/starlight/LessonExportActions.astro +27 -9
  199. package/src/components/starlight/LessonFooter.astro +59 -0
  200. package/src/components/starlight/ReadingMemoryNotice.astro +114 -0
  201. package/src/components/starlight/TextSizeControl.astro +24 -13
  202. package/src/components/starlight/page-marks.ts +139 -0
  203. package/src/components/starlight/section-summary.ts +1 -1
  204. package/src/components/starlight/sidebar-editions.ts +245 -0
  205. package/src/components/table/Table.astro +15 -7
  206. package/src/content-config.ts +87 -1
  207. package/src/curriculum/competency-graph.ts +95 -0
  208. package/src/reference/base-library.mjs +19 -0
  209. package/src/reference/consistency.ts +126 -39
  210. package/src/reference/deck-remark.mjs +600 -0
  211. package/src/reference/deck-source.ts +273 -0
  212. package/src/reference/decks-validate.ts +604 -0
  213. package/src/reference/diagram-layout.ts +3 -1
  214. package/src/reference/figure-symbols.ts +108 -31
  215. package/src/reference/folds-validate.ts +1 -1
  216. package/src/reference/gate-indices.ts +5 -3
  217. package/src/reference/gate-math.ts +169 -25
  218. package/src/reference/lab-katex-validate.ts +30 -15
  219. package/src/reference/lab-math-scope.ts +34 -3
  220. package/src/reference/math-glyphs.mjs +189 -25
  221. package/src/reference/page-notation.ts +69 -15
  222. package/src/reference/references.ts +28 -0
  223. package/src/reference/registry-scope.mjs +34 -0
  224. package/src/reference/registry.ts +41 -11
  225. package/src/reference/rehype-deck.mjs +286 -0
  226. package/src/reference/rehype-heading-anchors.mjs +48 -6
  227. package/src/reference/remark-notation.mjs +249 -40
  228. package/src/reference/source-lines.mjs +65 -0
  229. package/src/reference/types.ts +28 -0
  230. package/src/rules/catalog.ts +257 -4
  231. package/src/rules/course-rules.ts +21 -1
  232. package/src/rules/in-effect.ts +54 -8
  233. package/src/session/PreferenceStore.ts +23 -0
  234. package/src/session/deck-memory.ts +263 -0
  235. package/src/session/reading-memory.ts +243 -0
  236. package/src/styles/deck.css +1150 -0
  237. package/src/styles/global.css +958 -27
  238. package/src/styles/zones.css +277 -0
  239. /package/src/{components → reference}/float-name.ts +0 -0
@@ -9,9 +9,12 @@
9
9
  * names with `\explain{key}{latex}` — not the registry-wide table a card's
10
10
  * body math uses.
11
11
  *
12
- * The gloss list under the formula is not decoration: a gloss has no card of
13
- * its own, so this list is where its name lives with JavaScript off. The hover
14
- * panel is enhancement layered over content that already reads.
12
+ * The symbol list under the formula is not decoration: it is the "where …"
13
+ * clause a textbook prints after an equation, every symbol the formula names
14
+ * in the order it names them — the cards it wraps with `\explain`, each
15
+ * linked to its own entry where the page has one, and the glosses, whose
16
+ * names live only here. With JavaScript off, and in the exports, this is
17
+ * the whole explanation; the hover panel is enhancement over it.
15
18
  */
16
19
  import NotationMath from './NotationMath.astro';
17
20
  import SafeMathNode from './SafeMathNode.astro';
@@ -25,10 +28,22 @@ import {
25
28
  registerPageNotationDependencies,
26
29
  } from '../../reference/page-notation';
27
30
 
31
+ /** A card the formula names with `\explain{key}{…}`, as the symbol list shows it. */
32
+ export interface FormulaCard {
33
+ readonly key: string;
34
+ readonly latex: string;
35
+ readonly label: string;
36
+ readonly units?: string;
37
+ /** Where the card's own entry is, when the page has one (`#notation-<key>` on the glossary). */
38
+ readonly href?: string;
39
+ }
40
+
28
41
  export interface Props {
29
42
  formula: string;
30
43
  scope: readonly GlyphScopeEntry[];
31
44
  glosses?: readonly ResolvedGloss[];
45
+ /** The cards the formula names, in any order; listed with the glosses in formula order. */
46
+ cards?: readonly FormulaCard[];
32
47
  /** Heading for the gloss list; omitted renders the list unlabelled. */
33
48
  glossLabel?: string;
34
49
  /** The owning entry's display label, used to name the scrollable region. */
@@ -43,6 +58,7 @@ const {
43
58
  formula,
44
59
  scope,
45
60
  glosses = [],
61
+ cards = [],
46
62
  glossLabel = 'Symbols',
47
63
  entryLabel,
48
64
  number,
@@ -56,6 +72,41 @@ registerPageNotationDependencies(
56
72
  );
57
73
  const latex = stripNotationMarkers(formula);
58
74
  const numbered = Boolean(number && numberHref);
75
+ // Formula order: a card sits where its `\explain{key}` is, a gloss where its
76
+ // glyph first appears; a symbol the text does not show sorts last.
77
+ const FAR = Number.MAX_SAFE_INTEGER;
78
+ const positionOf = (needle: string): number => {
79
+ const at = formula.indexOf(needle);
80
+ return at < 0 ? FAR : at;
81
+ };
82
+ interface ListedSymbol {
83
+ readonly kind: 'card' | 'gloss';
84
+ readonly key: string;
85
+ readonly latex: string;
86
+ readonly label: string;
87
+ readonly units?: string;
88
+ readonly href?: string;
89
+ readonly position: number;
90
+ }
91
+ const symbols: ListedSymbol[] = [
92
+ ...cards.map((card) => ({
93
+ kind: 'card' as const,
94
+ key: card.key,
95
+ latex: card.latex,
96
+ label: card.label,
97
+ ...(card.units === undefined ? {} : { units: card.units }),
98
+ ...(card.href === undefined ? {} : { href: card.href }),
99
+ position: positionOf(`\\explain{${card.key}}`),
100
+ })),
101
+ ...glosses.map((gloss) => ({
102
+ kind: 'gloss' as const,
103
+ key: gloss.key,
104
+ latex: gloss.notation,
105
+ label: gloss.label,
106
+ ...(gloss.units === undefined ? {} : { units: gloss.units }),
107
+ position: positionOf(gloss.notation),
108
+ })),
109
+ ].sort((a, b) => a.position - b.position || a.key.localeCompare(b.key));
59
110
  // The copy tooltip names what it copies, the way a lesson equation's does
60
111
  // with its `\caption{}`: here the owning entry's label.
61
112
  const copyTitle = entryLabel ? `Copy LaTeX — ${entryLabel}` : 'Copy LaTeX';
@@ -104,21 +155,29 @@ const copyTitle = entryLabel ? `Copy LaTeX — ${entryLabel}` : 'Copy LaTeX';
104
155
  </div>
105
156
 
106
157
  {
107
- glosses.length > 0 && (
158
+ symbols.length > 0 && (
108
159
  <dl class="notation-gloss-list" data-notation-gloss-list>
109
160
  {glossLabel && <dt class="notation-gloss-label">{glossLabel}</dt>}
110
- {glosses.map((gloss) => (
111
- <dd class="notation-gloss" data-notation-gloss={gloss.key}>
161
+ {symbols.map((symbol) => (
162
+ <dd
163
+ class="notation-gloss"
164
+ data-notation-gloss={symbol.key}
165
+ data-notation-card={symbol.kind === 'card' ? '' : undefined}
166
+ >
112
167
  <span
113
168
  class="notation-gloss-symbol"
114
169
  data-notation-symbol-trigger
115
- data-notation-key={gloss.key}
170
+ data-notation-key={symbol.key}
116
171
  >
117
- <NotationMath class="notation-symbol" latex={gloss.notation} />
172
+ <NotationMath class="notation-symbol" latex={symbol.latex} />
118
173
  </span>
119
- <span class="notation-gloss-name">{gloss.label}</span>
120
- {gloss.units && (
121
- <span class="notation-gloss-units">({gloss.units})</span>
174
+ {symbol.href ? (
175
+ <a class="notation-gloss-name" href={symbol.href}>{symbol.label}</a>
176
+ ) : (
177
+ <span class="notation-gloss-name">{symbol.label}</span>
178
+ )}
179
+ {symbol.units && (
180
+ <span class="notation-gloss-units">({symbol.units})</span>
122
181
  )}
123
182
  </dd>
124
183
  ))}
@@ -347,6 +406,17 @@ const copyTitle = entryLabel ? `Copy LaTeX — ${entryLabel}` : 'Copy LaTeX';
347
406
  color: var(--sl-color-gray-3);
348
407
  }
349
408
 
409
+ a.notation-gloss-name {
410
+ color: inherit;
411
+ text-decoration: underline dotted;
412
+ text-underline-offset: 0.15em;
413
+ }
414
+
415
+ a.notation-gloss-name:hover,
416
+ a.notation-gloss-name:focus-visible {
417
+ text-decoration-style: solid;
418
+ }
419
+
350
420
  .notation-gloss-units {
351
421
  color: var(--sl-color-gray-3);
352
422
  }
@@ -6,7 +6,8 @@ import NotationLayer, {
6
6
  type NotationLayerDefinition,
7
7
  } from './NotationLayer.astro';
8
8
  import { resolveLabel } from '../../reference/label';
9
- import { formulaGlyphScope, resolveGlosses } from '../../reference/gloss';
9
+ import { formulaExplainKeys, formulaGlyphScope, resolveGlosses } from '../../reference/gloss';
10
+ import type { FormulaCard } from './NotationFormula.astro';
10
11
  import type { GlyphScopeEntry, ResolvedGloss } from '../../reference/gloss';
11
12
  import {
12
13
  isLessonSlug,
@@ -74,6 +75,74 @@ const glossesByKey = new Map<string, ResolvedGloss[]>(
74
75
  resolveGlosses(entry.data.key, entry.data.glosses),
75
76
  ]),
76
77
  );
78
+ // The cards a formula names, for its symbol list: label and units from
79
+ // their own entries, each linked to its entry on this page.
80
+ const entryByKey = new Map(notationEntries.map((entry) => [entry.data.key, entry]));
81
+ const formulaCardsByKey = new Map<string, FormulaCard[]>(
82
+ notationEntries.map((entry) => [
83
+ entry.data.key,
84
+ [...new Set(formulaExplainKeys(formulaOf(entry.data) ?? ''))].flatMap((key) => {
85
+ const named = entryByKey.get(key);
86
+ if (!named || key === entry.data.key) return [];
87
+ return [
88
+ {
89
+ key,
90
+ latex: named.data.latex,
91
+ label: labelOf(named.data),
92
+ ...(named.data.units === undefined ? {} : { units: named.data.units }),
93
+ href: `#notation-${key}`,
94
+ },
95
+ ];
96
+ }),
97
+ ]),
98
+ );
99
+
100
+ // What a single page defines for itself, and what a formula glosses: listed
101
+ // after the index so a reader who looks a letter up always finds it, and sees
102
+ // at a glance which meanings are course-wide and which are page-bound.
103
+ const lessonHref = (lessonId: string): { href: string; title: string } | undefined => {
104
+ const lesson = lessonById.get(lessonId);
105
+ if (!lesson) return undefined;
106
+ return {
107
+ href: `${import.meta.env.BASE_URL.replace(/\/$/, '')}/${lesson.id}/`,
108
+ title: String(lesson.data.title ?? lesson.id),
109
+ };
110
+ };
111
+ interface PageMeaning {
112
+ readonly latex: string;
113
+ readonly label: string;
114
+ readonly units?: string;
115
+ readonly where: 'page' | 'formula';
116
+ readonly href: string;
117
+ readonly title: string;
118
+ }
119
+ const pageMeanings: PageMeaning[] = [
120
+ ...manifest.notation.definitions.flatMap((definition) => {
121
+ if (definition.kind !== 'local') return [];
122
+ const at = lessonHref(definition.lessonId);
123
+ if (!at) return [];
124
+ return [
125
+ {
126
+ latex: definition.notation,
127
+ label: definition.label,
128
+ ...(definition.units === undefined ? {} : { units: definition.units }),
129
+ where: 'page' as const,
130
+ ...at,
131
+ },
132
+ ];
133
+ }),
134
+ ...notationEntries.flatMap((entry) =>
135
+ (glossesByKey.get(entry.data.key) ?? []).map((gloss) => ({
136
+ latex: gloss.notation,
137
+ label: gloss.label,
138
+ ...(gloss.units === undefined ? {} : { units: gloss.units }),
139
+ where: 'formula' as const,
140
+ href: `#notation-${entry.data.key}`,
141
+ title: labelOf(entry.data),
142
+ })),
143
+ ),
144
+ ].sort((a, b) => a.latex.localeCompare(b.latex) || a.label.localeCompare(b.label) || a.title.localeCompare(b.title));
145
+
77
146
  const formulaScopeByKey = new Map<string, GlyphScopeEntry[]>(
78
147
  notationEntries.map((entry) => [
79
148
  entry.data.key,
@@ -87,6 +156,21 @@ const formulaScopeByKey = new Map<string, GlyphScopeEntry[]>(
87
156
  ]),
88
157
  );
89
158
 
159
+ // Astro's content layer logs a body that fails to render (`[ERROR]
160
+ // [glob-loader]`) and keeps the entry without it, so the glossary would ship
161
+ // the entry with no text and the build would end green. `validate:content`
162
+ // reports the usual causes first; this stops the build on any of them.
163
+ const unrendered = notationEntries.filter(
164
+ (entry) => entry.body?.trim() && entry.rendered === undefined,
165
+ );
166
+ if (unrendered.length > 0) {
167
+ throw new Error(
168
+ `Notation entries whose body did not render: ${unrendered
169
+ .map((entry) => entry.filePath ?? entry.id)
170
+ .join(', ')}. The [glob-loader] error above each says why; run validate:content for the file's own line.`,
171
+ );
172
+ }
173
+
90
174
  const renderedEntries = await Promise.all(
91
175
  notationEntries
92
176
  .sort(
@@ -151,6 +235,31 @@ const panelDefinitions: NotationLayerDefinition[] = notationEntries.map(
151
235
  </ul>
152
236
  </nav>
153
237
 
238
+ {
239
+ pageMeanings.length > 0 && (
240
+ <section class="glossary-page-meanings" aria-labelledby="glossary-page-meanings-heading">
241
+ <h2 id="glossary-page-meanings-heading">On one page only</h2>
242
+ <p class="glossary-intro">
243
+ Symbols a single chapter defines for itself, or a formula names for its own use. The
244
+ letter may mean something else elsewhere; the page is where its card is.
245
+ </p>
246
+ <ul>
247
+ {pageMeanings.map((meaning) => (
248
+ <li>
249
+ <NotationMath class="glossary-symbol" latex={meaning.latex} />
250
+ <span class="glossary-page-meaning-text">
251
+ {' '}— {meaning.label}
252
+ {meaning.units && <span class="glossary-page-meaning-units"> ({meaning.units})</span>},{' '}
253
+ {meaning.where === 'page' ? 'on' : 'in the formula of'}{' '}
254
+ <a href={meaning.href}>{meaning.title}</a>
255
+ </span>
256
+ </li>
257
+ ))}
258
+ </ul>
259
+ </section>
260
+ )
261
+ }
262
+
154
263
  <div class="notation-glossary">
155
264
  {
156
265
  renderedEntries.map(({ entry, Content }) => {
@@ -241,6 +350,7 @@ const panelDefinitions: NotationLayerDefinition[] = notationEntries.map(
241
350
  formula={formulaOf(entry.data) ?? ''}
242
351
  scope={formulaScopeByKey.get(entry.data.key) ?? []}
243
352
  glosses={glossesByKey.get(entry.data.key)}
353
+ cards={formulaCardsByKey.get(entry.data.key)}
244
354
  entryLabel={labelOf(entry.data)}
245
355
  number={equationNumberByKey.get(entry.data.key)}
246
356
  numberHref={`#notation-${entry.data.key}`}
@@ -323,6 +433,27 @@ const panelDefinitions: NotationLayerDefinition[] = notationEntries.map(
323
433
  padding-inline-start: 1.25rem;
324
434
  }
325
435
 
436
+ .glossary-page-meanings {
437
+ margin-block: 1.5rem 2rem;
438
+ }
439
+
440
+ .glossary-page-meanings h2 {
441
+ font-size: var(--sl-text-h4);
442
+ margin-block-end: 0.25rem;
443
+ }
444
+
445
+ .glossary-page-meanings ul {
446
+ display: grid;
447
+ grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr));
448
+ gap: 0.3rem 1.5rem;
449
+ padding-inline-start: 1.25rem;
450
+ font-size: var(--sl-text-sm);
451
+ }
452
+
453
+ .glossary-page-meaning-units {
454
+ color: var(--sl-color-gray-3);
455
+ }
456
+
326
457
  .notation-glossary {
327
458
  display: grid;
328
459
  gap: 1.5rem;
@@ -1141,21 +1141,29 @@ for (const gloss of glossItems) {
1141
1141
  cursor: help;
1142
1142
  }
1143
1143
 
1144
- /* The highlight blends with what is behind it instead of covering it.
1145
- KaTeX lays a formula out as boxes that paint in source order, so an
1146
- opaque background (and its ring) on one symbol is painted over the glyph
1147
- to its left — hovering the y of `dy` hid part of the d. `multiply` (light
1148
- theme) and `screen` (dark) tint the page and leave ink of either theme as
1149
- it was. */
1150
1144
  :global(.katex-html [data-notation-key]:hover),
1151
1145
  :global([data-notation-key].is-notation-active) {
1152
1146
  background: var(--sl-color-accent-low);
1153
1147
  box-shadow: 0 0 0 0.12em var(--sl-color-accent-low);
1148
+ }
1149
+
1150
+ /* In math the highlight blends with what is behind it instead of covering
1151
+ it. KaTeX lays a formula out as boxes that paint in source order, so an
1152
+ opaque background (and its ring) on one symbol is painted over the glyph
1153
+ to its left — hovering the y of `dy` hid part of the d. `multiply` (light
1154
+ theme) and `screen` (dark) tint the page and leave ink of either theme as
1155
+ it was. Only math: a prose link (`a.notation-term`) is one box with its
1156
+ own text in it, and blending its tint with whatever sits behind it (a
1157
+ glossary row's panel) changes the ground its text is read on — in the
1158
+ light theme the engine's own link colour fell from 4.75:1 on the opaque
1159
+ tint to 4.4:1, under AA. */
1160
+ :global(.katex-html [data-notation-key]:hover),
1161
+ :global(.katex-html [data-notation-key].is-notation-active) {
1154
1162
  mix-blend-mode: screen;
1155
1163
  }
1156
1164
 
1157
1165
  :global(:root[data-theme='light'] .katex-html [data-notation-key]:hover),
1158
- :global(:root[data-theme='light'] [data-notation-key].is-notation-active) {
1166
+ :global(:root[data-theme='light'] .katex-html [data-notation-key].is-notation-active) {
1159
1167
  mix-blend-mode: multiply;
1160
1168
  }
1161
1169
 
@@ -13,21 +13,49 @@
13
13
  * `aria-expanded` for free; the inline script only adds Escape-to-close and
14
14
  * outside-click-to-close.
15
15
  *
16
+ * On a chapter or a part's page it adds the page's own version (rule C5,
17
+ * `lessons[].since` / `parts[].overview.since`): "unchanged since version
18
+ * 0.2.0", or "with changes after version 0.4.0, not yet released" — the line
19
+ * the page's PDF and Markdown print under their title.
20
+ *
16
21
  * The panel also *publishes* the identity it displays: the `data-knowledge-*`
17
- * and `data-engine-version` attributes carry the same values as the rendered
18
- * text. A build check that wants to confirm the shipped page really carries the
22
+ * and `data-engine-version` attributes (and `data-page-since` on the page's
23
+ * line, `unreleased` for a change not yet released) carry the same values as
24
+ * the rendered text. A build check that wants to confirm the shipped page really carries the
19
25
  * configured version would otherwise have to match the panel's prose and its
20
26
  * `version · date` join, which pins a course's test to one version string and
21
27
  * makes the check fail on every bump; a bare version substring is not even
22
28
  * scoped to the panel, so the engine's own version elsewhere on the page can
23
29
  * satisfy it. These attributes are exact, scoped, and stable across a bump.
24
30
  */
31
+ import { docsSlugsForRoute } from '../../compiler/lesson-derivation';
25
32
  import { loadManifest } from '../../compiler/manifest';
33
+ import { versionPhrase } from '../../compiler/page-versions';
26
34
 
27
- const { about } = await loadManifest();
35
+ const manifest = await loadManifest();
36
+ const { about } = manifest;
28
37
  const { engine, material, knowledge, authors, disclaimer, changelogHref } =
29
38
  about;
30
39
 
40
+ // The page's own version (rule C5): a chapter's or a part page's, so a reader
41
+ // can tell a downloaded PDF's "unchanged since version 0.2.0" against the
42
+ // live page. Other pages, and a course with the rule off, show no such line.
43
+ const routeSlugs = docsSlugsForRoute(Astro.locals.starlightRoute.entry.id);
44
+ const lesson = manifest.lessons.find((entry) => routeSlugs.includes(entry.slug));
45
+ const overview = lesson
46
+ ? undefined
47
+ : manifest.parts.find((entry) => routeSlugs.includes(`${entry.dir}/index`))
48
+ ?.overview;
49
+ const pageSince = lesson ? lesson.since : overview ? overview.since : undefined;
50
+ const pageLine =
51
+ manifest.pageVersions.enabled && pageSince !== undefined
52
+ ? versionPhrase(pageSince, knowledge.version)
53
+ : undefined;
54
+
55
+ // What this course keeps in the reader's browser, and their say over it
56
+ // (`session/reading-memory.ts`): the reading-memory answer, and forgetting.
57
+ const readingMemory = manifest.readingMemory ?? 'ask';
58
+
31
59
  /** `mailto:` an email, pass through an http(s) URL, plain text otherwise. */
32
60
  function contactHref(contact: string): string | undefined {
33
61
  if (/^https?:\/\//i.test(contact)) return contact;
@@ -64,6 +92,14 @@ const knowledgeLine = knowledge.date
64
92
  <dt>Knowledge version</dt>
65
93
  <dd data-knowledge-line>{knowledgeLine}</dd>
66
94
  </div>
95
+ {
96
+ pageLine && (
97
+ <div>
98
+ <dt>This page</dt>
99
+ <dd data-page-since={pageSince ?? 'unreleased'}>{pageLine}</dd>
100
+ </div>
101
+ )
102
+ }
67
103
  <div>
68
104
  <dt>Engine</dt>
69
105
  <dd>
@@ -123,6 +159,31 @@ const knowledgeLine = knowledge.date
123
159
  </p>
124
160
  )
125
161
  }
162
+
163
+ <div class="about-browser" data-about-browser>
164
+ <p class="about-browser__title">In this browser</p>
165
+ <p>No cookies. Nothing this course keeps leaves your browser.</p>
166
+ {
167
+ readingMemory === 'ask' ? (
168
+ <label class="about-browser__switch">
169
+ <input type="checkbox" data-reading-memory-toggle />
170
+ <span>
171
+ Remember my reading: the chapters I open and the cards I keep open
172
+ </span>
173
+ </label>
174
+ ) : (
175
+ <p>This course keeps nothing of your reading beyond the tab.</p>
176
+ )
177
+ }
178
+ <p class="about-browser__hint">
179
+ Settings you choose, such as the theme and the text size, are kept
180
+ either way.
181
+ </p>
182
+ <button type="button" class="about-browser__forget" data-forget-course>
183
+ Forget what this course stored
184
+ </button>
185
+ <p class="about-browser__status" role="status" data-forget-status></p>
186
+ </div>
126
187
  </div>
127
188
  </details>
128
189
 
@@ -190,6 +251,22 @@ const knowledgeLine = knowledge.date
190
251
  line-height: 1.5;
191
252
  }
192
253
 
254
+ /* Below 50rem the visible copy is the one in the phone menu. There the
255
+ toggle starts its row, at the start of the screen, and the panel hung
256
+ from the toggle's end ran off the screen's start edge and past the
257
+ menu's end. So it is a sheet fixed to the window instead, centred
258
+ under the header; the toggle's place in the menu no longer matters. */
259
+ @media not all and (min-width: 50rem) {
260
+ .about-body {
261
+ position: fixed;
262
+ inset-inline: 0.75rem;
263
+ top: calc(var(--sl-nav-height) + 0.5rem);
264
+ width: auto;
265
+ max-width: 21rem;
266
+ margin-inline: auto;
267
+ }
268
+ }
269
+
193
270
  .about-name {
194
271
  margin: 0;
195
272
  font-size: var(--sl-text-sm);
@@ -240,9 +317,115 @@ const knowledgeLine = knowledge.date
240
317
  .about-body a {
241
318
  color: var(--sl-color-text-accent);
242
319
  }
320
+
321
+ .about-browser {
322
+ margin: 0.85rem 0 0;
323
+ padding-top: 0.75rem;
324
+ border-top: 1px solid var(--sl-color-gray-6);
325
+ display: grid;
326
+ gap: 0.4rem;
327
+ }
328
+
329
+ .about-browser p {
330
+ margin: 0;
331
+ }
332
+
333
+ .about-browser__title {
334
+ color: var(--sl-color-gray-3);
335
+ text-transform: uppercase;
336
+ letter-spacing: 0.03em;
337
+ font-size: 0.65rem;
338
+ }
339
+
340
+ .about-browser__switch {
341
+ display: flex;
342
+ align-items: flex-start;
343
+ gap: 0.45rem;
344
+ cursor: pointer;
345
+ }
346
+
347
+ .about-browser__switch input {
348
+ margin: 0.15rem 0 0;
349
+ accent-color: var(--sl-color-accent);
350
+ }
351
+
352
+ .about-browser__hint {
353
+ color: var(--sl-color-gray-2);
354
+ }
355
+
356
+ .about-browser__forget {
357
+ justify-self: start;
358
+ border: 1px solid var(--sl-color-gray-5);
359
+ border-radius: 0.3rem;
360
+ padding: 0.2rem 0.55rem;
361
+ color: var(--sl-color-text);
362
+ background: none;
363
+ font: inherit;
364
+ cursor: pointer;
365
+ }
366
+
367
+ .about-browser__forget:hover,
368
+ .about-browser__forget:focus-visible {
369
+ border-color: var(--sl-color-accent);
370
+ background: var(--sl-color-accent-low);
371
+ }
372
+
373
+ .about-browser__status:empty {
374
+ display: none;
375
+ }
243
376
  }
244
377
  </style>
245
378
 
379
+ <script>
380
+ /**
381
+ * The reader's say over what the course keeps in their browser: the
382
+ * reading-memory answer, as a switch every copy of the panel shows the
383
+ * same, and "Forget what this course stored", after which the course asks
384
+ * again as on a first visit.
385
+ */
386
+ import {
387
+ answerReadingMemory,
388
+ forgetCourse,
389
+ readingAnswer,
390
+ READING_MEMORY_EVENT,
391
+ } from '../../session/reading-memory';
392
+
393
+ (() => {
394
+ const base =
395
+ document.querySelector<HTMLElement>('[data-explico-reading-memory]')
396
+ ?.dataset.explicoBase ?? '/';
397
+ const toggles = [
398
+ ...document.querySelectorAll<HTMLInputElement>(
399
+ '[data-reading-memory-toggle]',
400
+ ),
401
+ ];
402
+ const sync = () => {
403
+ const yes = readingAnswer(base) === 'yes';
404
+ for (const toggle of toggles) toggle.checked = yes;
405
+ };
406
+ for (const toggle of toggles) {
407
+ toggle.addEventListener('change', () => {
408
+ answerReadingMemory(base, toggle.checked);
409
+ });
410
+ }
411
+ for (const button of document.querySelectorAll<HTMLButtonElement>(
412
+ '[data-forget-course]',
413
+ )) {
414
+ button.addEventListener('click', () => {
415
+ forgetCourse(base);
416
+ for (const status of document.querySelectorAll<HTMLElement>(
417
+ '[data-forget-status]',
418
+ )) {
419
+ status.textContent =
420
+ 'Forgotten: this course keeps nothing of yours in this browser now.';
421
+ }
422
+ });
423
+ }
424
+ window.addEventListener(READING_MEMORY_EVENT, sync);
425
+ sync();
426
+ })();
427
+ </script>
428
+
246
429
  <script>
247
430
  (() => {
248
431
  const panels = Array.from(
@@ -62,9 +62,11 @@
62
62
  * outright, a rule no CSS can override — confirmed the hard way while
63
63
  * building the print handling below), so `peeking` still has to force
64
64
  * `details.open = true` to render anything; only `data-state` distinguishes
65
- * a real expand from a peek. `wirePersistedDetails`'s own `toggle`-event
66
- * listener is only fed a synthetic event on a genuine `open`/`closed`
67
- * commit — never for a peek — so hovering never persists anything.
65
+ * a real expand from a peek. A peek never persists anything: it carries
66
+ * `data-explico-temporary-open` from its first `open = true` until the
67
+ * `toggle` its closing fires, and `wirePersistedDetails`'s `toggle` listener
68
+ * skips an element that has it. Only a genuine commit saves, through
69
+ * `savePersistedOpenState`.
68
70
  *
69
71
  * Persistence goes through `../disclosure/persisted-disclosure` (keyed by
70
72
  * heading `id` + page path, same convention as `Figure.astro`'s
@@ -369,6 +371,17 @@
369
371
  });
370
372
  };
371
373
 
374
+ // A peek is no choice: it opens the real `<details>` to render, and that
375
+ // fires a native `toggle`, which `wirePersistedDetails` would save as
376
+ // "open". `persisted-disclosure.ts` skips a `toggle` while this marker
377
+ // is on the element, so the marker is on from the peek's first
378
+ // `open = true` until the `toggle` its closing fires has passed. Without
379
+ // it a reader who collapsed a section and reloaded with the pointer
380
+ // still over its title lost the choice for good: the browser sends the
381
+ // pointer's `pointerenter` after the reload, the peek opened, and
382
+ // "open" was stored over "closed".
383
+ const TEMPORARY_OPEN = 'data-explico-temporary-open';
384
+
372
385
  // Promote an in-progress peek to a real, persisted expand — grown
373
386
  // from the peek's *current* rendered height, not from zero. The
374
387
  // `position: absolute` → `static` switch happens the instant before
@@ -378,6 +391,7 @@
378
391
  // animation completes.
379
392
  const commitFromPeek = () => {
380
393
  const peekPx = body.getBoundingClientRect().height;
394
+ details.removeAttribute(TEMPORARY_OPEN); // now a choice
381
395
  body.style.transition = 'none';
382
396
  body.style.maxHeight = `${peekPx}px`;
383
397
  void body.offsetHeight; // reflow with the peek height frozen…
@@ -393,6 +407,7 @@
393
407
 
394
408
  const enterPeek = () => {
395
409
  if (details.dataset.state !== 'closed') return;
410
+ details.setAttribute(TEMPORARY_OPEN, '');
396
411
  details.open = true; // render — see the module comment
397
412
  details.dataset.state = 'peeking' satisfies State;
398
413
  growTo(body, Math.min(body.scrollHeight, peekMaxPx(body)), () => {});
@@ -402,6 +417,12 @@
402
417
  if (details.dataset.state !== 'peeking') return;
403
418
  growTo(body, 0, () => {
404
419
  details.dataset.state = 'closed' satisfies State;
420
+ // `toggle` is queued, not synchronous: the marker goes when it fires.
421
+ details.addEventListener(
422
+ 'toggle',
423
+ () => details.removeAttribute(TEMPORARY_OPEN),
424
+ { once: true },
425
+ );
405
426
  details.open = false;
406
427
  });
407
428
  };
@@ -216,7 +216,11 @@
216
216
  if (active?.figure === figure) collapseExpansion();
217
217
  else expand(figure, button, scroll, evaluate);
218
218
  });
219
- new ResizeObserver(evaluate).observe(scroll);
219
+ const resizes = new ResizeObserver(evaluate);
220
+ resizes.observe(scroll);
221
+ // The text-size menu resizes the drawing without resizing its scroller.
222
+ const frame = scroll.querySelector('.diagram-frame');
223
+ if (frame) resizes.observe(frame);
220
224
  updateExpandButton(figure, button, false);
221
225
  evaluate();
222
226
  }
@@ -57,7 +57,7 @@
57
57
  <script>
58
58
  import { HEADINGS, sectionParts } from './section-parts';
59
59
  import { animatedScrollIntoView } from '../scroll-animation';
60
- import { floatOf } from '../float-name';
60
+ import { floatOf } from '../../reference/float-name';
61
61
  import { readableText } from '../readable-text';
62
62
 
63
63
  (() => {