explico 0.8.0 → 0.11.1

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 (230) hide show
  1. package/CHANGELOG.md +1257 -1
  2. package/README.md +60 -15
  3. package/dist/compiler/astro.d.ts +6 -1
  4. package/dist/compiler/cli.d.ts +46 -2
  5. package/dist/compiler/cli.js +70 -4
  6. package/dist/compiler/collections.d.ts +35 -2
  7. package/dist/compiler/collections.js +68 -5
  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 +4 -1
  15. package/dist/compiler/logging.js +4 -1
  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 +3 -2
  27. package/dist/reference/diagrams-validate.d.ts +7 -2
  28. package/dist/reference/diagrams-validate.js +9 -1
  29. package/dist/reference/equations-validate.d.ts +8 -5
  30. package/dist/reference/equations-validate.js +1 -0
  31. package/dist/reference/equations.d.mts +60 -31
  32. package/dist/reference/equations.mjs +83 -43
  33. package/dist/reference/examples-validate.d.ts +31 -0
  34. package/dist/reference/examples-validate.js +48 -0
  35. package/dist/reference/figures-validate.d.ts +1 -1
  36. package/dist/reference/figures-validate.js +4 -1
  37. package/dist/reference/folds-validate.d.ts +33 -0
  38. package/dist/reference/folds-validate.js +32 -0
  39. package/dist/reference/gate-abbreviations.d.ts +3 -1
  40. package/dist/reference/gate-abbreviations.js +1 -0
  41. package/dist/reference/gate-course-rules.d.ts +32 -0
  42. package/dist/reference/gate-course-rules.js +85 -0
  43. package/dist/reference/gate-heading-length.d.ts +5 -2
  44. package/dist/reference/gate-heading-length.js +45 -21
  45. package/dist/reference/gate-heading-structure.d.ts +41 -0
  46. package/dist/reference/gate-heading-structure.js +113 -0
  47. package/dist/reference/gate-math-punctuation.d.ts +71 -0
  48. package/dist/reference/gate-math-punctuation.js +251 -0
  49. package/dist/reference/gate-math.d.ts +22 -6
  50. package/dist/reference/gate-math.js +42 -8
  51. package/dist/reference/gate-part-pages.d.ts +50 -0
  52. package/dist/reference/gate-part-pages.js +118 -0
  53. package/dist/reference/gate-prose-notation.d.ts +21 -11
  54. package/dist/reference/gate-prose-notation.js +25 -7
  55. package/dist/reference/katex-delimiters.mjs +20 -7
  56. package/dist/reference/keyed-float-validate.d.ts +27 -10
  57. package/dist/reference/keyed-float-validate.js +15 -1
  58. package/dist/reference/keyed-jsx-floats.d.mts +11 -5
  59. package/dist/reference/keyed-jsx-floats.mjs +73 -20
  60. package/dist/reference/knowledge-checks-validate.d.ts +1 -1
  61. package/dist/reference/knowledge-checks.d.mts +3 -1
  62. package/dist/reference/knowledge-checks.mjs +9 -4
  63. package/dist/reference/lab-math-scope.d.ts +1 -0
  64. package/dist/reference/lab-math-slot.d.ts +34 -0
  65. package/dist/reference/lab-math-slot.js +6 -0
  66. package/dist/reference/math-glyphs.d.mts +80 -4
  67. package/dist/reference/math-glyphs.mjs +572 -37
  68. package/dist/reference/page-notation.d.ts +6 -1
  69. package/dist/reference/raw-floats-validate.js +1 -1
  70. package/dist/reference/raw-floats.mjs +1 -1
  71. package/dist/reference/rehype-heading-anchors.d.mts +14 -0
  72. package/dist/reference/rehype-heading-anchors.mjs +116 -17
  73. package/dist/reference/rehype-table-header-labels.d.mts +6 -1
  74. package/dist/reference/rehype-table-header-labels.mjs +21 -3
  75. package/dist/reference/remark-asides.d.mts +3 -0
  76. package/dist/reference/remark-asides.mjs +180 -0
  77. package/dist/reference/remark-memo.d.mts +3 -1
  78. package/dist/reference/remark-memo.mjs +42 -11
  79. package/dist/reference/remark-notation.d.mts +60 -10
  80. package/dist/reference/remark-notation.mjs +387 -36
  81. package/dist/reference/render-lab-math.d.ts +4 -26
  82. package/dist/reference/render-lab-math.js +4 -4
  83. package/dist/reference/sections-validate.d.ts +27 -0
  84. package/dist/reference/sections-validate.js +41 -0
  85. package/dist/reference/sections.d.mts +97 -0
  86. package/dist/reference/sections.mjs +306 -0
  87. package/dist/reference/tables-validate.d.ts +1 -1
  88. package/dist/reference/types.d.ts +17 -1
  89. package/dist/reference/unit-words.d.ts +37 -0
  90. package/dist/reference/unit-words.js +170 -0
  91. package/dist/rules/catalog.d.ts +176 -0
  92. package/dist/rules/catalog.js +983 -0
  93. package/dist/rules/course-rules.d.ts +115 -0
  94. package/dist/rules/course-rules.js +292 -0
  95. package/dist/rules/course-rules.schema.json +138 -0
  96. package/dist/rules/in-effect.d.ts +53 -0
  97. package/dist/rules/in-effect.js +142 -0
  98. package/dist/rules/rules.schema.json +1072 -0
  99. package/dist/rules/settings.d.ts +3 -0
  100. package/dist/rules/settings.js +290 -0
  101. package/dist/rules/waivers.d.ts +53 -0
  102. package/dist/rules/waivers.js +132 -0
  103. package/dist/session/PreferenceStore.d.ts +5 -0
  104. package/dist/session/PreferenceStore.js +9 -0
  105. package/docs/VERSIONING.md +5 -3
  106. package/docs/authoring/README.md +232 -40
  107. package/docs/authoring/agents.md +55 -9
  108. package/docs/authoring/components.md +178 -22
  109. package/docs/authoring/migrating.md +1152 -2
  110. package/docs/authoring/notation.md +576 -322
  111. package/docs/authoring/prose.md +551 -0
  112. package/docs/authoring/retired.md +35 -0
  113. package/docs/authoring/structure-rules.md +422 -40
  114. package/docs/authoring/structure.md +445 -62
  115. package/docs/authoring/versioning.md +20 -28
  116. package/docs/authoring/visualizations.md +142 -20
  117. package/package.json +2 -2
  118. package/src/compiler/cli.ts +116 -6
  119. package/src/compiler/collections.ts +111 -7
  120. package/src/compiler/course-config.ts +273 -34
  121. package/src/compiler/knowledge-version.ts +1 -1
  122. package/src/compiler/lesson-derivation.ts +54 -15
  123. package/src/compiler/logging.ts +8 -2
  124. package/src/compiler/manifest.ts +573 -104
  125. package/src/components/BlockHeading.astro +113 -0
  126. package/src/components/CourseContents.astro +221 -0
  127. package/src/components/CurriculumMap.astro +5 -6
  128. package/src/components/FloatIndex.astro +200 -0
  129. package/src/components/HoverPanel.astro +431 -79
  130. package/src/components/PartBridge.astro +27 -0
  131. package/src/components/PartOverview.astro +401 -0
  132. package/src/components/PartScope.astro +27 -0
  133. package/src/components/abbreviation/AbbreviationLayer.astro +49 -51
  134. package/src/components/assessments/AssessmentRunner.tsx +8 -5
  135. package/src/components/assessments/AssessmentSet.astro +23 -26
  136. package/src/components/block-heading.ts +146 -0
  137. package/src/components/card-arrow.ts +243 -0
  138. package/src/components/card-drag.ts +631 -0
  139. package/src/components/card-jump.ts +45 -0
  140. package/src/components/card-manager.ts +1589 -0
  141. package/src/components/card-memory.ts +177 -0
  142. package/src/components/card-placement.ts +504 -0
  143. package/src/components/card-side.ts +336 -0
  144. package/src/components/card-travel.ts +197 -0
  145. package/src/components/citation/CitationLayer.astro +77 -76
  146. package/src/components/diagram/Diagram.astro +161 -316
  147. package/src/components/disclosure/course-disclosure.ts +24 -0
  148. package/src/components/disclosure/hover-preview.ts +122 -42
  149. package/src/components/disclosure/persisted-disclosure.ts +3 -3
  150. package/src/components/disclosure/print-as-shown.ts +252 -0
  151. package/src/components/examples/CompactExample.astro +3 -1
  152. package/src/components/examples/CompactExamples.astro +101 -94
  153. package/src/components/figure/Figure.astro +174 -274
  154. package/src/components/figure/FloatCaption.astro +87 -0
  155. package/src/components/figure/figure-state.ts +58 -0
  156. package/src/components/figure/figure-tools.ts +87 -0
  157. package/src/components/float-name.ts +101 -0
  158. package/src/components/fold/Fold.astro +125 -0
  159. package/src/components/fold/fold-id.ts +40 -0
  160. package/src/components/memo/Memo.astro +162 -231
  161. package/src/components/memo/ShortNote.astro +6 -6
  162. package/src/components/notation/NotationFormula.astro +5 -1
  163. package/src/components/notation/NotationGlossary.astro +3 -4
  164. package/src/components/notation/NotationLayer.astro +169 -162
  165. package/src/components/notation/NotationMath.astro +10 -1
  166. package/src/components/reference/FloatRefLayer.astro +485 -423
  167. package/src/components/starlight/AboutPanel.astro +5 -4
  168. package/src/components/starlight/CollapsibleSections.astro +207 -93
  169. package/src/components/starlight/DiagramEnhancer.astro +15 -138
  170. package/src/components/starlight/HeadingAnchorEnhancer.astro +193 -1
  171. package/src/components/starlight/KeyedFloatEnhancer.astro +100 -9
  172. package/src/components/starlight/LayoutHeader.astro +298 -19
  173. package/src/components/starlight/LayoutPageSidebar.astro +99 -12
  174. package/src/components/starlight/LayoutPageTitle.astro +120 -0
  175. package/src/components/starlight/LayoutSidebar.astro +229 -11
  176. package/src/components/starlight/LessonExportActions.astro +53 -72
  177. package/src/components/starlight/LessonFooter.astro +26 -4
  178. package/src/components/starlight/section-parts.ts +15 -2
  179. package/src/components/starlight/section-summary.ts +181 -0
  180. package/src/components/table/Table.astro +95 -76
  181. package/src/components/table/TableOverflowExpander.astro +65 -28
  182. package/src/content-config.ts +40 -4
  183. package/src/reference/base-accents.mjs +31 -0
  184. package/src/reference/base-roman-labels.mjs +2 -2
  185. package/src/reference/consistency.ts +176 -5
  186. package/src/reference/delimiter-size.ts +3 -2
  187. package/src/reference/diagrams-validate.ts +17 -1
  188. package/src/reference/equations-validate.ts +10 -5
  189. package/src/reference/equations.mjs +83 -43
  190. package/src/reference/examples-validate.ts +97 -0
  191. package/src/reference/figures-validate.ts +6 -1
  192. package/src/reference/folds-validate.ts +65 -0
  193. package/src/reference/gate-abbreviations.ts +1 -0
  194. package/src/reference/gate-course-rules.ts +134 -0
  195. package/src/reference/gate-heading-length.ts +66 -35
  196. package/src/reference/gate-heading-structure.ts +193 -0
  197. package/src/reference/gate-math-punctuation.ts +381 -0
  198. package/src/reference/gate-math.ts +69 -10
  199. package/src/reference/gate-part-pages.ts +217 -0
  200. package/src/reference/gate-prose-notation.ts +59 -12
  201. package/src/reference/katex-delimiters.mjs +20 -7
  202. package/src/reference/keyed-float-validate.ts +45 -11
  203. package/src/reference/keyed-jsx-floats.mjs +73 -20
  204. package/src/reference/knowledge-checks-validate.ts +1 -1
  205. package/src/reference/knowledge-checks.mjs +9 -4
  206. package/src/reference/lab-math-slot.ts +36 -0
  207. package/src/reference/math-glyphs.mjs +572 -37
  208. package/src/reference/raw-floats-validate.ts +1 -1
  209. package/src/reference/raw-floats.mjs +1 -1
  210. package/src/reference/rehype-heading-anchors.mjs +116 -17
  211. package/src/reference/rehype-table-header-labels.mjs +21 -3
  212. package/src/reference/remark-asides.mjs +180 -0
  213. package/src/reference/remark-memo.mjs +42 -11
  214. package/src/reference/remark-notation.mjs +387 -36
  215. package/src/reference/render-lab-math.ts +16 -31
  216. package/src/reference/sections-validate.ts +81 -0
  217. package/src/reference/sections.mjs +306 -0
  218. package/src/reference/tables-validate.ts +2 -1
  219. package/src/reference/types.ts +17 -0
  220. package/src/reference/unit-words.ts +233 -0
  221. package/src/rules/catalog.ts +1298 -0
  222. package/src/rules/course-rules.ts +456 -0
  223. package/src/rules/in-effect.ts +235 -0
  224. package/src/rules/settings.ts +448 -0
  225. package/src/rules/waivers.ts +249 -0
  226. package/src/session/PreferenceStore.ts +15 -0
  227. package/src/styles/global.css +1467 -163
  228. package/src/styles/surfaces.css +182 -0
  229. package/src/components/hover-panel.ts +0 -339
  230. package/src/components/popup-stack.ts +0 -345
