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
@@ -4,19 +4,21 @@
4
4
  * once the pointer/focus leaves. See `docs/plans/collapsible-disclosure.md`
5
5
  * Part 0.
6
6
  *
7
- * This is deliberately not built on `../hover-panel.ts`'s `createHoverPanel`
8
- * — that engine is a single *reference* card with pin/jump semantics and a
9
- * cross-group exclusivity arbiter (`claimActiveSurface`); a collapsed-content
10
- * peek has no pin state and doesn't need to coordinate with the
11
- * notation/citation/float-ref panel group. It does reuse that group's
12
- * `requestAnimationFrame`-scheduled leave check (see `scheduleLeave` in
13
- * `FloatRefLayer.astro`) so a pointer move from the trigger onto the peek
14
- * itself doesn't cause a flicker-close.
7
+ * This is deliberately not an explanation card (`../card-manager.ts`): a
8
+ * peek has no pin state — its content is one click from being expanded for
9
+ * real — and takes no part in the cards' hover chain. It shares only their
10
+ * travel zone (rules C2 and C12 of `docs/plans/hover-card-consistency.md`,
11
+ * `../card-travel.ts`): after the pointer leaves the trigger toward the peek,
12
+ * the peek stays open while the pointer is on its way, another peek trigger
13
+ * crossed on the way is held back, and anywhere else the peek closes after
14
+ * the cards' grace.
15
15
  *
16
16
  * One peek panel is created lazily and shared across every trigger wired
17
17
  * with `attachHoverPeek` — only one can be hovered at a time, so reusing a
18
18
  * single element avoids paying DOM-churn cost per hover.
19
19
  */
20
+ import { CARD_CONSTANTS } from '../card-manager';
21
+ import { startTrip, type Point, type Trip } from '../card-travel';
20
22
 
