explico 0.7.0 → 0.11.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 (236) hide show
  1. package/CHANGELOG.md +1409 -1
  2. package/README.md +60 -15
  3. package/dist/compiler/astro.d.ts +6 -1
  4. package/dist/compiler/cli.d.ts +54 -3
  5. package/dist/compiler/cli.js +80 -6
  6. package/dist/compiler/collections.d.ts +35 -2
  7. package/dist/compiler/collections.js +84 -8
  8. package/dist/compiler/course-config.d.ts +3 -194
  9. package/dist/compiler/course-config.js +78 -5
  10. package/dist/compiler/exports/standalone-html.d.ts +6 -1
  11. package/dist/compiler/knowledge-version.js +1 -1
  12. package/dist/compiler/lesson-derivation.d.ts +27 -10
  13. package/dist/compiler/lesson-derivation.js +23 -8
  14. package/dist/compiler/logging.d.ts +14 -1
  15. package/dist/compiler/logging.js +6 -2
  16. package/dist/compiler/manifest.d.ts +204 -31
  17. package/dist/compiler/manifest.js +270 -55
  18. package/dist/compiler/resolution-report.d.ts +6 -1
  19. package/dist/course-config-Dsnri9Tk.d.ts +464 -0
  20. package/dist/reference/base-accents.d.mts +26 -0
  21. package/dist/reference/base-accents.mjs +31 -0
  22. package/dist/reference/base-roman-labels.d.mts +2 -2
  23. package/dist/reference/base-roman-labels.mjs +2 -2
  24. package/dist/reference/consistency.d.ts +14 -2
  25. package/dist/reference/consistency.js +108 -4
  26. package/dist/reference/delimiter-size.d.ts +21 -11
  27. package/dist/reference/delimiter-size.js +15 -39
  28. package/dist/reference/diagrams-validate.d.ts +7 -2
  29. package/dist/reference/diagrams-validate.js +9 -1
  30. package/dist/reference/equations-validate.d.ts +8 -5
  31. package/dist/reference/equations-validate.js +1 -0
  32. package/dist/reference/equations.d.mts +69 -34
  33. package/dist/reference/equations.mjs +90 -44
  34. package/dist/reference/examples-validate.d.ts +31 -0
  35. package/dist/reference/examples-validate.js +48 -0
  36. package/dist/reference/figures-validate.d.ts +1 -1
  37. package/dist/reference/figures-validate.js +4 -1
  38. package/dist/reference/folds-validate.d.ts +33 -0
  39. package/dist/reference/folds-validate.js +32 -0
  40. package/dist/reference/gate-abbreviations.d.ts +3 -1
  41. package/dist/reference/gate-abbreviations.js +3 -2
  42. package/dist/reference/gate-course-rules.d.ts +32 -0
  43. package/dist/reference/gate-course-rules.js +85 -0
  44. package/dist/reference/gate-heading-length.d.ts +5 -2
  45. package/dist/reference/gate-heading-length.js +47 -23
  46. package/dist/reference/gate-heading-structure.d.ts +41 -0
  47. package/dist/reference/gate-heading-structure.js +113 -0
  48. package/dist/reference/gate-math-punctuation.d.ts +71 -0
  49. package/dist/reference/gate-math-punctuation.js +251 -0
  50. package/dist/reference/gate-math.d.ts +44 -6
  51. package/dist/reference/gate-math.js +63 -10
  52. package/dist/reference/gate-part-pages.d.ts +50 -0
  53. package/dist/reference/gate-part-pages.js +118 -0
  54. package/dist/reference/gate-prose-notation.d.ts +32 -11
  55. package/dist/reference/gate-prose-notation.js +30 -8
  56. package/dist/reference/katex-delimiters.d.mts +15 -0
  57. package/dist/reference/katex-delimiters.mjs +138 -0
  58. package/dist/reference/katex-options.mjs +4 -0
  59. package/dist/reference/keyed-float-validate.d.ts +27 -10
  60. package/dist/reference/keyed-float-validate.js +15 -1
  61. package/dist/reference/keyed-jsx-floats.d.mts +11 -5
  62. package/dist/reference/keyed-jsx-floats.mjs +73 -20
  63. package/dist/reference/knowledge-checks-validate.d.ts +1 -1
  64. package/dist/reference/knowledge-checks.d.mts +3 -1
  65. package/dist/reference/knowledge-checks.mjs +9 -4
  66. package/dist/reference/lab-math-scope.d.ts +1 -0
  67. package/dist/reference/lab-math-slot.d.ts +34 -0
  68. package/dist/reference/lab-math-slot.js +6 -0
  69. package/dist/reference/math-glyphs.d.mts +80 -4
  70. package/dist/reference/math-glyphs.mjs +588 -39
  71. package/dist/reference/page-notation.d.ts +6 -1
  72. package/dist/reference/raw-floats-validate.js +1 -1
  73. package/dist/reference/raw-floats.mjs +1 -1
  74. package/dist/reference/rehype-heading-anchors.d.mts +14 -0
  75. package/dist/reference/rehype-heading-anchors.mjs +116 -17
  76. package/dist/reference/rehype-table-header-labels.d.mts +6 -1
  77. package/dist/reference/rehype-table-header-labels.mjs +21 -3
  78. package/dist/reference/remark-asides.d.mts +3 -0
  79. package/dist/reference/remark-asides.mjs +180 -0
  80. package/dist/reference/remark-memo.d.mts +3 -1
  81. package/dist/reference/remark-memo.mjs +42 -11
  82. package/dist/reference/remark-notation.d.mts +60 -10
  83. package/dist/reference/remark-notation.mjs +387 -36
  84. package/dist/reference/render-lab-math.d.ts +4 -26
  85. package/dist/reference/render-lab-math.js +5 -5
  86. package/dist/reference/sections-validate.d.ts +27 -0
  87. package/dist/reference/sections-validate.js +41 -0
  88. package/dist/reference/sections.d.mts +97 -0
  89. package/dist/reference/sections.mjs +306 -0
  90. package/dist/reference/tables-validate.d.ts +1 -1
  91. package/dist/reference/types.d.ts +24 -1
  92. package/dist/reference/unit-words.d.ts +37 -0
  93. package/dist/reference/unit-words.js +170 -0
  94. package/dist/rules/catalog.d.ts +176 -0
  95. package/dist/rules/catalog.js +983 -0
  96. package/dist/rules/course-rules.d.ts +115 -0
  97. package/dist/rules/course-rules.js +292 -0
  98. package/dist/rules/course-rules.schema.json +138 -0
  99. package/dist/rules/in-effect.d.ts +53 -0
  100. package/dist/rules/in-effect.js +142 -0
  101. package/dist/rules/rules.schema.json +1072 -0
  102. package/dist/rules/settings.d.ts +3 -0
  103. package/dist/rules/settings.js +290 -0
  104. package/dist/rules/waivers.d.ts +53 -0
  105. package/dist/rules/waivers.js +132 -0
  106. package/dist/session/PreferenceStore.d.ts +5 -0
  107. package/dist/session/PreferenceStore.js +9 -0
  108. package/docs/VERSIONING.md +2 -2
  109. package/docs/authoring/README.md +232 -40
  110. package/docs/authoring/agents.md +55 -9
  111. package/docs/authoring/components.md +178 -22
  112. package/docs/authoring/migrating.md +1324 -2
  113. package/docs/authoring/notation.md +616 -344
  114. package/docs/authoring/prose.md +551 -0
  115. package/docs/authoring/retired.md +35 -0
  116. package/docs/authoring/structure-rules.md +422 -40
  117. package/docs/authoring/structure.md +445 -62
  118. package/docs/authoring/versioning.md +20 -28
  119. package/docs/authoring/visualizations.md +142 -20
  120. package/docs/katex-bracket-scaling.md +191 -0
  121. package/package.json +3 -2
  122. package/src/compiler/cli.ts +139 -6
  123. package/src/compiler/collections.ts +144 -10
  124. package/src/compiler/course-config.ts +273 -34
  125. package/src/compiler/knowledge-version.ts +1 -1
  126. package/src/compiler/lesson-derivation.ts +54 -15
  127. package/src/compiler/logging.ts +21 -4
  128. package/src/compiler/manifest.ts +573 -104
  129. package/src/components/BlockHeading.astro +113 -0
  130. package/src/components/CourseContents.astro +221 -0
  131. package/src/components/CurriculumMap.astro +5 -6
  132. package/src/components/FloatIndex.astro +200 -0
  133. package/src/components/HoverPanel.astro +431 -79
  134. package/src/components/PartBridge.astro +27 -0
  135. package/src/components/PartOverview.astro +401 -0
  136. package/src/components/PartScope.astro +27 -0
  137. package/src/components/abbreviation/AbbreviationLayer.astro +49 -51
  138. package/src/components/assessments/AssessmentRunner.tsx +8 -5
  139. package/src/components/assessments/AssessmentSet.astro +23 -26
  140. package/src/components/block-heading.ts +146 -0
  141. package/src/components/card-arrow.ts +243 -0
  142. package/src/components/card-drag.ts +631 -0
  143. package/src/components/card-jump.ts +45 -0
  144. package/src/components/card-manager.ts +1589 -0
  145. package/src/components/card-memory.ts +177 -0
  146. package/src/components/card-placement.ts +504 -0
  147. package/src/components/card-side.ts +336 -0
  148. package/src/components/card-travel.ts +197 -0
  149. package/src/components/citation/CitationLayer.astro +77 -76
  150. package/src/components/diagram/Diagram.astro +161 -316
  151. package/src/components/disclosure/course-disclosure.ts +24 -0
  152. package/src/components/disclosure/hover-preview.ts +122 -42
  153. package/src/components/disclosure/persisted-disclosure.ts +3 -3
  154. package/src/components/disclosure/print-as-shown.ts +252 -0
  155. package/src/components/examples/CompactExample.astro +3 -1
  156. package/src/components/examples/CompactExamples.astro +101 -94
  157. package/src/components/figure/Figure.astro +174 -274
  158. package/src/components/figure/FloatCaption.astro +87 -0
  159. package/src/components/figure/figure-state.ts +58 -0
  160. package/src/components/figure/figure-tools.ts +87 -0
  161. package/src/components/float-name.ts +101 -0
  162. package/src/components/fold/Fold.astro +125 -0
  163. package/src/components/fold/fold-id.ts +40 -0
  164. package/src/components/memo/Memo.astro +162 -231
  165. package/src/components/memo/ShortNote.astro +6 -6
  166. package/src/components/notation/NotationFormula.astro +5 -1
  167. package/src/components/notation/NotationGlossary.astro +24 -14
  168. package/src/components/notation/NotationLayer.astro +169 -162
  169. package/src/components/notation/NotationMath.astro +10 -1
  170. package/src/components/notation/render-notation-math.ts +111 -10
  171. package/src/components/reference/FloatRefLayer.astro +485 -423
  172. package/src/components/starlight/AboutPanel.astro +22 -6
  173. package/src/components/starlight/CollapsibleSections.astro +207 -93
  174. package/src/components/starlight/DiagramEnhancer.astro +15 -138
  175. package/src/components/starlight/HeadingAnchorEnhancer.astro +193 -1
  176. package/src/components/starlight/KeyedFloatEnhancer.astro +100 -9
  177. package/src/components/starlight/LayoutHeader.astro +298 -19
  178. package/src/components/starlight/LayoutPageSidebar.astro +99 -12
  179. package/src/components/starlight/LayoutPageTitle.astro +120 -0
  180. package/src/components/starlight/LayoutSidebar.astro +229 -11
  181. package/src/components/starlight/LessonExportActions.astro +53 -72
  182. package/src/components/starlight/LessonFooter.astro +26 -4
  183. package/src/components/starlight/section-parts.ts +15 -2
  184. package/src/components/starlight/section-summary.ts +181 -0
  185. package/src/components/table/Table.astro +95 -76
  186. package/src/components/table/TableOverflowExpander.astro +65 -28
  187. package/src/content-config.ts +40 -4
  188. package/src/reference/base-accents.mjs +31 -0
  189. package/src/reference/base-roman-labels.mjs +2 -2
  190. package/src/reference/consistency.ts +176 -5
  191. package/src/reference/delimiter-size.ts +40 -61
  192. package/src/reference/diagrams-validate.ts +17 -1
  193. package/src/reference/equations-validate.ts +10 -5
  194. package/src/reference/equations.mjs +90 -44
  195. package/src/reference/examples-validate.ts +97 -0
  196. package/src/reference/figures-validate.ts +6 -1
  197. package/src/reference/folds-validate.ts +65 -0
  198. package/src/reference/gate-abbreviations.ts +3 -2
  199. package/src/reference/gate-course-rules.ts +134 -0
  200. package/src/reference/gate-heading-length.ts +68 -37
  201. package/src/reference/gate-heading-structure.ts +193 -0
  202. package/src/reference/gate-math-punctuation.ts +381 -0
  203. package/src/reference/gate-math.ts +119 -12
  204. package/src/reference/gate-part-pages.ts +217 -0
  205. package/src/reference/gate-prose-notation.ts +74 -13
  206. package/src/reference/katex-delimiters.mjs +138 -0
  207. package/src/reference/katex-options.mjs +4 -0
  208. package/src/reference/keyed-float-validate.ts +45 -11
  209. package/src/reference/keyed-jsx-floats.mjs +73 -20
  210. package/src/reference/knowledge-checks-validate.ts +1 -1
  211. package/src/reference/knowledge-checks.mjs +9 -4
  212. package/src/reference/lab-math-slot.ts +36 -0
  213. package/src/reference/math-glyphs.mjs +588 -39
  214. package/src/reference/raw-floats-validate.ts +1 -1
  215. package/src/reference/raw-floats.mjs +1 -1
  216. package/src/reference/rehype-heading-anchors.mjs +116 -17
  217. package/src/reference/rehype-table-header-labels.mjs +21 -3
  218. package/src/reference/remark-asides.mjs +180 -0
  219. package/src/reference/remark-memo.mjs +42 -11
  220. package/src/reference/remark-notation.mjs +387 -36
  221. package/src/reference/render-lab-math.ts +17 -32
  222. package/src/reference/sections-validate.ts +81 -0
  223. package/src/reference/sections.mjs +306 -0
  224. package/src/reference/tables-validate.ts +2 -1
  225. package/src/reference/types.ts +24 -0
  226. package/src/reference/unit-words.ts +233 -0
  227. package/src/rules/catalog.ts +1298 -0
  228. package/src/rules/course-rules.ts +456 -0
  229. package/src/rules/in-effect.ts +235 -0
  230. package/src/rules/settings.ts +448 -0
  231. package/src/rules/waivers.ts +249 -0
  232. package/src/session/PreferenceStore.ts +15 -0
  233. package/src/styles/global.css +1471 -163
  234. package/src/styles/surfaces.css +182 -0
  235. package/src/components/hover-panel.ts +0 -339
  236. package/src/components/popup-stack.ts +0 -345