@@ -1,9 +1,9 @@
1
1
  ---
2
2
  /**
3
3
  * Progressive enhancement for `[[eq-key]]` / `[[tab-key]]` / `[[fig-key]]` /
4
- * `[[dia-key]]`
4
+ * `[[dia-key]]` / `[[sec-slug]]` / `[[ex-key]]` / `[[check-key]]`
5
5
  * references in prose (F4, extended F-tables-figures) — one hover/pin/jump
6
- * engine shared by all four keyed floats. This generalizes the original
6
+ * engine shared by the four keyed floats and sections. This generalizes the original
7
7
  * equation-only `EquationRefLayer`; the positioning, mutual-exclusion, and
8
8
  * pin/jump/Escape mechanics are unchanged, parameterized over `KIND_CONFIG`
9
9
  * below so a hover on any of the three trigger classes is handled the same
@@ -17,9 +17,19 @@
17
17
  * it points to:
18
18
  *
19
19
  * - if the referenced float is on the current page and already fully on
20
- * screen, it is highlighted in place instead of opening a card;
21
- * - otherwise a small card floats near the reference showing its number,
22
- * caption (table/figure), content, and a "Jump to …" link.
20
+ * screen, it is highlighted in place instead of opening a card, and a
21
+ * click navigates to it natively (highlight mode);
22
+ * - otherwise a card floats near the reference showing its number,
23
+ * caption (table/figure), content, and a "Go to …" link, whose arrow
24
+ * says where it lands (`../card-jump.ts`): ↑ / ↓ on this page, → with the
25
+ * lesson's name on another one. A click on the
26
+ * reference pins the card, a second one closes it (the card manager's
27
+ * contract, `../card-manager.ts`); the card's own links navigate.
28
+ *
29
+ * A float with a short caption — an equation's `\caption{…}`, the `short`
30
+ * prop of a table, figure or diagram — heads the card with it
31
+ * (docs/plans/titles-and-containers.md Part 3), read off the target's
32
+ * `data-‹kind›-short`.
23
33
  *
24
34
  * What the card's content shows differs by kind:
25
35
  * - equation: the KaTeX formula, cloned from the target (unchanged from the
@@ -30,27 +40,36 @@
30
40
  * `previewable={false}` shows its caption and number only, since a
31
41
  * cloned copy would be an inert, non-hydrated, id-colliding duplicate,
32
42
  * not a working preview.
43
+ * - section: the heading's title and the section's first paragraph, with
44
+ * `§ 1.1.2` as the jump link when the heading shows a number.
45
+ * - worked-example set and knowledge check (docs/plans/titles-and-containers.md
46
+ * Part 6): the block's topical title — not its badge — and one line of
47
+ * what it holds (the examples' names, the number of questions), with
48
+ * `Example 1.1.1` / `Knowledge check 1.1.1` as the jump link when the
49
+ * heading shows a number.
33
50
  *
34
51
  * Cross-page references (`[[lesson#eq-key]]` etc.) get the same card: their
35
- * `href` is a real path to another lesson page, so `showCrossPage` fetches
36
- * and parses that page once (cached), pulls the `#eq-…`/`#tab-…`/`#fig-…`
37
- * node out of it, and fills the card exactly as above (cloning across
38
- * documents via `importNode`). The card always opens — there is nothing in
39
- * this document to highlight in place — and clicking the reference navigates
40
- * to the other page, where `KeyedFloatEnhancer.astro` handles the landing.
52
+ * `href` is a real path to another lesson page, so the card's `render`
53
+ * fetches and parses that page once (cached), pulls the
54
+ * `#eq-…`/`#tab-…`/`#fig-…` node out of it, and fills the card exactly as
55
+ * above (cloning across documents via `importNode`). There is nothing in this
56
+ * document to highlight in place, so the card always opens; its link goes to
57
+ * the other page, where `KeyedFloatEnhancer.astro` handles the landing. A
58
+ * standalone export has no other pages to fetch: there a cross-page
59
+ * reference stays a plain link.
41
60
  *
42
- * This is a single-panel engine, not the notation layer's stack
43
- * (`popup-stack.ts`): only one card is ever open at a time — now shared
44
- * across all three kinds, not just equations — which is also why it's
45
- * hand-rolled here rather than built on `createHoverPanel`.
46
- * `claimActiveSurface`/`releaseActiveSurface` are still reused so an open
47
- * float card and an open citation or notation popup remain mutually
48
- * exclusive.
61
+ * One card per referenced float, several at once; the card manager places
62
+ * them clear of every other open card, and a symbol inside a card opens its
63
+ * notation card as a child of it.
49
64
  */