21
23
  export interface HoverPeekOptions {
22
24
  /**
@@ -35,17 +37,23 @@ export interface HoverPeekOptions {
35
37
  let panel: HTMLElement | null = null;
36
38
  let scroller: HTMLElement | null = null;
37
39
  let activeTrigger: HTMLElement | null = null;
38
- let leaveTimer: number | null = null;
40
+ let trip: Trip | null = null;
41
+ let graceTimer: ReturnType<typeof setTimeout> | null = null;
39
42
  let globalListenersReady = false;
43
+ /** The pointer's last position: where it was when it left a trigger (see `../card-manager.ts`). */
44
+ let lastPointer: Point | null = null;
45
+ /** Each wired trigger's `show`, so resting on one mid-trip can switch to it. */
46
+ const showFor = new WeakMap<HTMLElement, () => void>();
40
47
 
41
48
  function cancelLeave(): void {
42
- if (leaveTimer !== null) {
43
- window.cancelAnimationFrame(leaveTimer);
44
- leaveTimer = null;
45
- }
49
+ trip?.end();
50
+ trip = null;
51
+ if (graceTimer !== null) clearTimeout(graceTimer);
52
+ graceTimer = null;
46
53
  }
47
54
 
48
55
  function close(): void {
56
+ cancelLeave();
49
57
  if (!panel) return;
50
58
  panel.hidden = true;
51
59
  scroller?.replaceChildren();
@@ -63,26 +71,70 @@ export function closeHoverPeek(trigger: HTMLElement): void {
63
71
  if (activeTrigger === trigger) close();
64
72
  }
65
73
 
74
+ function closeUnlessWanted(): void {
75
+ if (!activeTrigger || !panel) return;
76
+ // `:focus-visible`, not `=== document.activeElement` / `:focus`: the
77
+ // trigger is very often something like a `<summary>` that a *click* also
78
+ // focuses as a side effect (e.g. clicking it to toggle a `<details>`) — a
79
+ // plain focus check would then treat that leftover, non-keyboard focus as
80
+ // "still viewing it" forever, since nothing else ever takes focus to fire
81
+ // `blur`. `:focus-visible` is only true for focus a browser considers
82
+ // keyboard/keyboard-equivalent, so a mouse click doesn't leave the peek
83
+ // stuck open after the pointer leaves.
84
+ const stillActive =
85
+ activeTrigger.matches(':hover') ||
86
+ activeTrigger.matches(':focus-visible') ||
87
+ panel.matches(':hover') ||
88
+ panel.matches(':focus-within');
89
+ if (!stillActive) close();
90
+ }
91
+
92
+ /** The cards' grace: close after it unless the peek is wanted again by then. */
66
93
  function scheduleLeave(): void {
67
94
  cancelLeave();
68
- leaveTimer = window.requestAnimationFrame(() => {
69
- leaveTimer = null;
70
- if (!activeTrigger || !panel) return;
71
- // `:focus-visible`, not `=== document.activeElement` / `:focus`: the
72
- // trigger is very often something like a `<summary>` that a *click*
73
- // also focuses as a side effect (e.g. clicking it to toggle a
74
- // `<details>`) — a plain focus check would then treat that leftover,
75
- // non-keyboard focus as "still viewing it" forever, since nothing else
76
- // ever takes focus to fire `blur`. `:focus-visible` is only true for
77
- // focus a browser considers keyboard/keyboard-equivalent, so a mouse
78
- // click doesn't leave the peek stuck open after the pointer leaves.
79
- const stillActive =
80
- activeTrigger.matches(':hover') ||
81
- activeTrigger.matches(':focus-visible') ||
82
- panel.matches(':hover') ||
83
- panel.matches(':focus-within');
84
- if (!stillActive) close();
95
+ graceTimer = setTimeout(() => {
96
+ graceTimer = null;
97
+ closeUnlessWanted();
98
+ }, CARD_CONSTANTS.travelGraceMs);
99
+ }
100
+
101
+ function peekTriggerAt(point: Point): HTMLElement | null {
102
+ return (
103
+ document
104
+ .elementFromPoint(point.x, point.y)
105
+ ?.closest<HTMLElement>('[data-hover-peek-wired="true"]') ?? null
106
+ );
107
+ }
108
+
109
+ /** The pointer left `trigger` at `apex`, perhaps on its way to the peek. */
110
+ function travelFrom(trigger: HTMLElement, apex: Point): void {
111
+ cancelLeave();
112
+ let own: Trip | null = null;
113
+ const release = () => {
114
+ if (trip === own) trip = null;
115
+ };
116
+ own = startTrip(apex, {
117
+ graceMs: CARD_CONSTANTS.travelGraceMs,
118
+ restMs: CARD_CONSTANTS.travelRestMs,
119
+ tripMaxMs: CARD_CONSTANTS.travelTripMaxMs,
120
+ cardBox: () => (panel && !panel.hidden ? panel.getBoundingClientRect() : null),
121
+ triggerBox: () => (trigger.isConnected ? trigger.getBoundingClientRect() : null),
122
+ onExit: () => {
123
+ release();
124
+ closeUnlessWanted();
125
+ },
126
+ onLand: () => {
127
+ release();
128
+ scheduleLeave();
129
+ },
130
+ onRest: (point) => {
131
+ const other = peekTriggerAt(point);
132
+ if (!other || other === trigger) return;
133
+ cancelLeave();
134
+ showFor.get(other)?.();
135
+ },
85
136
  });
137
+ trip = own.active ? own : null;
86
138
  }
87
139
 
88
140
  function position(trigger: HTMLElement, target: HTMLElement): void {
@@ -138,7 +190,18 @@ function ensurePanel(): { panel: HTMLElement; scroller: HTMLElement } {
138
190
  document.body.append(panel);
139
191
 
140
192
  panel.addEventListener('pointerenter', cancelLeave);
141
- panel.addEventListener('pointerleave', scheduleLeave);
193
+ panel.addEventListener('pointerleave', (event) => {
194
+ if (event.pointerType === 'touch') return;
195
+ // Straight back onto the trigger is the trigger's own business.
196
+ if (
197
+ activeTrigger &&
198
+ event.relatedTarget instanceof Node &&
199
+ activeTrigger.contains(event.relatedTarget)
200
+ ) {
201
+ return;
202
+ }
203
+ scheduleLeave();
204
+ });
142
205
 
143
206
  if (!globalListenersReady) {
144
207
  globalListenersReady = true;
@@ -152,6 +215,15 @@ function ensurePanel(): { panel: HTMLElement; scroller: HTMLElement } {
152
215
  const insidePanel = !!target && panel.contains(target);
153
216
  if (!insideTrigger && !insidePanel) close();
154
217
  });
218
+ document.addEventListener(
219
+ 'pointermove',
220
+ (event) => {
221
+ if (event.pointerType === 'touch') return;
222
+ lastPointer = { x: event.clientX, y: event.clientY };
223
+ trip?.move(lastPointer);
224
+ },
225
+ { passive: true },
226
+ );
155
227
  }
156
228
 
157
229
  return { panel, scroller };
@@ -174,29 +246,37 @@ export function attachHoverPeek(
174
246
 
175
247
  const show = () => {
176
248
  const { panel: el, scroller: scrollEl } = ensurePanel();
177
- if (activeTrigger === trigger) {
178
- cancelLeave();
179
- return;
180
- }
249
+ cancelLeave();
250
+ if (activeTrigger === trigger) return;
181
251
  activeTrigger = trigger;
182
252
  el.setAttribute('aria-label', label);
183
253
  el.style.setProperty('--hover-peek-max-height', `${maxHeightEm}em`);
184
254
  scrollEl.replaceChildren(options.getContent());
185
255
  position(trigger, el);
186
256
  };
187
-
188
- const leave = () => {
189
- if (activeTrigger === trigger) scheduleLeave();
190
- };
257
+ showFor.set(trigger, show);
191
258
 
192
259
  trigger.addEventListener('pointerenter', (event) => {
193
260
  if (event.pointerType === 'touch') return;
261
+ // Crossed on the way to another trigger's peek: held back.
262
+ if (
263
+ trip &&
264
+ activeTrigger !== trigger &&
265
+ trip.holds({ x: event.clientX, y: event.clientY })
266
+ ) {
267
+ return;
268
+ }
194
269
  show();
195
270
  });
196
271
  trigger.addEventListener('pointerleave', (event) => {
197
- if (event.pointerType === 'touch') return;
198
- leave();
272
+ if (event.pointerType === 'touch' || activeTrigger !== trigger) return;
273
+ if (panel && event.relatedTarget instanceof Node && panel.contains(event.relatedTarget)) {
274
+ return;
275
+ }
276
+ travelFrom(trigger, lastPointer ?? { x: event.clientX, y: event.clientY });
199
277
  });
200
278
  trigger.addEventListener('focus', show);
201
- trigger.addEventListener('blur', leave);
279
+ trigger.addEventListener('blur', () => {
280
+ if (activeTrigger === trigger) scheduleLeave();
281
+ });
202
282
  }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Shared open/closed persistence for a native `<details>` disclosure.
3
3
  *
4
- * Every collapsible built on top of this (figures, sections, tables) wants
4
+ * Every collapsible built on top of this (sections, the notation panel) wants
5
5
  * the same three behaviors: restore the reader's last choice before the
6
6
  * element paints, save reader-triggered native `toggle`s, and degrade to the
7
7
  * author's `defaultOpen` when nothing is saved yet or storage is unavailable.
@@ -10,8 +10,8 @@
10
10
  *
11
11
  * Storage goes through `PreferenceStore` only (never `localStorage`
12
12
  * directly — `tests/unit/seams.test.ts` enforces that boundary). The key is
13
- * scoped by `location.pathname` so the same figure/section/table `id` can
14
- * repeat across pages without one page's saved state leaking into another's.
13
+ * scoped by `location.pathname` so the same `id` can repeat across pages
14
+ * without one page's saved state leaking into another's.
15
15
  *
16
16
  * **Wiring**: call this from a plain top-level `<script>` in the consuming
17
17
  * Astro component — not from inside an `astro:page-load` listener — the same
@@ -0,0 +1,252 @@
1
+ /**
2
+ * **Print as shown.** A printed lesson keeps every reader-controlled
3
+ * disclosure in the state the reader left it on screen: an open section,
4
+ * memo, glossary, worked-example set, short note, or question prints its
5
+ * content; a closed one prints its title followed by a "(content collapsed —
6
+ * full text: <url>)" note, so a paper reader knows something was left out
7
+ * and where to read it. A figure or a diagram is not a disclosure: it always
8
+ * prints whole.
9
+ *
10
+ * Content hidden only to save screen space — overflowing table rows, the
11
+ * inactive tabs of an open worked-example set — was never the reader's
12
+ * choice to hide, and still prints in full (the global print stylesheet and
13
+ * `LessonExportActions.astro` handle those).
14
+ *
15
+ * A disclosure marked `data-print-omit` — a lab's tools (`Figure.astro`) —
16
+ * never prints, open or folded, and is left exactly as it is: no note, not
17
+ * opened for an export.
18
+ *
19
+ * Two callers share this module:
20
+ * - `LessonExportActions.astro`, the print controller a course gets with
21
+ * `lessonExports.print`: `'as-shown'` for its print button and the
22
+ * browser's own print, `'expanded'` for the Markdown export, which needs
23
+ * every word regardless of screen state.
24
+ * - `installPrintAsShownFallback`, for a course without that controller, so
25
+ * the browser's own print behaves the same way there.
26
+ *
27
+ * A closed native `<details>` never renders its body, whatever the author
28
+ * CSS says, so "as shown" mostly means *not* opening anything. What this
29
+ * does have to fix up is transient state: a section mid-hover-peek or
30
+ * mid-animation has `details.open === true` while reading as closed on
31
+ * screen, so its `data-state` — not `.open` — decides.
32
+ */
33
+
34
+ export type DisclosureExportMode = 'as-shown' | 'expanded';
35
+
36
+ export interface PrepareDisclosureOptions {
37
+ /** Absolute URL of this page, at `#anchorId` when one is given. */
38
+ noteHref(anchorId: string | undefined): string;
39
+ }
40
+
41
+ export interface PreparedDisclosures {
42
+ /** Put every disclosure back exactly as the reader left it and remove the notes. */
43
+ restore(): void;
44
+ /**
45
+ * Drop the temporary marker that keeps disclosure persistence from saving
46
+ * the prepare/restore toggles. Native `toggle` events are queued, so call
47
+ * this one task after `restore()`, not in the same one.
48
+ */
49
+ releaseMarkers(): void;
50
+ }
51
+
52
+ export const COLLAPSED_NOTE_CLASS = 'explico-collapsed-note';
53
+
54
+ /**
55
+ * Whether a disclosure reads as open on screen. A component that animates
56
+ * its own open/close (a section) keeps `data-state` as the truth —
57
+ * `peeking` and a collapse still in flight both read as closed.
58
+ */
59
+ export function isShownOpen(open: boolean, state: string | undefined): boolean {
60
+ return state === undefined ? open : state === 'open';
61
+ }
62
+
63
+ /** `https://host/path/#id` → `host/path/#id`: what a paper reader types. */
64
+ export function displayUrl(href: string): string {
65
+ return href.replace(/^https?:\/\//u, '');
66
+ }
67
+
68
+ /** `id` values React's `useId` generates are not stable across builds. */
69
+ function isStableId(id: string): boolean {
70
+ return /^[A-Za-z]/u.test(id);
71
+ }
72
+
73
+ /**
74
+ * The permalink a note points at: the disclosure's own anchor if it has
75
+ * one, otherwise the nearest enclosing one (a question's knowledge-check
76
+ * section, say). A collapsible section's anchor is its heading's id.
77
+ */
78
+ function anchorIdFor(element: Element): string | undefined {
79
+ for (
80
+ let node: Element | null = element;
81
+ node && node.tagName !== 'MAIN';
82
+ node = node.parentElement
83
+ ) {
84
+ if (node.matches('details.sl-section-collapsible')) {
85
+ const heading = node.querySelector(':scope > summary > [id]');
86
+ if (heading && isStableId(heading.id)) return heading.id;
87
+ }
88
+ if (node.id && isStableId(node.id)) return node.id;
89
+ }
90
+ return undefined;
91
+ }
92
+
93
+ function createNote(href: string): HTMLElement {
94
+ const note = document.createElement('div');
95
+ note.className = COLLAPSED_NOTE_CLASS;
96
+ const link = document.createElement('a');
97
+ link.href = href;
98
+ link.textContent = displayUrl(href);
99
+ note.append('(content collapsed — full text: ', link, ')');
100
+ return note;
101
+ }
102
+
103
+ type SavedDetails = {
104
+ readonly element: HTMLDetailsElement;
105
+ readonly open: boolean;
106
+ readonly state: string | undefined;
107
+ readonly style: string | null;
108
+ readonly body: HTMLElement | null;
109
+ readonly bodyStyle: string | null;
110
+ readonly temporaryMarker: string | null;
111
+ };
112
+
113
+ export function prepareDisclosures(
114
+ mode: DisclosureExportMode,
115
+ options: PrepareDisclosureOptions,
116
+ ): PreparedDisclosures {
117
+ const main = document.querySelector('main');
118
+ const allDetails = main
119
+ ? Array.from(main.querySelectorAll<HTMLDetailsElement>('details:not([data-print-omit])'))
120
+ : [];
121
+
122
+ const saved: SavedDetails[] = allDetails.map((element) => {
123
+ const body = element.querySelector<HTMLElement>(':scope > .sl-section-body');
124
+ const record: SavedDetails = {
125
+ element,
126
+ open: element.open,
127
+ state: element.dataset.state,
128
+ style: element.getAttribute('style'),
129
+ body,
130
+ bodyStyle: body?.getAttribute('style') ?? null,
131
+ temporaryMarker: element.getAttribute('data-explico-temporary-open'),
132
+ };
133
+ const open =
134
+ mode === 'expanded' || isShownOpen(element.open, element.dataset.state);
135
+ element.dataset.explicoTemporaryOpen = mode;
136
+ element.open = open;
137
+ if (element.dataset.state !== undefined) {
138
+ element.dataset.state = open ? 'open' : 'closed';
139
+ }
140
+ // A peek or an open/close animation leaves an inline `max-height` (and a
141
+ // clipping `overflow`) behind; the printed box needs its natural height.
142
+ body?.style.removeProperty('max-height');
143
+ body?.style.removeProperty('overflow');
144
+ body?.style.removeProperty('transition');
145
+ return record;
146
+ });
147
+
148
+ const notes: HTMLElement[] = [];
149
+ if (main && mode === 'as-shown') {
150
+ // Only the outermost closed disclosure gets a note: anything nested in
151
+ // it is already out of the printed page along with it.
152
+ const insideClosed = (element: Element) =>
153
+ !!element.parentElement?.closest('details:not([open])');
154
+
155
+ for (const details of main.querySelectorAll('details:not([open]):not([data-print-omit])')) {
156
+ if (insideClosed(details)) continue;
157
+ const note = createNote(options.noteHref(anchorIdFor(details)));
158
+ details.after(note);
159
+ notes.push(note);
160
+ }
161
+ // Disclosures built on `hidden` rather than `<details>`.
162
+ for (const element of main.querySelectorAll(
163
+ ':is(.short-note, .assessment-panel)[hidden]',
164
+ )) {
165
+ if (insideClosed(element)) continue;
166
+ const note = createNote(options.noteHref(anchorIdFor(element)));
167
+ element.before(note);
168
+ notes.push(note);
169
+ }
170
+ }
171
+
172
+ return {
173
+ restore() {
174
+ for (const note of notes) note.remove();
175
+ for (const state of saved) {
176
+ state.element.open = state.open;
177
+ if (state.state === undefined) delete state.element.dataset.state;
178
+ else state.element.dataset.state = state.state;
179
+ if (state.style === null) state.element.removeAttribute('style');
180
+ else state.element.setAttribute('style', state.style);
181
+ if (state.body) {
182
+ if (state.bodyStyle === null) state.body.removeAttribute('style');
183
+ else state.body.setAttribute('style', state.bodyStyle);
184
+ }
185
+ }
186
+ },
187
+ releaseMarkers() {
188
+ for (const state of saved) {
189
+ if (state.temporaryMarker === null) {
190
+ state.element.removeAttribute('data-explico-temporary-open');
191
+ } else {
192
+ state.element.setAttribute(
193
+ 'data-explico-temporary-open',
194
+ state.temporaryMarker,
195
+ );
196
+ }
197
+ }
198
+ },
199
+ };
200
+ }
201
+
202
+ /** This page's own URL (no canonical rewrite available) at `#anchorId`. */
203
+ function pageHref(anchorId: string | undefined): string {
204
+ const url = new URL(location.href);
205
+ url.hash = anchorId ? `#${anchorId}` : '';
206
+ return url.href;
207
+ }
208
+
209
+ /**
210
+ * Print-as-shown for the browser's own print on a page without the print
211
+ * controller. Idempotent across every component that calls it — a flag on
212
+ * `<html>` rather than module state, since separately bundled scripts may
213
+ * each carry their own copy of this module.
214
+ */
215
+ export function installPrintAsShownFallback(): void {
216
+ const root = document.documentElement;
217
+ if (root.hasAttribute('data-explico-print-as-shown')) return;
218
+ root.setAttribute('data-explico-print-as-shown', '');
219
+
220
+ let prepared: PreparedDisclosures | null = null;
221
+ let pendingRelease: (() => void) | null = null;
222
+ const hasController = () =>
223
+ !!document.querySelector('[data-explico-print-controller]');
224
+
225
+ const enter = () => {
226
+ if (prepared || hasController()) return;
227
+ pendingRelease?.();
228
+ prepared = prepareDisclosures('as-shown', { noteHref: pageHref });
229
+ };
230
+ const leave = () => {
231
+ const done = prepared;
232
+ if (!done) return;
233
+ prepared = null;
234
+ done.restore();
235
+ const release = () => {
236
+ if (pendingRelease !== release) return;
237
+ done.releaseMarkers();
238
+ pendingRelease = null;
239
+ };
240
+ pendingRelease = release;
241
+ window.setTimeout(release, 0);
242
+ };
243
+
244
+ const printMedia = window.matchMedia('print');
245
+ printMedia.addEventListener('change', (event) => {
246
+ if (event.matches) enter();
247
+ else leave();
248
+ });
249
+ window.addEventListener('beforeprint', enter);
250
+ window.addEventListener('afterprint', leave);
251
+ if (printMedia.matches) enter();
252
+ }
@@ -28,7 +28,9 @@ if (typeof label !== 'string' || !label.trim()) {
28
28
  margin-block-start: 0;
29
29
  }
30
30
 
31
- .compact-example > :last-child {
31
+ /* `:global`: the last child is slotted MDX content, which a scoped
32
+ selector never matches — a closing display equation kept its margin. */
33
+ .compact-example > :global(:last-child) {
32
34
  margin-block-end: 0;
33
35
  }
34
36
  </style>