@@ -2,8 +2,8 @@
2
2
  /**
3
3
  * Progressive behavior for `<Diagram>`:
4
4
  *
5
- * - a persistent, animated title disclosure;
6
- * - a compact horizontal expand control shown only when the canvas overflows;
5
+ * - the widen button ↔ at the end of the diagram's caption line (a
6
+ * `.float-tool`, like a table's), shown only while the canvas overflows;
7
7
  * - an in-page expanded surface that moves the real figure (never a clone) to
8
8
  * `<body>`, overlays its placeholder, and follows that placeholder while the
9
9
  * page scrolls — the same spatial model as `TableOverflowExpander.astro`.
@@ -16,15 +16,9 @@
16
16
  ---
17
17
 
18
18
  <script>
19
- import {
20
- savePersistedOpenState,
21
- wirePersistedDetails,
22
- } from '../disclosure/persisted-disclosure';
23
- import { claimActiveSurface, releaseActiveSurface } from '../hover-panel';
19
+ import { closeTransientCards } from '../card-manager';
24
20
 
25
- const GROUP = 'diagram-overflow';
26
21
  const EDGE_PX = 16;
27
- const ANIMATION_MS = 220;
28
22
 
29
23
  interface Expansion {
30
24
  readonly figure: HTMLElement;
@@ -56,8 +50,8 @@
56
50
 
57
51
  function captionFor(figure: HTMLElement): string {
58
52
  return (
59
- figure.querySelector<HTMLElement>('.diagram__summary')?.textContent?.trim() ??
60
- 'this diagram'
53
+ figure.querySelector<HTMLElement>(':scope > figcaption[data-float-name]')?.dataset
54
+ .floatName ?? 'this diagram'
61
55
  );
62
56
  }
63
57
 
@@ -74,8 +68,6 @@
74
68
  : `Expand ${captionFor(figure)} horizontally`,
75
69
  );
76
70
  button.title = expanded ? 'Shrink diagram' : 'Expand diagram horizontally';
77
- const srText = button.querySelector<HTMLElement>('.sr-only');
78
- if (srText) srText.textContent = expanded ? 'Shrink diagram' : 'Expand diagram';
79
71
  }
80
72
 
81
73
  function updateScrollFocusability(scroll: HTMLElement, figure: HTMLElement): void {
@@ -90,15 +82,12 @@
90
82
 
91
83
  function evaluateOverflow(
92
84
  figure: HTMLElement,
93
- details: HTMLDetailsElement,
94
- toolbar: HTMLElement,
95
85
  button: HTMLButtonElement,
96
86
  scroll: HTMLElement,
97
87
  ): void {
98
88
  if (active?.figure === figure) return;
99
- const overflows = details.open && scroll.scrollWidth > scroll.clientWidth + 1;
89
+ const overflows = scroll.scrollWidth > scroll.clientWidth + 1;
100
90
  figure.dataset.diagramOverflow = String(overflows);
101
- toolbar.hidden = !overflows;
102
91
  button.hidden = !overflows;
103
92
  updateScrollFocusability(scroll, figure);
104
93
  }
@@ -130,8 +119,8 @@
130
119
  // restored.
131
120
  if (active && active.figure !== figure) return;
132
121
 
133
- claimActiveSurface(GROUP, () => {});
134
- releaseActiveSurface(GROUP);
122
+ // A hovered card would linger over the expanded diagram; pinned ones stay.
123
+ closeTransientCards();
135
124
 
136
125
  const neededWidth = scroll.scrollWidth;
137
126
  const rect = figure.getBoundingClientRect();
@@ -183,13 +172,13 @@
183
172
  80,
184
173
  );
185
174
  figure.style.maxHeight = `${maxHeight}px`;
186
- const titleHeight =
175
+ const captionHeight =
187
176
  figure
188
- .querySelector<HTMLElement>('.diagram__summary')
177
+ .querySelector<HTMLElement>(':scope > figcaption')
189
178
  ?.getBoundingClientRect().height ?? 0;
190
179
  figure.style.setProperty(
191
180
  '--diagram-expanded-body-height',
192
- `${Math.max(maxHeight - titleHeight, 40)}px`,
181
+ `${Math.max(maxHeight - captionHeight, 40)}px`,
193
182
  );
194
183
  updateScrollFocusability(scroll, figure);
195
184
  };
@@ -212,126 +201,22 @@
212
201
  window.addEventListener('resize', reposition, { passive: true });
213
202
  }
214
203
 
215
- function wireDisclosure(
216
- figure: HTMLElement,
217
- details: HTMLDetailsElement,
218
- body: HTMLElement,
219
- evaluate: () => void,
220
- ): void {
221
- const summary = details.querySelector<HTMLElement>(':scope > summary');
222
- if (!summary) return;
223
- const defaultOpen = details.open;
224
- const remember = details.dataset.rememberState === 'true';
225
- if (remember) {
226
- wirePersistedDetails(details, `diagram:${figure.id}`, defaultOpen);
227
- }
228
- details.dataset.state = details.open ? 'open' : 'closed';
229
-
230
- // An external/bookmarked deep link reveals the complete diagram. Later
231
- // in-page jumps retain the reader's persisted minimized state.
232
- if (location.hash === `#${figure.id}`) {
233
- details.open = true;
234
- details.dataset.state = 'open';
235
- }
236
-
237
- const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
238
- let finishTimer: number | null = null;
239
- let transitionEnd: ((event: TransitionEvent) => void) | null = null;
240
-
241
- const cancelAnimation = () => {
242
- if (finishTimer !== null) window.clearTimeout(finishTimer);
243
- finishTimer = null;
244
- if (transitionEnd) body.removeEventListener('transitionend', transitionEnd);
245
- transitionEnd = null;
246
- body.style.removeProperty('transition');
247
- body.style.removeProperty('overflow');
248
- };
249
-
250
- const setOpen = (open: boolean) => {
251
- cancelAnimation();
252
- const current = body.getBoundingClientRect().height;
253
- if (open) details.open = true;
254
- details.dataset.state = open ? 'open' : 'closed';
255
- if (remember) savePersistedOpenState(`diagram:${figure.id}`, open);
256
-
257
- if (reduceMotion.matches) {
258
- details.open = open;
259
- body.style.maxHeight = open ? '' : '0px';
260
- if (active?.figure === figure) {
261
- active.placeholder.style.height = `${figure.getBoundingClientRect().height}px`;
262
- }
263
- evaluate();
264
- active?.reposition();
265
- return;
266
- }
267
-
268
- body.style.transition = 'none';
269
- body.style.maxHeight = `${current}px`;
270
- body.style.overflow = 'hidden';
271
- void body.offsetHeight;
272
- body.style.removeProperty('transition');
273
-
274
- const expandedLimit = Number.parseFloat(
275
- getComputedStyle(figure).getPropertyValue(
276
- '--diagram-expanded-body-height',
277
- ),
278
- );
279
- const target = open
280
- ? Math.min(
281
- body.scrollHeight,
282
- Number.isFinite(expandedLimit) ? expandedLimit : Number.POSITIVE_INFINITY,
283
- )
284
- : 0;
285
- const finish = () => {
286
- cancelAnimation();
287
- details.open = open;
288
- body.style.maxHeight = open ? '' : '0px';
289
- if (active?.figure === figure) {
290
- active.placeholder.style.height = `${figure.getBoundingClientRect().height}px`;
291
- }
292
- evaluate();
293
- active?.reposition();
294
- };
295
- transitionEnd = (event) => {
296
- if (event.target === body && event.propertyName === 'max-height') finish();
297
- };
298
- body.addEventListener('transitionend', transitionEnd);
299
- finishTimer = window.setTimeout(finish, ANIMATION_MS + 60);
300
- requestAnimationFrame(() => {
301
- body.style.maxHeight = `${target}px`;
302
- });
303
- };
304
-
305
- summary.addEventListener('click', (event) => {
306
- event.preventDefault();
307
- setOpen(details.dataset.state !== 'open');
308
- });
309
- }
310
-
311
204
  function setup(): void {
312
205
  for (const figure of document.querySelectorAll<HTMLElement>(
313
206
  '.diagram[data-diagram-expandable]',
314
207
  )) {
315
208
  if (figure.dataset.diagramExpandReady === 'true') continue;
316
- const details = figure.querySelector<HTMLDetailsElement>(
317
- '[data-diagram-details]',
318
- );
319
- const body = figure.querySelector<HTMLElement>('[data-diagram-body]');
320
- const toolbar = figure.querySelector<HTMLElement>('[data-diagram-toolbar]');
321
209
  const button = figure.querySelector<HTMLButtonElement>('[data-diagram-expand]');
322
210
  const scroll = figure.querySelector<HTMLElement>('.diagram-scroll');
323
- if (!details || !body || !toolbar || !button || !scroll) continue;
211
+ if (!button || !scroll) continue;
324
212
  figure.dataset.diagramExpandReady = 'true';
325
213
 
326
- const evaluate = () =>
327
- evaluateOverflow(figure, details, toolbar, button, scroll);
214
+ const evaluate = () => evaluateOverflow(figure, button, scroll);
328
215
  button.addEventListener('click', () => {
329
216
  if (active?.figure === figure) collapseExpansion();
330
217
  else expand(figure, button, scroll, evaluate);
331
218
  });
332
219
  new ResizeObserver(evaluate).observe(scroll);
333
- details.addEventListener('toggle', evaluate);
334
- wireDisclosure(figure, details, body, evaluate);
335
220
  updateExpandButton(figure, button, false);
336
221
  evaluate();
337
222
  }
@@ -359,16 +244,8 @@
359
244
  box-shadow: var(--sl-shadow-lg);
360
245
  }
361
246
 
362
- .diagram-is-expanded .diagram__titlebar,
363
- .diagram-is-expanded .diagram__details {
364
- max-height: inherit;
365
- }
366
-
367
- .diagram-is-expanded .diagram__details {
368
- display: flex;
369
- flex-direction: column;
370
- }
371
-
247
+ /* The caption line stays below the graph; the body takes the height the
248
+ caption leaves (`--diagram-expanded-body-height`) and scrolls. */
372
249
  .diagram-is-expanded .diagram__body {
373
250
  min-height: 0;
374
251
  max-height: var(--diagram-expanded-body-height);
@@ -10,6 +10,24 @@
10
10
  * sticky-header `scroll-margin`.
11
11
  *
12
12
  * This script only adds what CSS and anchors cannot:
13
+ * - the chain icon copies the section's link: one click puts the page URL
14
+ * with the heading's named anchor (never its number) on the clipboard and
15
+ * says so beside the icon — "Link copied · § 1.1.2 · Velocity over time",
16
+ * the number read off the heading's `data-sec-number`
17
+ * (docs/plans/titles-and-containers.md, Q2/Q3). On a narrow screen, where
18
+ * headings show no number, this is where the number surfaces. The link is
19
+ * a named one (docs/plans/figures-and-short-captions.md, decision 9.3):
20
+ * beside the URL (`text/plain`), the clipboard holds an `<a>` reading
21
+ * "§ 1.1.2 Velocity over time" (`text/html`) where the browser has
22
+ * `ClipboardItem`, so a paste into a document keeps the name. The click
23
+ * still navigates, so the hash and the highlight below follow as before;
24
+ * a clipboard that is missing or refuses leaves exactly that navigation.
25
+ * On hover the icon says "Copy link", a title this script sets, since
26
+ * only with it does a click copy. A float's chain icon in its caption
27
+ * line (`a[data-copy-link]`, `FloatCaption.astro`) copies the same way,
28
+ * named by its number and short caption — "Link copied · Figure 2.2.1 ·
29
+ * Projectile trajectory explorer", pasted as "Figure 2.2.1, Projectile
30
+ * trajectory explorer"; its landing is `KeyedFloatEnhancer`'s;
13
31
  * - a smooth, header-aware scroll on an in-page jump;
14
32
  * - a brief highlight of the section body (the heading's following siblings up
15
33
  * to the next heading of equal or higher rank), so a deep link lands the
@@ -38,6 +56,7 @@
38
56
  <script>
39
57
  import { HEADINGS, sectionParts } from './section-parts';
40
58
  import { animatedScrollIntoView } from '../scroll-animation';
59
+ import { floatOf } from '../float-name';
41
60
 
42
61
  (() => {
43
62
  const root = document.querySelector('.sl-markdown-content');
@@ -90,14 +109,187 @@
90
109
  }
91
110
  };
92
111
 
112
+ // The link a reader can share: the page's canonical URL when it declares
113
+ // one (Starlight does once the site has a `site` URL), else this page
114
+ // without its query — and the heading's named anchor, never a number.
115
+ const shareableUrl = (id: string): string => {
116
+ const canonical = document.querySelector<HTMLLinkElement>(
117
+ 'link[rel="canonical"]',
118
+ )?.href;
119
+ const url = new URL(canonical || location.href);
120
+ if (!canonical) url.search = '';
121
+ url.hash = id;
122
+ return url.href;
123
+ };
124
+
125
+ // Screen readers hear the confirmation from one polite live region; the
126
+ // bubble beside the icon is for the eye only.
127
+ const status = document.createElement('div');
128
+ status.className = 'sr-only';
129
+ status.setAttribute('role', 'status');
130
+ document.body.append(status);
131
+
132
+ const COPIED_MS = 1800;
133
+ let bubble: HTMLElement | null = null;
134
+ let bubbleTimer = 0;
135
+
136
+ // What a link is called: its kind and number ("§ 1.1.2", "Figure 2.2.1")
137
+ // and its title — a heading's text, a check's or example set's topic, a
138
+ // float's short caption. Either can be missing.
139
+ interface LinkName {
140
+ readonly label: string;
141
+ readonly title: string;
142
+ }
143
+
144
+ const headingName = (heading: HTMLElement): LinkName => {
145
+ const { secNumber, checkNumber, exNumber, memoNumber } = heading.dataset;
146
+ const label = secNumber
147
+ ? `§ ${secNumber}`
148
+ : checkNumber
149
+ ? `Knowledge check ${checkNumber}`
150
+ : exNumber
151
+ ? `Example ${exNumber}`
152
+ : memoNumber
153
+ ? `Memo ${memoNumber}`
154
+ : '';
155
+ // A memo goes by its short caption where it has one, as its marker's
156
+ // tooltip does; a check's or example set's heading also draws its kind
157
+ // and number, so only its topic counts.
158
+ const short = memoNumber
159
+ ? heading.closest<HTMLElement>('[data-memo-short]')?.dataset.memoShort
160
+ : undefined;
161
+ const titled = heading.querySelector('.block-heading__title') ?? heading;
162
+ return {
163
+ label,
164
+ title: short || (titled.textContent?.replace(/\s+/g, ' ').trim() ?? ''),
165
+ };
166
+ };
167
+
168
+ const floatName = (link: HTMLElement): LinkName => {
169
+ const root = link.closest('figure');
170
+ return {
171
+ label: link.dataset.copyLink ?? '',
172
+ title: (root && floatOf(root)?.short) || '',
173
+ };
174
+ };
175
+
176
+ const confirmation = ({ label, title }: LinkName): string =>
177
+ ['Link copied', label, title].filter(Boolean).join(' · ');
178
+
179
+ // "§ 1.1.2 Velocity over time", "Figure 2.2.1, Projectile trajectory
180
+ // explorer": the text a pasted link reads.
181
+ const linkText = ({ label, title }: LinkName): string =>
182
+ !title ? label : !label ? title : label.startsWith('§') ? `${label} ${title}` : `${label}, ${title}`;
183
+
184
+ // `below`: the viewport y the bubble hangs from — under the icon, or
185
+ // under a heading's whole title when that wraps lower.
186
+ const confirmCopied = (link: HTMLElement, message: string, below: number) => {
187
+ status.textContent = message;
188
+
189
+ bubble?.remove();
190
+ window.clearTimeout(bubbleTimer);
191
+ const shown = document.createElement('span');
192
+ shown.className = 'sl-anchor-copied';
193
+ shown.setAttribute('aria-hidden', 'true');
194
+ shown.textContent = message;
195
+ document.body.append(shown);
196
+ // Under the title — a long one wraps below its icon — with its end edge
197
+ // on the icon's, and never past either side of the text column: a short
198
+ // heading's icon sits near the start.
199
+ const icon = link.getBoundingClientRect();
200
+ const column = root.getBoundingClientRect();
201
+ const width = shown.offsetWidth;
202
+ const left = Math.max(
203
+ column.left,
204
+ Math.min(icon.right - width, column.right - width),
205
+ );
206
+ // Page coordinates, so the bubble travels with the heading while the
207
+ // click's own navigation scrolls it into place.
208
+ shown.style.left = `${Math.round(left + window.scrollX)}px`;
209
+ shown.style.top = `${Math.round(below + window.scrollY + 4)}px`;
210
+ bubble = shown;
211
+ bubbleTimer = window.setTimeout(() => {
212
+ shown.remove();
213
+ if (bubble === shown) bubble = null;
214
+ }, COPIED_MS);
215
+ };
216
+
217
+ // The URL as plain text, and — where the browser can hold two flavours —
218
+ // a named link beside it, for a paste into a document.
219
+ const copyLink = (id: string, name: LinkName, confirm: () => void) => {
220
+ const clipboard = navigator.clipboard;
221
+ if (!clipboard?.writeText) return;
222
+ const url = shareableUrl(id);
223
+ const text = linkText(name);
224
+ let written: Promise<void>;
225
+ if (text && typeof ClipboardItem === 'function' && typeof clipboard.write === 'function') {
226
+ const named = document.createElement('a');
227
+ named.href = url;
228
+ named.textContent = text;
229
+ written = clipboard.write([
230
+ new ClipboardItem({
231
+ 'text/plain': new Blob([url], { type: 'text/plain' }),
232
+ 'text/html': new Blob([named.outerHTML], { type: 'text/html' }),
233
+ }),
234
+ ]);
235
+ } else {
236
+ written = clipboard.writeText(url);
237
+ }
238
+ written.then(confirm, () => {
239
+ /* Refused: the navigation still happens, as it always has. */
240
+ });
241
+ };
242
+
243
+ // A heading's chain icon says on hover that a click copies, as a float's
244
+ // does in its own markup (`FloatCaption.astro`). The markup cannot say it
245
+ // here: without this script, or without a clipboard, the icon only
246
+ // navigates. So the title goes on exactly the icons the click handler
247
+ // below copies from — every heading's, including the ones
248
+ // `BlockHeading.astro` and `Memo.astro` draw.
249
+ if (typeof navigator.clipboard?.writeText === 'function') {
250
+ for (const link of root.querySelectorAll<HTMLElement>('a.sl-anchor-link')) {
251
+ const href = link.getAttribute('href') || '';
252
+ if (href.startsWith('#') && headingForId(href.slice(1))) {
253
+ link.title = 'Copy link';
254
+ }
255
+ }
256
+ }
257
+
93
258
  root.addEventListener('click', (event) => {
94
259
  const origin = event.target instanceof Element ? event.target : null;
95
- const link = origin?.closest('a.sl-anchor-link');
260
+
261
+ // A float's chain icon: copy, confirm, and let the click navigate.
262
+ const floatLink = origin?.closest<HTMLElement>('a[data-copy-link]');
263
+ const floatHref = floatLink?.getAttribute('href') || '';
264
+ if (floatLink && floatHref.startsWith('#')) {
265
+ const name = floatName(floatLink);
266
+ copyLink(floatHref.slice(1), name, () =>
267
+ confirmCopied(
268
+ floatLink,
269
+ confirmation(name),
270
+ floatLink.getBoundingClientRect().bottom,
271
+ ),
272
+ );
273
+ return;
274
+ }
275
+
276
+ const link = origin?.closest<HTMLElement>('a.sl-anchor-link');
96
277
  if (!link) return;
97
278
  const href = link.getAttribute('href') || '';
98
279
  if (!href.startsWith('#')) return;
99
280
  const heading = headingForId(href.slice(1));
100
281
  if (!heading) return;
282
+ const name = headingName(heading);
283
+ copyLink(heading.id, name, () =>
284
+ confirmCopied(
285
+ link,
286
+ confirmation(name),
287
+ Math.max(
288
+ link.getBoundingClientRect().bottom,
289
+ heading.getBoundingClientRect().bottom,
290
+ ),
291
+ ),
292
+ );
101
293
  // Only the already-on-this-anchor click needs handling here — see
102
294
  // `KeyedFloatEnhancer.astro`. Every other click is left to the browser's
103
295
  // own fragment navigation, picked up below via `hashchange`.
@@ -20,6 +20,7 @@
20
20
  * activated again — the one case a plain anchor click does nothing for,
21
21
  * since the browser won't navigate to a fragment that's already current;
22
22
  * - moving keyboard focus onto the float so screen-reader users land on it;
23
+ * - making a display equation that overflows its column keyboard-scrollable;
23
24
  * - for an equation only, marking its number permanently visible
24
25
  * (`.eq-number-revealed`, styled in `global.css`) once the reader has
25
26
  * actually arrived at it, so it doesn't fade back down once the
@@ -36,9 +37,31 @@
36
37
  *
37
38
  * Everything here is gated on `prefers-reduced-motion` and degrades to the
38
39
  * native anchor behaviour if it throws.
40
+ *
41
+ * **Number aliases** (docs/plans/titles-and-containers.md, Q4). With the
42
+ * course's `numbering.aliases` on (the `aliases` prop, from the manifest), a
43
+ * fragment a reader types in number form — `#sec-1.1.2`, `#fig-2.2.1`,
44
+ * `#eq-2.2.4`, `#tab-…`, `#dia-…`, `#check-…`, `#ex-…` — is rewritten to the named
45
+ * anchor of the element that prints that number here. The map is the page
46
+ * itself: every numbered element already carries `data-‹kind›-number` beside
47
+ * its `id`, so an alias resolves exactly when the reader can see its number
48
+ * on this page, and never disagrees with it. Inbound only: nothing the site
49
+ * shows or copies is a number form, and an alias is only as stable as the
50
+ * content version the About panel names. This script runs before
51
+ * `CollapsibleSections` and `HeadingAnchorEnhancer` (see `LessonFooter`), so
52
+ * on arrival the rewrite is in place before either reads the hash — an alias
53
+ * lands exactly like a named deep link.
39
54
  */
55
+ interface Props {
56
+ /** `manifest.numbering.aliases`: resolve number-form fragments. */
57
+ aliases?: boolean;
58
+ }
59
+
60
+ const { aliases = false } = Astro.props;
40
61
  ---
41
62
 
63
+ {aliases && <div hidden data-explico-number-aliases />}
64
+
42
65
  <script>
43
66
  import { animatedScrollIntoView } from '../scroll-animation';
44
67
 
@@ -46,6 +69,21 @@
46
69
  const root = document.querySelector('.sl-markdown-content');
47
70
  if (!root) return;
48
71
 
72
+ // `#sec-1.1.2` → the element printing that number, if this page has one.
73
+ const ALIAS = /^#(sec|eq|tab|fig|dia|check|ex)-(\d+(?:\.\d+)+)$/;
74
+ const aliasesOn = document.querySelector('[data-explico-number-aliases]') !== null;
75
+ const aliasTarget = (hash: string): HTMLElement | null => {
76
+ const match = aliasesOn ? ALIAS.exec(hash) : null;
77
+ if (!match) return null;
78
+ return root.querySelector<HTMLElement>(
79
+ `[id][data-${match[1]}-number="${match[2]}"]`,
80
+ );
81
+ };
82
+ // On arrival: rewrite without an event, so every enhancer's own
83
+ // first-hash handling sees the named anchor.
84
+ const arrived = aliasTarget(location.hash);
85
+ if (arrived) history.replaceState(history.state, '', `#${arrived.id}`);
86
+
49
87
  interface Kind {
50
88
  readonly prefix: string;
51
89
  readonly triggerSelector: string;
@@ -60,21 +98,23 @@
60
98
  numberSelector: '.keyed-equation__number',
61
99
  reveal: true,
62
100
  },
101
+ // A float's caption line links to it twice: its number, and the chain
102
+ // icon beside it (which also copies the link, `HeadingAnchorEnhancer`).
63
103
  {
64
104
  prefix: '#tab-',
65
- triggerSelector: 'a.table-ref, a.keyed-table__number',
105
+ triggerSelector: 'a.table-ref, a.keyed-table__number, a.float-caption__anchor',
66
106
  numberSelector: '.keyed-table__number',
67
107
  reveal: false,
68
108
  },
69
109
  {
70
110
  prefix: '#fig-',
71
- triggerSelector: 'a.figure-ref, a.keyed-figure__number',
111
+ triggerSelector: 'a.figure-ref, a.keyed-figure__number, a.float-caption__anchor',
72
112
  numberSelector: '.keyed-figure__number',
73
113
  reveal: false,
74
114
  },
75
115
  {
76
116
  prefix: '#dia-',
77
- triggerSelector: 'a.diagram-ref, a.diagram__title-anchor',
117
+ triggerSelector: 'a.diagram-ref, a.diagram__number, a.float-caption__anchor',
78
118
  numberSelector: '.diagram__number',
79
119
  reveal: false,
80
120
  },
@@ -178,12 +218,14 @@
178
218
 
179
219
  // A tooltip for the gesture above, and only for the part of the equation
180
220
  // it actually applies to — an explained symbol has its own hover card
181
- // (`NotationLayer.astro`) and needs no tooltip layered on top of it.
221
+ // (`NotationLayer.astro`) and needs no tooltip layered on top of it. It
222
+ // names the equation by its `\caption{}` short caption when it has one —
223
+ // "Copy LaTeX — Range formula", the same title as its copy button.
182
224
  // `mouseover`/`mouseout` (not `mouseenter`/`mouseleave`) because they
183
225
  // bubble, which delegation on `root` needs.
184
226
  root.addEventListener('mouseover', (event) => {
185
227
  const origin = event.target instanceof Element ? event.target : null;
186
- if (!origin || origin.getAttribute('title') === 'Copy LaTeX') return;
228
+ if (!origin || origin.getAttribute('title')?.startsWith('Copy LaTeX')) return;
187
229
  if (
188
230
  origin.closest(
189
231
  '[data-notation-key], [data-eq-copy], a.keyed-equation__number',
@@ -191,18 +233,67 @@
191
233
  ) {
192
234
  return;
193
235
  }
194
- if (!origin.closest('.keyed-equation, .inline-equation')) return;
195
- origin.setAttribute('title', 'Copy LaTeX');
236
+ const equation = origin.closest<HTMLElement>('.keyed-equation, .inline-equation');
237
+ if (!equation) return;
238
+ const short = equation.dataset.eqShort;
239
+ origin.setAttribute('title', short ? `Copy LaTeX — ${short}` : 'Copy LaTeX');
240
+ origin.setAttribute('data-copy-hint', '');
196
241
  });
197
242
 
198
243
  root.addEventListener('mouseout', (event) => {
199
244
  const origin = event.target instanceof Element ? event.target : null;
200
- if (origin?.getAttribute('title') === 'Copy LaTeX') {
245
+ // Only a title this script added — the copy button's own static
246
+ // `title` must survive the pointer leaving it (or crossing onto its icon).
247
+ if (origin?.hasAttribute('data-copy-hint')) {
201
248
  origin.removeAttribute('title');
249
+ origin.removeAttribute('data-copy-hint');
202
250
  }
203
251
  });
204
252
 
205
- window.addEventListener('hashchange', () => {
253
+ // A display equation wider than the column scrolls sideways
254
+ // (`.katex-display { overflow-x: auto }` in `global.css`) — on a phone,
255
+ // with a numbered equation's controls beside it, that is common. A
256
+ // scrollable region must be keyboard-reachable (WCAG 2.1.1, axe's
257
+ // `scrollable-region-focusable`), so, the same way
258
+ // `TableOverflowExpander.astro` and `DiagramEnhancer.astro` treat their
259
+ // scrollers, it gets a `tabindex` and a name only while it overflows. A
260
+ // `ResizeObserver` catches every case that changes that: first layout, a
261
+ // viewport change, and a collapsed section opening (no layout box while
262
+ // closed, so no overflow either).
263
+ const updateEquationScroll = (display: HTMLElement) => {
264
+ if (display.scrollWidth > display.clientWidth + 1) {
265
+ const number = display
266
+ .closest<HTMLElement>('.keyed-equation')
267
+ ?.dataset.eqNumber;
268
+ display.tabIndex = 0;
269
+ display.setAttribute('role', 'group');
270
+ display.setAttribute(
271
+ 'aria-label',
272
+ number ? `Scrollable equation (${number})` : 'Scrollable equation',
273
+ );
274
+ } else {
275
+ display.removeAttribute('tabindex');
276
+ display.removeAttribute('role');
277
+ display.removeAttribute('aria-label');
278
+ }
279
+ };
280
+ const equationResize = new ResizeObserver((entries) => {
281
+ for (const entry of entries) updateEquationScroll(entry.target as HTMLElement);
282
+ });
283
+ for (const display of root.querySelectorAll<HTMLElement>('.katex-display')) {
284
+ equationResize.observe(display);
285
+ }
286
+
287
+ window.addEventListener('hashchange', (event) => {
288
+ // A number form typed into the address bar of a loaded page: navigate
289
+ // on to the named anchor. The listeners registered after this one
290
+ // (sections, headings) see only that second navigation, not this one.
291
+ const aliased = aliasTarget(location.hash);
292
+ if (aliased) {
293
+ event.stopImmediatePropagation();
294
+ location.replace(`#${aliased.id}`);
295
+ return;
296
+ }
206
297
  const kind = kindForHash(location.hash);
207
298
  if (!kind) return;
208
299
  activate(document.getElementById(location.hash.slice(1)), true, kind);