50
65
  import HoverPanel from '../HoverPanel.astro';
51
66
  ---
52
67
 
53
68
  <HoverPanel id="float-ref-panel" label="Reference preview" data-float-ref-panel>
69
+ <p class="float-ref-panel-title" slot="heading">
70
+ <span class="float-ref-panel-kind" data-panel-kind></span>
71
+ <span class="float-ref-panel-heading" data-panel-heading hidden></span>
72
+ </p>
54
73
  <div class="float-ref-panel-body">
55
74
  <div class="float-ref-panel-main">
56
75
  <div class="float-ref-panel-content" data-panel-content></div>
@@ -58,16 +77,15 @@ import HoverPanel from '../HoverPanel.astro';
58
77
  </div>
59
78
  <p class="float-ref-panel-caption" data-panel-caption></p>
60
79
  <a class="float-ref-panel-jump" data-panel-jump
61
- >Jump to <span data-panel-jump-label></span
62
- ><span aria-hidden="true"> ↗</span></a
80
+ >Go to <span data-panel-jump-label></span
81
+ ><span aria-hidden="true" data-jump-arrow> →</span></a
63
82
  >
64
83
  </div>
65
84
  </HoverPanel>
66
85
 
67
86
  <script>
68
- import { claimActiveSurface, releaseActiveSurface } from '../hover-panel';
69
-
70
- const GROUP = 'float-ref';
87
+ import { cloneCardShell, isKept, registerCardSource } from '../card-manager';
88
+ import { wireJumpArrow } from '../card-jump';
71
89
 
72
90
  interface KindConfig {
73
91
  readonly triggerClass: string;
@@ -82,6 +100,10 @@ import HoverPanel from '../HoverPanel.astro';
82
100
  readonly revealOnPin: boolean;
83
101
  fillContent(target: HTMLElement, contentEl: HTMLElement): void;
84
102
  captionFor(target: HTMLElement): string;
103
+ /** The target's `data-*` key holding its short caption, if the kind has one. */
104
+ readonly shortDataKey?: string;
105
+ /** The jump link's text, when not `(n)` / `‹Kind› n`; empty hides it. */
106
+ numberLabel?(number: string): string;
85
107
  }
86
108
 
87
109
  // `importNode`, not `cloneNode`: `target` may come from a *different*
@@ -154,22 +176,16 @@ import HoverPanel from '../HoverPanel.astro';
154
176
  .forEach((button) => button.remove());
155
177
  }
156
178
 
157
- let diagramCloneSerial = 0;
179
+ let cloneSerial = 0;
158
180
 
159
181
  /**
160
- * A diagram preview contains SVG fragment references (`url(#marker-id)`).
161
- * Prefix every cloned id and rewrite those references so the preview cannot
162
- * steal the moved/original diagram's arrow markers — or vice versa.
182
+ * A picture's copy keeps its SVG fragment references (`url(#marker-id)`, a
183
+ * clip path) working without stealing the original's: prefix every cloned
184
+ * id and rewrite the references to it, so neither the card nor the page
185
+ * resolves `#id` to the other's element.
163
186
  */
164
- function cloneDiagramBody(target: HTMLElement): HTMLElement | null {
165
- const clone = cloneBody(target, 'diagram-scroll');
166
- if (!clone) return null;
167
- clone.removeAttribute('id');
168
- clone.removeAttribute('tabindex');
169
- clone.removeAttribute('aria-labelledby');
170
- clone.setAttribute('aria-label', 'Diagram preview');
171
-
172
- const prefix = `float-ref-diagram-${++diagramCloneSerial}-`;
187
+ function isolateIds(clone: HTMLElement, kind: string): void {
188
+ const prefix = `float-ref-${kind}-${++cloneSerial}-`;
173
189
  const ids = new Map<string, string>();
174
190
  for (const element of clone.querySelectorAll<HTMLElement>('[id]')) {
175
191
  const oldId = element.id;
@@ -200,10 +216,101 @@ import HoverPanel from '../HoverPanel.astro';
200
216
  if (rewritten !== value) element.setAttribute(attribute, rewritten);
201
217
  }
202
218
  }
219
+ }
220
+
221
+ function cloneDiagramBody(target: HTMLElement): HTMLElement | null {
222
+ const clone = cloneBody(target, 'diagram-scroll');
223
+ if (!clone) return null;
224
+ clone.removeAttribute('id');
225
+ clone.removeAttribute('tabindex');
226
+ clone.removeAttribute('aria-labelledby');
227
+ clone.setAttribute('aria-label', 'Diagram preview');
228
+ isolateIds(clone, 'diagram');
229
+ return clone;
230
+ }
231
+
232
+ /**
233
+ * A figure's picture, as a still copy (docs/plans/figures-and-short-captions.md,
234
+ * decision 12). The body is the picture alone — a lab's tools sit in a tray
235
+ * outside it. A hydrated island in it (a lab's plot) becomes plain markup,
236
+ * so it never hydrates a second time inside the card: its plot is copied as
237
+ * drawn. A picture with nothing drawn yet — a plot the browser has not
238
+ * drawn, or one on a fetched page, where only the server markup exists —
239
+ * gives no copy, and the card shows the caption alone.
240
+ */
241
+ function cloneFigureBody(target: HTMLElement): HTMLElement | null {
242
+ const clone = cloneBody(target, 'keyed-figure__body');
243
+ if (!clone) return null;
244
+ const islands = Array.from(clone.querySelectorAll('astro-island'));
245
+ for (const island of islands) {
246
+ const still = document.createElement('div');
247
+ still.className = 'float-ref-island';
248
+ still.append(...island.childNodes);
249
+ island.replaceWith(still);
250
+ }
251
+ clone.querySelectorAll('script, template, astro-slot').forEach((node) => node.remove());
252
+ const drawn = clone.querySelector('svg, img, picture, canvas, video, table');
253
+ if (islands.length > 0 && !drawn) return null;
254
+ for (const control of clone.querySelectorAll<HTMLElement>('input, select, textarea, button')) {
255
+ control.setAttribute('tabindex', '-1');
256
+ control.setAttribute('disabled', '');
257
+ }
258
+ isolateIds(clone, 'figure');
203
259
  return clone;
204
260
  }
205
261
 
206
- const KINDS: Record<'equation' | 'table' | 'figure' | 'diagram', KindConfig> = {
262
+ /** A copy with no ids, so a preview never collides with the page. */
263
+ function importWithoutIds(node: Node): Node {
264
+ const clone = document.importNode(node, true);
265
+ if (clone instanceof Element) {
266
+ clone.removeAttribute('id');
267
+ clone.querySelectorAll('[id]').forEach((element) => element.removeAttribute('id'));
268
+ }
269
+ return clone;
270
+ }
271
+
272
+ /**
273
+ * The first paragraph of a heading's section. On this page a collapsible
274
+ * section's body sits in its `<details>` (`CollapsibleSections.astro`); on
275
+ * a fetched page, or an ordinary section, it is the paragraphs after the
276
+ * heading's wrapper, up to the next heading.
277
+ */
278
+ function sectionLead(heading: HTMLElement): HTMLElement | null {
279
+ const body = heading
280
+ .closest('summary')
281
+ ?.parentElement?.querySelector<HTMLElement>(':scope > .sl-section-body');
282
+ if (body) return body.querySelector<HTMLElement>(':scope > p');
283
+ let node = (heading.closest('.sl-heading-wrapper') ?? heading).nextElementSibling;
284
+ while (node && !node.matches('.sl-heading-wrapper, h1, h2, h3, h4, h5, h6')) {
285
+ if (node.tagName === 'P') return node as HTMLElement;
286
+ node = node.nextElementSibling;
287
+ }
288
+ return null;
289
+ }
290
+
291
+ /**
292
+ * A knowledge check's or an example set's heading (`BlockHeading.astro`):
293
+ * its topical title — not the badge or the number, which the card's jump
294
+ * link already names — then `detail`, one line of what the block holds.
295
+ */
296
+ function fillBlockPreview(target: HTMLElement, contentEl: HTMLElement, detail: string) {
297
+ const title = document.createElement('p');
298
+ title.className = 'float-ref-panel-section-title';
299
+ const source = target.querySelector('.block-heading__title') ?? target;
300
+ for (const child of source.childNodes) title.append(importWithoutIds(child));
301
+ const lead = document.createElement('p');
302
+ lead.className = 'float-ref-panel-section-lead';
303
+ lead.textContent = detail;
304
+ contentEl.replaceChildren(title, ...(detail ? [lead] : []));
305
+ }
306
+
307
+ const plural = (count: number, noun: string) =>
308
+ `${count} ${noun}${count === 1 ? '' : 's'}`;
309
+
310
+ const KINDS: Record<
311
+ 'equation' | 'table' | 'figure' | 'diagram' | 'section' | 'example' | 'check',
312
+ KindConfig
313
+ > = {
207
314
  equation: {
208
315
  triggerClass: 'equation-ref',
209
316
  hrefPrefix: '#eq-',
@@ -211,6 +318,7 @@ import HoverPanel from '../HoverPanel.astro';
211
318
  numberDataKey: 'eqNumber',
212
319
  highlightClass: 'eq-ref-highlight',
213
320
  jumpLabel: 'equation',
321
+ shortDataKey: 'eqShort',
214
322
  revealOnPin: true,
215
323
  fillContent(target, contentEl) {
216
324
  contentEl.replaceChildren(cloneEquationFormula(target));
@@ -226,6 +334,7 @@ import HoverPanel from '../HoverPanel.astro';
226
334
  numberDataKey: 'tabNumber',
227
335
  highlightClass: 'tab-ref-highlight',
228
336
  jumpLabel: 'table',
337
+ shortDataKey: 'tabShort',
229
338
  revealOnPin: false,
230
339
  fillContent(target, contentEl) {
231
340
  const clone = cloneBody(target, 'keyed-table__body');
@@ -245,10 +354,11 @@ import HoverPanel from '../HoverPanel.astro';
245
354
  numberDataKey: 'figNumber',
246
355
  highlightClass: 'fig-ref-highlight',
247
356
  jumpLabel: 'figure',
357
+ shortDataKey: 'figShort',
248
358
  revealOnPin: false,
249
359
  fillContent(target, contentEl) {
250
360
  const previewable = target.dataset.figPreviewable !== 'false';
251
- const clone = previewable ? cloneBody(target, 'keyed-figure__body') : null;
361
+ const clone = previewable ? cloneFigureBody(target) : null;
252
362
  contentEl.replaceChildren(...(clone ? [clone] : []));
253
363
  },
254
364
  captionFor(target) {
@@ -264,6 +374,7 @@ import HoverPanel from '../HoverPanel.astro';
264
374
  numberDataKey: 'diaNumber',
265
375
  highlightClass: 'dia-ref-highlight',
266
376
  jumpLabel: 'diagram',
377
+ shortDataKey: 'diaShort',
267
378
  revealOnPin: false,
268
379
  fillContent(target, contentEl) {
269
380
  const clone = cloneDiagramBody(target);
@@ -273,6 +384,81 @@ import HoverPanel from '../HoverPanel.astro';
273
384
  return target.querySelector('.diagram__caption-text')?.textContent ?? '';
274
385
  },
275
386
  },
387
+ section: {
388
+ triggerClass: 'section-ref',
389
+ // A section's anchor is its heading's slug, with no prefix of its own;
390
+ // only `a.section-ref` links are ever considered here.
391
+ hrefPrefix: '#',
392
+ numberClass: '',
393
+ numberDataKey: 'secNumber',
394
+ highlightClass: 'sec-ref-highlight',
395
+ jumpLabel: 'section',
396
+ revealOnPin: false,
397
+ fillContent(target, contentEl) {
398
+ const title = document.createElement('p');
399
+ title.className = 'float-ref-panel-section-title';
400
+ // The heading's own nodes, so a title with math keeps it rendered.
401
+ for (const child of target.childNodes) title.append(importWithoutIds(child));
402
+ const lead = sectionLead(target);
403
+ const leadClone = lead ? (importWithoutIds(lead) as HTMLElement) : null;
404
+ leadClone?.classList.add('float-ref-panel-section-lead');
405
+ contentEl.replaceChildren(title, ...(leadClone ? [leadClone] : []));
406
+ },
407
+ captionFor() {
408
+ return '';
409
+ },
410
+ numberLabel(number) {
411
+ return number ? `§\u00a0${number}` : '';
412
+ },
413
+ },
414
+ example: {
415
+ triggerClass: 'example-ref',
416
+ hrefPrefix: '#ex-',
417
+ numberClass: '',
418
+ numberDataKey: 'exNumber',
419
+ highlightClass: 'sec-ref-highlight',
420
+ jumpLabel: 'example',
421
+ revealOnPin: false,
422
+ fillContent(target, contentEl) {
423
+ // The examples' own names, as the set's tabs show them.
424
+ const labels = Array.from(
425
+ target.closest('compact-examples')?.querySelectorAll<HTMLElement>(
426
+ '[data-example-label]',
427
+ ) ?? [],
428
+ (panel) => panel.dataset.exampleLabel ?? '',
429
+ ).filter(Boolean);
430
+ fillBlockPreview(target, contentEl, labels.join(' · '));
431
+ },
432
+ captionFor() {
433
+ return '';
434
+ },
435
+ numberLabel(number) {
436
+ return number ? `Example\u00a0${number}` : '';
437
+ },
438
+ },
439
+ check: {
440
+ triggerClass: 'knowledge-check-ref',
441
+ hrefPrefix: '#check-',
442
+ numberClass: '',
443
+ numberDataKey: 'checkNumber',
444
+ highlightClass: 'sec-ref-highlight',
445
+ jumpLabel: 'knowledge check',
446
+ revealOnPin: false,
447
+ fillContent(target, contentEl) {
448
+ // The runner is labelled by the check's heading
449
+ // (`AssessmentRunner.tsx`), wherever the section has moved it.
450
+ const questions = target.ownerDocument
451
+ .querySelector(`.assessment-shell[aria-labelledby="${CSS.escape(target.id)}"]`)
452
+ ?.querySelectorAll('.assessment-item').length;
453
+ fillBlockPreview(target, contentEl, questions ? plural(questions, 'question') : '');
454
+ },
455
+ captionFor() {
456
+ return '';
457
+ },
458
+ numberLabel(number) {
459
+ return number ? `Knowledge check\u00a0${number}` : '';
460
+ },
461
+ },
276
462
  };
277
463
 
278
464
  const TRIGGER_SELECTOR = Object.values(KINDS)
@@ -351,57 +537,34 @@ import HoverPanel from '../HoverPanel.astro';
351
537
  if (link) document.head.appendChild(document.importNode(link, true));
352
538
  }
353
539
 
540
+ const capitalized = (text: string) =>
541
+ `${text.charAt(0).toUpperCase()}${text.slice(1)}`;
542
+
543
+ /** The number link's text: `(2.1)` for an equation, else `Table 2.1`, `§ 1.1.2`, …; empty hides it. */
544
+ function numberText(kind: KindConfig, number: string): string {
545
+ if (kind.numberLabel) return kind.numberLabel(number);
546
+ return kind.jumpLabel === 'equation'
547
+ ? `(${number})`
548
+ : `${capitalized(kind.jumpLabel)} ${number}`;
549
+ }
550
+
551
+ /** What the card's "Go to …" link names (C15): `equation (1.4)`, `table 2.1`, `the section`. */
552
+ function goToLabel(kind: KindConfig, number: string): string {
553
+ if (!number) return `the ${kind.jumpLabel}`;
554
+ return kind.jumpLabel === 'equation'
555
+ ? `equation (${number})`
556
+ : `${kind.jumpLabel} ${number}`;
557
+ }
558
+
354
559
  function setup() {
355
- const panel = document.querySelector<HTMLElement>('[data-float-ref-panel]')!;
356
- if (!panel || panel.dataset.floatRefReady === 'true') return;
357
-
358
- // `.main-pane` (Starlight's `TwoColumnContent.astro`) sets
359
- // `isolation: isolate`, which traps this panel's z-index inside it — the
360
- // right "On this page" rail sits outside that isolation as a
361
- // `position: fixed` sibling with no z-index of its own, and CSS still
362
- // paints any positioned box ahead of a plain in-flow one regardless of
363
- // z-index or DOM order. No z-index set here could ever win against that
364
- // from inside `.main-pane`, so the panel is allowed to cover the rail
365
- // (per its own design) by moving it out to `<body>` instead — the same
366
- // fix the notation layer's popups already use for this exact trap (see
367
- // `PopupStackConfig.container` in `popup-stack.ts`). `position: fixed`
368
- // means the DOM parent doesn't affect its layout.
369
- if (panel.parentElement !== document.body) {
370
- document.body.appendChild(panel);
560
+ const template = document.getElementById('float-ref-panel');
561
+ if (
562
+ !(template instanceof HTMLTemplateElement) ||
563
+ template.dataset.cardSourceReady === 'true'
564
+ ) {
565
+ return;
371
566
  }
372
-
373
- const refs = Array.from(
374
- document.querySelectorAll<HTMLAnchorElement>(
375
- Object.values(KINDS)
376
- .map((kind) => `.sl-markdown-content a.${kind.triggerClass}`)
377
- .join(', '),
378
- ),
379
- ).filter((a) => {
380
- const kind = kindOf(a);
381
- if (!kind) return false;
382
- // Same-page (`#eq-…`) or a resolvable cross-page (`/lesson/#eq-…`) ref.
383
- return (
384
- (a.getAttribute('href') ?? '').startsWith(kind.hrefPrefix) ||
385
- crossPageHref(a, kind) !== null
386
- );
387
- });
388
- if (refs.length === 0) return;
389
- panel.dataset.floatRefReady = 'true';
390
-
391
- const refSet = new Set<HTMLElement>(refs);
392
- const numberEl = panel.querySelector<HTMLAnchorElement>('[data-panel-number]')!;
393
- const contentEl = panel.querySelector<HTMLElement>('[data-panel-content]')!;
394
- const captionEl = panel.querySelector<HTMLElement>('[data-panel-caption]')!;
395
- const jumpEl = panel.querySelector<HTMLAnchorElement>('[data-panel-jump]')!;
396
- const jumpLabelEl = panel.querySelector<HTMLElement>('[data-panel-jump-label]')!;
397
- const closeButton = panel.querySelector<HTMLElement>('[data-hover-panel-close]');
398
-
399
- let activeTrigger: HTMLElement | null = null;
400
- let activeTarget: HTMLElement | null = null;
401
- let activeKind: KindConfig | null = null;
402
- let mode: 'highlight' | 'panel' | null = null;
403
- let pinned = false;
404
- let leaveTimer: number | null = null;
567
+ const shell: HTMLTemplateElement = template;
405
568
 
406
569
  const targetFor = (trigger: HTMLElement, kind: KindConfig) => {
407
570
  const href = trigger.getAttribute('href') ?? '';
@@ -410,7 +573,51 @@ import HoverPanel from '../HoverPanel.astro';
410
573
  : null;
411
574
  };
412
575
 
576
+ // A reference this layer can preview: same-page with its target on this
577
+ // page, or a cross-page one. A card cloned from another page
578
+ // (`data-card-foreign`) holds references into that page, not this one.
579
+ const refFrom = (node: EventTarget | null) => {
580
+ const trigger = closestElement(node, TRIGGER_SELECTOR);
581
+ if (!trigger || trigger.closest('[data-card-foreign]')) return null;
582
+ const kind = kindOf(trigger);
583
+ if (!kind) return null;
584
+ return targetFor(trigger, kind) || crossPageHref(trigger, kind) ? trigger : null;
585
+ };
586
+
587
+ const refs = Array.from(document.querySelectorAll<HTMLElement>(TRIGGER_SELECTOR));
588
+ if (!refs.some((ref) => refFrom(ref))) return;
589
+ shell.dataset.cardSourceReady = 'true';
590
+
591
+ /**
592
+ * The outermost fold hiding `el`: a closed memo or section (its
593
+ * `<details>` stays laid out while closed, clipped, so its content still
594
+ * has an on-screen box), any other closed `<details>`, or a `hidden`
595
+ * ancestor. A reference to something in there previews it in a card, as
596
+ * if it were off screen, and marks the fold's bar while the card is open.
597
+ */
598
+ const foldHiding = (el: Element): HTMLElement | null => {
599
+ let hiding: HTMLElement | null = null;
600
+ for (let node = el.parentElement; node; node = node.parentElement) {
601
+ if (node instanceof HTMLDetailsElement) {
602
+ const state = node.dataset.state;
603
+ if (!node.open || (state !== undefined && state !== 'open' && state !== 'peeking')) {
604
+ hiding = node;
605
+ }
606
+ } else if (node.hidden) {
607
+ hiding = node;
608
+ }
609
+ }
610
+ return hiding;
611
+ };
612
+
613
+ /** The part of a fold a reader sees while it is closed: its bar. */
614
+ const foldBar = (fold: HTMLElement): HTMLElement =>
615
+ (fold instanceof HTMLDetailsElement
616
+ ? fold.querySelector<HTMLElement>(':scope > summary')
617
+ : null) ?? fold;
618
+
413
619
  const isFullyVisible = (el: HTMLElement) => {
620
+ if (foldHiding(el)) return false;
414
621
  const rect = el.getBoundingClientRect();
415
622
  const headerBottom =
416
623
  document
@@ -425,373 +632,182 @@ import HoverPanel from '../HoverPanel.astro';
425
632
  );
426
633
  };
427
634
 
428
- const reveal = (target: HTMLElement | null, kind: KindConfig) => {
429
- if (!target || !kind.revealOnPin) return;
430
- target.querySelector(`.${kind.numberClass}`)?.classList.add('eq-number-revealed');
635
+ // Highlight mode: a same-page float already fully on screen is lit up in
636
+ // place, and its reference stays a plain link (the manager leaves a
637
+ // trigger the source does not claim alone).
638
+ let highlighted: { target: HTMLElement; className: string } | null = null;
639
+ const clearHighlight = () => {
640
+ highlighted?.target.classList.remove(highlighted.className);
641
+ highlighted = null;
642
+ };
643
+ const highlight = (trigger: HTMLElement) => {
644
+ const kind = kindOf(trigger);
645
+ const target = kind ? targetFor(trigger, kind) : null;
646
+ if (!kind || !target || !isFullyVisible(target)) return;
647
+ clearHighlight();
648
+ target.classList.add(kind.highlightClass);
649
+ highlighted = { target, className: kind.highlightClass };
431
650
  };
432
651
 
433
- /**
434
- * How much horizontal room the card may use: from the right edge of the
435
- * left sidebar nav to the viewport edge. Unlike the old article-column
436
- * clamp, this deliberately does not stop at the right "On this page"
437
- * rail — a wide equation (e.g. an OLS estimator) needs the room more
438
- * than the card needs to respect the ToC's column, and readers can
439
- * still reach the ToC once the card closes.
440
- *
441
- * `.sidebar-pane` is `position: fixed` and reports its full-viewport
442
- * geometry even while hidden below the sidebar breakpoint (Starlight
443
- * hides it with `visibility`, not `display`), so that has to be checked
444
- * explicitly rather than trusted from the rect alone.
445
- */
446
- function horizontalRegion(edge: number): { start: number; end: number } {
447
- const sidebarPane = document.querySelector<HTMLElement>('.sidebar-pane');
448
- const sidebarVisible =
449
- !!sidebarPane && getComputedStyle(sidebarPane).visibility !== 'hidden';
450
- const sidebarRect = sidebarVisible
451
- ? sidebarPane!.getBoundingClientRect()
452
- : null;
453
- return {
454
- start: Math.max(edge, sidebarRect?.right ?? edge),
455
- end: window.innerWidth - edge,
456
- };
457
- }
458
-
459
- function position(trigger: HTMLElement) {
460
- const edge = 12;
461
- const gap = 10;
462
- const displayEquation = trigger.closest<HTMLElement>('.katex-display');
463
- const safeRegion = displayEquation ?? trigger;
464
- const regionRect = safeRegion.getBoundingClientRect();
465
- const anchorRect = trigger.getBoundingClientRect();
466
-
467
- // Set before any width-dependent measurement below so the card has
468
- // already grown (or not) to its final width by the time its rect is
469
- // read for placement.
470
- const { start: horizontalStart, end: horizontalEnd } =
471
- horizontalRegion(edge);
472
- panel.style.setProperty(
473
- '--float-ref-max-width',
474
- `${Math.max(horizontalEnd - horizontalStart, 0)}px`,
475
- );
476
-
477
- const headerBottom =
478
- document
479
- .querySelector<HTMLElement>('.header')
480
- ?.getBoundingClientRect().bottom ?? 0;
481
- const verticalStart = Math.max(edge, headerBottom + edge);
482
- const verticalEnd = window.innerHeight - edge;
483
- const spaceAbove = regionRect.top - gap - verticalStart;
484
- const spaceBelow = verticalEnd - regionRect.bottom - gap;
485
-
486
- panel.style.maxHeight = `${Math.max(spaceAbove, spaceBelow, 0)}px`;
487
- let rect = panel.getBoundingClientRect();
488
- const placeBelow = spaceBelow >= rect.height || spaceBelow >= spaceAbove;
489
- panel.style.maxHeight = `${Math.max(placeBelow ? spaceBelow : spaceAbove, 0)}px`;
490
- rect = panel.getBoundingClientRect();
491
-
492
- let top = placeBelow
493
- ? regionRect.bottom + gap
494
- : regionRect.top - gap - rect.height;
495
- let left = anchorRect.left + anchorRect.width / 2 - rect.width / 2;
496
-
497
- top = Math.max(verticalStart, Math.min(top, verticalEnd - rect.height));
498
- left =
499
- horizontalEnd - horizontalStart >= rect.width
500
- ? Math.max(horizontalStart, Math.min(left, horizontalEnd - rect.width))
501
- : Math.max(edge, Math.min(left, window.innerWidth - rect.width - edge));
502
-
503
- panel.style.top = `${Math.round(top)}px`;
504
- panel.style.left = `${Math.round(left)}px`;
505
- }
652
+ /** `lesson` names the page a cross-page reference lands on. */
653
+ const buildCard = (
654
+ trigger: HTMLElement,
655
+ target: HTMLElement,
656
+ kind: KindConfig,
657
+ lesson?: string,
658
+ ) => {
659
+ const el = cloneCardShell(shell);
660
+ if (!el) return null;
661
+ const field = (name: string) => el.querySelector<HTMLElement>(`[data-panel-${name}]`);
662
+ const kindEl = field('kind');
663
+ const headingEl = field('heading');
664
+ const numberEl = el.querySelector<HTMLAnchorElement>('[data-panel-number]');
665
+ const contentEl = field('content');
666
+ const captionEl = field('caption');
667
+ const jumpEl = el.querySelector<HTMLAnchorElement>('[data-panel-jump]');
668
+ const jumpLabelEl = field('jump-label');
669
+ if (
670
+ !kindEl ||
671
+ !headingEl ||
672
+ !numberEl ||
673
+ !contentEl ||
674
+ !captionEl ||
675
+ !jumpEl ||
676
+ !jumpLabelEl
677
+ ) {
678
+ return null;
679
+ }
506
680
 
507
- function fillPanel(trigger: HTMLElement, target: HTMLElement, kind: KindConfig) {
508
- const number = (target.dataset[kind.numberDataKey] ?? '') as string;
509
- const label = kind.jumpLabel;
681
+ const number = target.dataset[kind.numberDataKey] ?? '';
510
682
  const href = trigger.getAttribute('href') ?? '';
511
- // The number sits to the right of the content and is itself the jump link
512
- // — for an equation just "(2.1)", for a table/figure the labelled form.
513
- numberEl.textContent =
514
- kind.jumpLabel === 'equation'
515
- ? `(${number})`
516
- : `${label.charAt(0).toUpperCase()}${label.slice(1)} ${number}`;
683
+ const goTo = goToLabel(kind, number);
684
+ kindEl.textContent = capitalized(kind.jumpLabel);
685
+ headingEl.textContent = kind.shortDataKey
686
+ ? (target.dataset[kind.shortDataKey] ?? '')
687
+ : '';
688
+ headingEl.hidden = headingEl.textContent.length === 0;
689
+ // The number sits to the right of the content and is itself a link to
690
+ // the float — for an equation just "(2.1)", else the labelled form.
691
+ numberEl.textContent = numberText(kind, number);
692
+ numberEl.hidden = numberEl.textContent.length === 0;
517
693
  numberEl.href = href;
518
- numberEl.title = `Jump to the ${label}`;
694
+ numberEl.title = `Go to the ${kind.jumpLabel}`;
519
695
  kind.fillContent(target, contentEl);
696
+ // A term cloned from another page still carries its no-JS tooltip.
697
+ for (const term of contentEl.querySelectorAll('a.notation-term[title]')) {
698
+ term.removeAttribute('title');
699
+ }
520
700
  captionEl.textContent = kind.captionFor(target);
521
701
  captionEl.hidden = captionEl.textContent.length === 0;
522
- jumpLabelEl.textContent = label;
702
+ jumpLabelEl.textContent = lesson ? `${goTo} in ${lesson}` : goTo;
523
703
  jumpEl.href = href;
524
- }
525
-
526
- function clearAll() {
527
- if (activeTarget && activeKind) {
528
- activeTarget.classList.remove(activeKind.highlightClass);
529
- }
530
- if (mode === 'panel') {
531
- panel.hidden = true;
532
- releaseActiveSurface(GROUP);
533
- }
534
- activeTrigger = null;
535
- activeTarget = null;
536
- activeKind = null;
537
- mode = null;
538
- pinned = false;
539
- }
704
+ wireJumpArrow(jumpEl);
705
+ el.setAttribute('aria-label', `Preview of ${goTo}`);
706
+ return el;
707
+ };
540
708
 
541
709
  /**
542
- * Pin the open card and hand the exclusivity group back: a pinned card is a
543
- * little workspace the reader dwells in, so hovering a symbol *inside* it
544
- * must be free to open its notation popup without the surface arbiter
545
- * treating that as "attention moved elsewhere" and closing the card. The
546
- * card still closes on its "×", on an outside click, or on Escape.
710
+ * A same-page reference fills its card from this document. A cross-page
711
+ * one fetches and parses its page once (`fetchDoc` caches it), pulls the
712
+ * `#eq-…`/`#tab-…`/`#fig-…` node out of it, and fills the same card —
713
+ * cloned across documents by the `importNode`-based helpers above.
547
714
  */
548
- function pinPanel(target: HTMLElement | null, kind: KindConfig) {
549
- pinned = true;
550
- reveal(target, kind);
551
- releaseActiveSurface(GROUP);
552
- }
715
+ // Fold bars marked for an open card, by the card's item.
716
+ const foldMarks = new Map<string, HTMLElement>();
553
717
 
554
- function show(trigger: HTMLElement, pin: boolean) {
718
+ const render = (item: string, trigger: HTMLElement) => {
555
719
  const kind = kindOf(trigger);
556
- if (!kind) return;
557
-
720
+ if (!kind) return null;
558
721
  const crossPage = crossPageHref(trigger, kind);
559
- if (crossPage) {
560
- if (standalone) return;
561
- void showCrossPage(trigger, kind, crossPage);
562
- return;
563
- }
564
-
565
- const target = targetFor(trigger, kind);
566
- if (!target) return;
567
-
568
- if (pinned && trigger !== activeTrigger) return;
569
-
570
- if (trigger === activeTrigger) {
571
- if (pin) pinPanel(activeTarget, kind);
572
- return;
573
- }
574
-
575
- clearAll();
576
- activeTrigger = trigger;
577
- activeTarget = target;
578
- activeKind = kind;
579
-
580
- if (isFullyVisible(target)) {
581
- // Never reached with `pin: true` — the click handler only calls
582
- // `show(trigger, true)` once it has already checked the float is
583
- // off screen; an on-screen reference is left to navigate natively.
584
- mode = 'highlight';
585
- target.classList.add(kind.highlightClass);
586
- return;
587
- }
588
-
589
- mode = 'panel';
590
- fillPanel(trigger, target, kind);
591
- claimActiveSurface(GROUP, clearAll);
592
- panel.style.visibility = 'hidden';
593
- panel.hidden = false;
594
- position(trigger);
595
- panel.style.visibility = 'visible';
596
- if (pin) pinPanel(target, kind);
597
- }
598
-
599
- /**
600
- * The cross-page counterpart of `show`: the target float lives on another
601
- * lesson page, so there is nothing in this document to highlight in place.
602
- * Fetch and parse that page once (`fetchDoc` caches it), pull the
603
- * `#eq-…`/`#tab-…`/`#fig-…` node out of it, and fill the same card — the
604
- * formula, table body, or (for a `previewable` figure) figure body, cloned
605
- * across documents by `fillPanel`'s `importNode`-based helpers. A slow
606
- * fetch that resolves after the pointer has moved on is discarded.
607
- */
608
- async function showCrossPage(
609
- trigger: HTMLElement,
610
- kind: KindConfig,
611
- ref: { page: string; id: string },
612
- ) {
613
- if (pinned && trigger !== activeTrigger) return;
614
- if (trigger === activeTrigger) return;
615
-
616
- clearAll();
617
- activeTrigger = trigger;
618
- activeKind = kind;
619
-
620
- let sourceDoc: Document;
621
- try {
622
- sourceDoc = await fetchDoc(ref.page);
623
- } catch {
624
- if (activeTrigger === trigger) clearAll();
625
- return;
626
- }
627
- if (activeTrigger !== trigger) return;
628
-
629
- const target = sourceDoc.getElementById(ref.id);
630
- if (!target) {
631
- clearAll();
632
- return;
722
+ if (!crossPage) {
723
+ const target = targetFor(trigger, kind);
724
+ if (!target) return null;
725
+ const fold = foldHiding(target);
726
+ if (fold) {
727
+ const bar = foldBar(fold);
728
+ bar.classList.add('float-ref-fold-hint');
729
+ foldMarks.set(item, bar);
730
+ }
731
+ return buildCard(trigger, target, kind);
633
732
  }
634
-
635
- activeTarget = target; // a detached node owned by `sourceDoc`
636
- mode = 'panel';
637
- ensureKatexStylesheet(sourceDoc);
638
- fillPanel(trigger, target, kind);
639
- claimActiveSurface(GROUP, clearAll);
640
- panel.style.visibility = 'hidden';
641
- panel.hidden = false;
642
- position(trigger);
643
- panel.style.visibility = 'visible';
644
- }
645
-
646
- function scheduleLeave() {
647
- if (pinned) return;
648
- if (leaveTimer !== null) window.cancelAnimationFrame(leaveTimer);
649
- leaveTimer = window.requestAnimationFrame(() => {
650
- leaveTimer = null;
651
- if (pinned || !activeTrigger) return;
652
- const stillActive =
653
- activeTrigger.matches(':hover') ||
654
- activeTrigger === document.activeElement ||
655
- panel.matches(':hover') ||
656
- panel.contains(document.activeElement);
657
- if (!stillActive) clearAll();
733
+ return fetchDoc(crossPage.page).then((sourceDoc) => {
734
+ const target = sourceDoc.getElementById(crossPage.id);
735
+ if (!target) return null;
736
+ ensureKatexStylesheet(sourceDoc);
737
+ const lesson = sourceDoc.getElementById('_top')?.textContent?.trim() || undefined;
738
+ const el = buildCard(trigger, target, kind, lesson);
739
+ el?.setAttribute('data-card-foreign', '');
740
+ return el;
658
741
  });
659
- }
742
+ };
743
+
744
+ registerCardSource({
745
+ layer: 'reference',
746
+ triggerFrom: refFrom,
747
+ itemFor: (trigger) => {
748
+ const kind = kindOf(trigger);
749
+ if (!kind) return null;
750
+ const crossPage = crossPageHref(trigger, kind);
751
+ if (crossPage) return `${crossPage.page}#${crossPage.id}`;
752
+ return (trigger.getAttribute('href') ?? '').slice(1) || null;
753
+ },
754
+ claims: (trigger) => {
755
+ const kind = kindOf(trigger);
756
+ if (!kind) return false;
757
+ // The exporter rewrote a cross-page reference to the course's own
758
+ // URL; an offline copy must never fetch it.
759
+ if (crossPageHref(trigger, kind)) return !standalone;
760
+ const target = targetFor(trigger, kind);
761
+ return !!target && !isFullyVisible(target);
762
+ },
763
+ render,
764
+ onChange: (open) => {
765
+ for (const [item, bar] of foldMarks) {
766
+ if (open.has(item)) continue;
767
+ bar.classList.remove('float-ref-fold-hint');
768
+ foldMarks.delete(item);
769
+ }
770
+ // Keeping an equation's card, pinned or docked, reveals its faded
771
+ // number badge (F4).
772
+ for (const [item, mode] of open) {
773
+ if (!isKept(mode)) continue;
774
+ const kind = Object.values(KINDS).find(
775
+ (candidate) =>
776
+ candidate.revealOnPin && item.startsWith(candidate.hrefPrefix.slice(1)),
777
+ );
778
+ if (!kind) continue;
779
+ document
780
+ .getElementById(item)
781
+ ?.querySelector(`.${kind.numberClass}`)
782
+ ?.classList.add('eq-number-revealed');
783
+ }
784
+ },
785
+ });
660
786
 
661
787
  document.addEventListener('pointerover', (event) => {
662
788
  if (event.pointerType === 'touch') return;
663
- const trigger = closestElement(event.target, TRIGGER_SELECTOR);
664
- if (!trigger || !refSet.has(trigger)) return;
665
- if (trigger === closestElement(event.relatedTarget, TRIGGER_SELECTOR)) return;
666
- show(trigger, false);
789
+ const trigger = refFrom(event.target);
790
+ if (!trigger) return;
791
+ if (event.relatedTarget instanceof Node && trigger.contains(event.relatedTarget)) return;
792
+ highlight(trigger);
667
793
  });
668
794
 
669
795
  document.addEventListener('pointerout', (event) => {
670
796
  if (event.pointerType === 'touch') return;
671
- const trigger = closestElement(event.target, TRIGGER_SELECTOR);
672
- if (!trigger || !refSet.has(trigger)) return;
673
- if (trigger === closestElement(event.relatedTarget, TRIGGER_SELECTOR)) return;
674
- scheduleLeave();
797
+ const trigger = refFrom(event.target);
798
+ if (!trigger) return;
799
+ if (event.relatedTarget instanceof Node && trigger.contains(event.relatedTarget)) return;
800
+ clearHighlight();
675
801
  });
676
802
 
677
803
  document.addEventListener('focusin', (event) => {
678
- const trigger = closestElement(event.target, TRIGGER_SELECTOR);
679
- if (trigger && refSet.has(trigger)) show(trigger, false);
804
+ const trigger = refFrom(event.target);
805
+ if (trigger) highlight(trigger);
680
806
  });
681
807
 
682
808
  document.addEventListener('focusout', (event) => {
683
- const trigger = closestElement(event.target, TRIGGER_SELECTOR);
684
- if (trigger && refSet.has(trigger)) scheduleLeave();
685
- });
686
-
687
- panel.addEventListener('pointerleave', scheduleLeave);
688
-
689
- document.addEventListener('click', (event) => {
690
- if (closeButton && closestElement(event.target, '[data-hover-panel-close]')) {
691
- event.preventDefault();
692
- clearAll();
693
- return;
694
- }
695
-
696
- const trigger = closestElement(event.target, TRIGGER_SELECTOR);
697
- if (trigger && refSet.has(trigger)) {
698
- const kind = kindOf(trigger);
699
- if (!kind) return;
700
-
701
- const isCrossPage = crossPageHref(trigger, kind) !== null;
702
-
703
- // A diagram reference remains an ordinary navigation link. Hover may
704
- // preview an off-screen diagram, but clicking must dismiss that card
705
- // and take the reader to the real diagram in one gesture.
706
- if (kind.jumpLabel === 'diagram' && !isCrossPage) {
707
- clearAll();
708
- return;
709
- }
710
-
711
- // A card that is already open for this trigger — same-page *or*
712
- // cross-page — pins in place instead of navigating. The only way to
713
- // the float itself is now the card's own number/"Jump to …" link.
714
- if (mode === 'panel' && activeTrigger === trigger) {
715
- event.preventDefault();
716
- pinPanel(isCrossPage ? null : targetFor(trigger, kind), kind);
717
- return;
718
- }
719
-
720
- if (isCrossPage) {
721
- // The exporter rewrites this to the canonical course URL. Leave it
722
- // as an ordinary link; an offline hover/click must never fetch it.
723
- if (standalone) {
724
- clearAll();
725
- return;
726
- }
727
- // No card open yet (keyboard, or a click that beat the hover): open
728
- // it — pinned — rather than leave the page.
729
- event.preventDefault();
730
- const ref = crossPageHref(trigger, kind)!;
731
- void showCrossPage(trigger, kind, ref).then(() => {
732
- if (activeTrigger === trigger && mode === 'panel') {
733
- pinPanel(null, kind);
734
- }
735
- });
736
- return;
737
- }
738
-
739
- const target = targetFor(trigger, kind);
740
- if (target && !isFullyVisible(target)) {
741
- event.preventDefault();
742
- show(trigger, true);
743
- }
744
- // Already fully visible: let the native anchor navigation through —
745
- // `KeyedFloatEnhancer.astro` picks up the `hashchange` it produces.
746
- return;
747
- }
748
-
749
- if (closestElement(event.target, '[data-panel-jump], [data-panel-number]')) {
750
- // Real navigation to the float is the point of these links; just drop
751
- // our own state so a stray pointerout doesn't fight it.
752
- clearAll();
753
- return;
754
- }
755
-
756
- if (mode === 'panel' && event.target instanceof Node) {
757
- // An outside click closes the card — but a click landing in a notation
758
- // popup that a card symbol opened is "inside" as far as the reader is
759
- // concerned.
760
- if (
761
- !panel.contains(event.target) &&
762
- !closestElement(event.target, '.notation-popup')
763
- ) {
764
- clearAll();
765
- }
766
- }
809
+ if (refFrom(event.target)) clearHighlight();
767
810
  });
768
-
769
- document.addEventListener('keydown', (event) => {
770
- if (event.key === 'Escape' && mode) clearAll();
771
- });
772
-
773
- window.addEventListener(
774
- 'resize',
775
- () => {
776
- if (mode === 'panel' && activeTrigger) position(activeTrigger);
777
- },
778
- { passive: true },
779
- );
780
- window.addEventListener(
781
- 'scroll',
782
- () => {
783
- if (mode !== 'panel' || !activeTrigger) return;
784
- if (
785
- activeTarget?.ownerDocument === document &&
786
- isFullyVisible(activeTarget)
787
- ) {
788
- clearAll();
789
- return;
790
- }
791
- position(activeTrigger);
792
- },
793
- { passive: true, capture: true },
794
- );
795
811
  }
796
812
 
797
813
  setup();
@@ -837,7 +853,11 @@ import HoverPanel from '../HoverPanel.astro';
837
853
  text-decoration: underline;
838
854
  }
839
855
 
856
+ /* The content takes the card's width: a figure's SVG sized by its
857
+ `viewBox` alone, or a table in its scroll clip, has no width of its
858
+ own to give a card that sizes itself to its content. */
840
859
  .float-ref-panel-content {
860
+ flex: 1 1 auto;
841
861
  min-width: 0;
842
862
  overflow-x: auto;
843
863
  /* `overflow-x: auto` alone makes the block axis compute to `auto` too
@@ -853,6 +873,10 @@ import HoverPanel from '../HoverPanel.astro';
853
873
  display: none;
854
874
  }
855
875
 
876
+ .float-ref-panel-content :global(.keyed-figure__body) {
877
+ min-inline-size: min(22rem, 70vw);
878
+ }
879
+
856
880
  .float-ref-panel-content :global(.katex-display) {
857
881
  margin-block: 0;
858
882
  }
@@ -881,6 +905,44 @@ import HoverPanel from '../HoverPanel.astro';
881
905
  font-size: var(--sl-text-xs);
882
906
  }
883
907
 
908
+ /* The card's header: the kind of float, then its short caption when it
909
+ has one. */
910
+ .float-ref-panel-title {
911
+ display: flex;
912
+ flex-wrap: wrap;
913
+ align-items: baseline;
914
+ gap: 0.2rem 0.6rem;
915
+ margin: 0;
916
+ }
917
+
918
+ .float-ref-panel-kind {
919
+ color: var(--sl-color-gray-3);
920
+ font-size: var(--sl-text-xs);
921
+ }
922
+
923
+ .float-ref-panel-heading {
924
+ font-weight: 600;
925
+ font-size: var(--sl-text-sm);
926
+ }
927
+
928
+ /* A section preview: its title, then the start of its first paragraph —
929
+ enough to recognise the passage, not to read it here. */
930
+ .float-ref-panel-content :global(.float-ref-panel-section-title) {
931
+ margin: 0;
932
+ font-weight: 600;
933
+ }
934
+
935
+ .float-ref-panel-content :global(.float-ref-panel-section-lead) {
936
+ display: -webkit-box;
937
+ margin: 0.35rem 0 0;
938
+ overflow: hidden;
939
+ -webkit-box-orient: vertical;
940
+ -webkit-line-clamp: 4;
941
+ line-clamp: 4;
942
+ color: var(--sl-color-gray-2);
943
+ font-size: var(--sl-text-sm);
944
+ }
945
+
884
946
  .float-ref-panel-jump {
885
947
  justify-self: start;
886
948
  font-size: var(--sl-text-xs);