explico 0.1.1 → 0.3.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 (94) hide show
  1. package/CHANGELOG.md +102 -0
  2. package/README.md +46 -5
  3. package/dist/compiler/cli.d.ts +17 -0
  4. package/dist/compiler/cli.js +41 -3
  5. package/dist/compiler/collections.js +31 -1
  6. package/dist/compiler/course-config.d.ts +52 -1
  7. package/dist/compiler/knowledge-version.d.ts +58 -0
  8. package/dist/compiler/knowledge-version.js +80 -0
  9. package/dist/compiler/manifest.d.ts +87 -3
  10. package/dist/compiler/manifest.js +133 -20
  11. package/dist/compiler/resolution-report.d.ts +5 -0
  12. package/dist/reference/base-operators.d.mts +38 -0
  13. package/dist/reference/base-operators.mjs +103 -0
  14. package/dist/reference/consistency.d.ts +7 -2
  15. package/dist/reference/consistency.js +142 -2
  16. package/dist/reference/diagram-layout.d.ts +63 -0
  17. package/dist/reference/diagram-layout.js +122 -0
  18. package/dist/reference/equations-validate.d.ts +5 -0
  19. package/dist/reference/equations-validate.js +1 -1
  20. package/dist/reference/equations.d.mts +76 -18
  21. package/dist/reference/equations.mjs +118 -41
  22. package/dist/reference/figures-validate.d.ts +21 -0
  23. package/dist/reference/figures-validate.js +13 -0
  24. package/dist/reference/gate-math.d.ts +5 -4
  25. package/dist/reference/gate-math.js +73 -2
  26. package/dist/reference/gloss.d.ts +87 -0
  27. package/dist/reference/gloss.js +59 -0
  28. package/dist/reference/keyed-float-validate.d.ts +67 -0
  29. package/dist/reference/keyed-float-validate.js +110 -0
  30. package/dist/reference/keyed-jsx-floats.d.mts +56 -0
  31. package/dist/reference/keyed-jsx-floats.mjs +167 -0
  32. package/dist/reference/math-glyphs.d.mts +18 -0
  33. package/dist/reference/math-glyphs.mjs +92 -0
  34. package/dist/reference/registry.js +25 -0
  35. package/dist/reference/rehype-heading-anchors.d.mts +1 -0
  36. package/dist/reference/rehype-heading-anchors.mjs +144 -0
  37. package/dist/reference/remark-notation.d.mts +12 -0
  38. package/dist/reference/remark-notation.mjs +228 -32
  39. package/dist/reference/tables-validate.d.ts +21 -0
  40. package/dist/reference/tables-validate.js +13 -0
  41. package/dist/reference/types.d.ts +25 -2
  42. package/dist/reference/units-vocab.js +19 -1
  43. package/docs/VERSIONING.md +66 -0
  44. package/docs/authoring/README.md +96 -0
  45. package/docs/authoring/agents.md +63 -0
  46. package/docs/authoring/migrating.md +107 -0
  47. package/docs/authoring/notation.md +296 -0
  48. package/docs/authoring/versioning.md +116 -0
  49. package/docs/authoring/visualizations.md +195 -0
  50. package/package.json +20 -5
  51. package/src/compiler/cli.ts +49 -0
  52. package/src/compiler/collections.ts +52 -3
  53. package/src/compiler/course-config.ts +54 -0
  54. package/src/compiler/knowledge-version.ts +157 -0
  55. package/src/compiler/manifest.ts +270 -17
  56. package/src/components/CurriculumMap.astro +27 -0
  57. package/src/components/HoverPanel.astro +42 -0
  58. package/src/components/citation/CitationLayer.astro +1 -0
  59. package/src/components/diagram/Diagram.astro +317 -0
  60. package/src/components/examples/CompactExamples.astro +1 -1
  61. package/src/components/figure/Figure.astro +104 -0
  62. package/src/components/hover-panel.ts +71 -5
  63. package/src/components/notation/NotationFormula.astro +154 -0
  64. package/src/components/notation/NotationGlossary.astro +60 -0
  65. package/src/components/notation/NotationLayer.astro +248 -103
  66. package/src/components/notation/render-notation-math.ts +66 -0
  67. package/src/components/popup-stack.ts +345 -0
  68. package/src/components/reference/FloatRefLayer.astro +671 -0
  69. package/src/components/starlight/AboutPanel.astro +255 -0
  70. package/src/components/starlight/HeadingAnchorEnhancer.astro +131 -0
  71. package/src/components/starlight/KeyedFloatEnhancer.astro +137 -0
  72. package/src/components/starlight/LayoutSocialIcons.astro +12 -0
  73. package/src/components/starlight/LessonFooter.astro +8 -2
  74. package/src/components/table/Table.astro +104 -0
  75. package/src/content-config.ts +38 -0
  76. package/src/reference/base-operators.mjs +103 -0
  77. package/src/reference/consistency.ts +236 -2
  78. package/src/reference/diagram-layout.ts +246 -0
  79. package/src/reference/equations-validate.ts +6 -1
  80. package/src/reference/equations.mjs +118 -41
  81. package/src/reference/figures-validate.ts +40 -0
  82. package/src/reference/gate-math.ts +104 -7
  83. package/src/reference/gloss.ts +153 -0
  84. package/src/reference/keyed-float-validate.ts +207 -0
  85. package/src/reference/keyed-jsx-floats.mjs +167 -0
  86. package/src/reference/math-glyphs.mjs +92 -0
  87. package/src/reference/registry.ts +43 -0
  88. package/src/reference/rehype-heading-anchors.mjs +144 -0
  89. package/src/reference/remark-notation.mjs +228 -32
  90. package/src/reference/tables-validate.ts +40 -0
  91. package/src/reference/types.ts +26 -1
  92. package/src/reference/units-vocab.ts +18 -0
  93. package/src/styles/global.css +193 -3
  94. package/src/components/starlight/EquationEnhancer.astro +0 -72
@@ -0,0 +1,671 @@
1
+ ---
2
+ /**
3
+ * Progressive enhancement for `[[eq-key]]` / `[[tab-key]]` / `[[fig-key]]`
4
+ * references in prose (F4, extended F-tables-figures) — one hover/pin/jump
5
+ * engine shared by all three keyed floats. This generalizes the original
6
+ * equation-only `EquationRefLayer`; the positioning, mutual-exclusion, and
7
+ * pin/jump/Escape mechanics are unchanged, parameterized over `KIND_CONFIG`
8
+ * below so a hover on any of the three trigger classes is handled the same
9
+ * way.
10
+ *
11
+ * The no-JS baseline already works for all three: `a.equation-ref` /
12
+ * `a.table-ref` / `a.figure-ref` are real links to `#eq-`/`#tab-`/`#fig-`
13
+ * anchors, and `KeyedFloatEnhancer.astro` + `global.css`'s `:target` rules
14
+ * handle the landing. This layer adds a *preview*, so pointing at a
15
+ * reference in the prose doesn't require leaving the sentence to see what
16
+ * it points to:
17
+ *
18
+ * - if the referenced float is on the current page and already fully on
19
+ * screen, it is highlighted in place instead of opening a card;
20
+ * - otherwise a small card floats near the reference showing its number,
21
+ * caption (table/figure), content, and a "Jump to …" link.
22
+ *
23
+ * What the card's content shows differs by kind:
24
+ * - equation: the KaTeX formula, cloned from the target (unchanged from the
25
+ * original `EquationRefLayer` behavior).
26
+ * - table: the rendered `<table>`, cloned from the target.
27
+ * - figure: cloned only when the `<Figure>` was authored `previewable`
28
+ * (the default) — an interactive `client:*` island set
29
+ * `previewable={false}` shows its caption and number only, since a
30
+ * cloned copy would be an inert, non-hydrated, id-colliding duplicate,
31
+ * not a working preview.
32
+ *
33
+ * Cross-page references (`[[lesson#eq-key]]` etc.) get the same card: their
34
+ * `href` is a real path to another lesson page, so `showCrossPage` fetches
35
+ * and parses that page once (cached), pulls the `#eq-…`/`#tab-…`/`#fig-…`
36
+ * node out of it, and fills the card exactly as above (cloning across
37
+ * documents via `importNode`). The card always opens — there is nothing in
38
+ * this document to highlight in place — and clicking the reference navigates
39
+ * to the other page, where `KeyedFloatEnhancer.astro` handles the landing.
40
+ *
41
+ * This is a single-panel engine, not the notation layer's stack
42
+ * (`popup-stack.ts`): only one card is ever open at a time — now shared
43
+ * across all three kinds, not just equations — which is also why it's
44
+ * hand-rolled here rather than built on `createHoverPanel`.
45
+ * `claimActiveSurface`/`releaseActiveSurface` are still reused so an open
46
+ * float card and an open citation or notation popup remain mutually
47
+ * exclusive.
48
+ */
49
+ import HoverPanel from '../HoverPanel.astro';
50
+ ---
51
+
52
+ <HoverPanel id="float-ref-panel" label="Reference preview" data-float-ref-panel>
53
+ <div class="float-ref-panel-body">
54
+ <div class="float-ref-panel-main">
55
+ <div class="float-ref-panel-content" data-panel-content></div>
56
+ <a class="float-ref-panel-number" data-panel-number></a>
57
+ </div>
58
+ <p class="float-ref-panel-caption" data-panel-caption></p>
59
+ <a class="float-ref-panel-jump" data-panel-jump
60
+ >Jump to <span data-panel-jump-label></span
61
+ ><span aria-hidden="true"> ↗</span></a
62
+ >
63
+ </div>
64
+ </HoverPanel>
65
+
66
+ <script>
67
+ import { claimActiveSurface, releaseActiveSurface } from '../hover-panel';
68
+
69
+ const GROUP = 'float-ref';
70
+
71
+ interface KindConfig {
72
+ readonly triggerClass: string;
73
+ readonly hrefPrefix: string;
74
+ readonly numberClass: string;
75
+ readonly numberDataKey: string;
76
+ readonly highlightClass: string;
77
+ readonly jumpLabel: string;
78
+ /** Only the equation number badge fades until hovered/visited (F4) — a
79
+ * table/figure number is always visible in its caption, so pinning one
80
+ * has nothing to reveal. */
81
+ readonly revealOnPin: boolean;
82
+ fillContent(target: HTMLElement, contentEl: HTMLElement): void;
83
+ captionFor(target: HTMLElement): string;
84
+ }
85
+
86
+ // `importNode`, not `cloneNode`: `target` may come from a *different*
87
+ // document (a cross-page reference's target, parsed from a fetched page —
88
+ // see `fetchDoc`), and only `importNode` adopts it into this document.
89
+ function cloneEquationFormula(target: HTMLElement): HTMLElement {
90
+ const clone = document.importNode(target, true);
91
+ clone.removeAttribute('id');
92
+ clone.removeAttribute('data-eq-number');
93
+ clone.removeAttribute('data-eq-key');
94
+ clone.removeAttribute('data-eq-auto');
95
+ clone.classList.remove('keyed-equation', 'eq-ref-highlight');
96
+ clone.querySelector('.keyed-equation__number')?.remove();
97
+ return clone;
98
+ }
99
+
100
+ function cloneBody(target: HTMLElement, bodyClass: string): HTMLElement | null {
101
+ const body = target.querySelector<HTMLElement>(`.${bodyClass}`);
102
+ return body ? document.importNode(body, true) : null;
103
+ }
104
+
105
+ const KINDS: Record<'equation' | 'table' | 'figure', KindConfig> = {
106
+ equation: {
107
+ triggerClass: 'equation-ref',
108
+ hrefPrefix: '#eq-',
109
+ numberClass: 'keyed-equation__number',
110
+ numberDataKey: 'eqNumber',
111
+ highlightClass: 'eq-ref-highlight',
112
+ jumpLabel: 'equation',
113
+ revealOnPin: true,
114
+ fillContent(target, contentEl) {
115
+ contentEl.replaceChildren(cloneEquationFormula(target));
116
+ },
117
+ captionFor() {
118
+ return '';
119
+ },
120
+ },
121
+ table: {
122
+ triggerClass: 'table-ref',
123
+ hrefPrefix: '#tab-',
124
+ numberClass: 'keyed-table__number',
125
+ numberDataKey: 'tabNumber',
126
+ highlightClass: 'tab-ref-highlight',
127
+ jumpLabel: 'table',
128
+ revealOnPin: false,
129
+ fillContent(target, contentEl) {
130
+ const clone = cloneBody(target, 'keyed-table__body');
131
+ contentEl.replaceChildren(...(clone ? [clone] : []));
132
+ },
133
+ captionFor(target) {
134
+ return (
135
+ target.querySelector('.keyed-table__caption-text')?.textContent ?? ''
136
+ );
137
+ },
138
+ },
139
+ figure: {
140
+ triggerClass: 'figure-ref',
141
+ hrefPrefix: '#fig-',
142
+ numberClass: 'keyed-figure__number',
143
+ numberDataKey: 'figNumber',
144
+ highlightClass: 'fig-ref-highlight',
145
+ jumpLabel: 'figure',
146
+ revealOnPin: false,
147
+ fillContent(target, contentEl) {
148
+ const previewable = target.dataset.figPreviewable !== 'false';
149
+ const clone = previewable ? cloneBody(target, 'keyed-figure__body') : null;
150
+ contentEl.replaceChildren(...(clone ? [clone] : []));
151
+ },
152
+ captionFor(target) {
153
+ return (
154
+ target.querySelector('.keyed-figure__caption-text')?.textContent ?? ''
155
+ );
156
+ },
157
+ },
158
+ };
159
+
160
+ const TRIGGER_SELECTOR = Object.values(KINDS)
161
+ .map((kind) => `a.${kind.triggerClass}`)
162
+ .join(', ');
163
+
164
+ function closestElement(node: EventTarget | null, selector: string) {
165
+ return node instanceof Element ? node.closest<HTMLElement>(selector) : null;
166
+ }
167
+
168
+ function kindOf(trigger: HTMLElement): KindConfig | undefined {
169
+ return Object.values(KINDS).find((kind) =>
170
+ trigger.classList.contains(kind.triggerClass),
171
+ );
172
+ }
173
+
174
+ /**
175
+ * A `[[lesson#eq-key]]` reference: its `href` is a real path to another page
176
+ * (`/motion/constant-acceleration/#eq-velocity`), not the bare `#eq-…`
177
+ * fragment a same-page reference carries. `remark-notation` also stamps a
178
+ * `data-<prefix>-lesson` attribute, so either signal identifies one.
179
+ */
180
+ function crossPageHref(
181
+ trigger: HTMLElement,
182
+ kind: KindConfig,
183
+ ): { page: string; id: string } | null {
184
+ const href = trigger.getAttribute('href') ?? '';
185
+ if (href.startsWith('#')) return null;
186
+ const hash = href.indexOf('#');
187
+ if (hash === -1) return null;
188
+ const id = href.slice(hash + 1);
189
+ if (!id.startsWith(kind.hrefPrefix.slice(1))) return null;
190
+ return { page: href.slice(0, hash), id };
191
+ }
192
+
193
+ /**
194
+ * Parsed target pages, keyed by path. A cross-page preview fetches the
195
+ * referenced lesson once and reuses it for every later hover on this page.
196
+ * A failed fetch drops its entry so a later hover can retry.
197
+ */
198
+ const pageCache = new Map<string, Promise<Document>>();
199
+
200
+ function fetchDoc(page: string): Promise<Document> {
201
+ let pending = pageCache.get(page);
202
+ if (!pending) {
203
+ pending = fetch(page, { credentials: 'same-origin' })
204
+ .then((response) => {
205
+ if (!response.ok) throw new Error(`${response.status} for ${page}`);
206
+ return response.text();
207
+ })
208
+ .then((html) => new DOMParser().parseFromString(html, 'text/html'))
209
+ .catch((error) => {
210
+ pageCache.delete(page);
211
+ throw error;
212
+ });
213
+ pageCache.set(page, pending);
214
+ }
215
+ return pending;
216
+ }
217
+
218
+ /**
219
+ * A cloned equation preview needs KaTeX's stylesheet to render. Every page
220
+ * that *authors* math already loads it; this only matters for a page whose
221
+ * sole math is a cross-page reference, so copy the `<link>` from the fetched
222
+ * document when this one has none.
223
+ */
224
+ function ensureKatexStylesheet(sourceDoc: Document): void {
225
+ const selector = 'link[rel="stylesheet"][href*="katex"]';
226
+ if (document.querySelector(selector)) return;
227
+ const link = sourceDoc.querySelector<HTMLLinkElement>(selector);
228
+ if (link) document.head.appendChild(document.importNode(link, true));
229
+ }
230
+
231
+ function setup() {
232
+ const panel = document.querySelector<HTMLElement>('[data-float-ref-panel]')!;
233
+ if (!panel || panel.dataset.floatRefReady === 'true') return;
234
+
235
+ const refs = Array.from(
236
+ document.querySelectorAll<HTMLAnchorElement>(
237
+ Object.values(KINDS)
238
+ .map((kind) => `.sl-markdown-content a.${kind.triggerClass}`)
239
+ .join(', '),
240
+ ),
241
+ ).filter((a) => {
242
+ const kind = kindOf(a);
243
+ if (!kind) return false;
244
+ // Same-page (`#eq-…`) or a resolvable cross-page (`/lesson/#eq-…`) ref.
245
+ return (
246
+ (a.getAttribute('href') ?? '').startsWith(kind.hrefPrefix) ||
247
+ crossPageHref(a, kind) !== null
248
+ );
249
+ });
250
+ if (refs.length === 0) return;
251
+ panel.dataset.floatRefReady = 'true';
252
+
253
+ const refSet = new Set<HTMLElement>(refs);
254
+ const numberEl = panel.querySelector<HTMLAnchorElement>('[data-panel-number]')!;
255
+ const contentEl = panel.querySelector<HTMLElement>('[data-panel-content]')!;
256
+ const captionEl = panel.querySelector<HTMLElement>('[data-panel-caption]')!;
257
+ const jumpEl = panel.querySelector<HTMLAnchorElement>('[data-panel-jump]')!;
258
+ const jumpLabelEl = panel.querySelector<HTMLElement>('[data-panel-jump-label]')!;
259
+ const closeButton = panel.querySelector<HTMLElement>('[data-hover-panel-close]');
260
+
261
+ let activeTrigger: HTMLElement | null = null;
262
+ let activeTarget: HTMLElement | null = null;
263
+ let activeKind: KindConfig | null = null;
264
+ let mode: 'highlight' | 'panel' | null = null;
265
+ let pinned = false;
266
+ let leaveTimer: number | null = null;
267
+
268
+ const targetFor = (trigger: HTMLElement, kind: KindConfig) => {
269
+ const href = trigger.getAttribute('href') ?? '';
270
+ return href.startsWith(kind.hrefPrefix)
271
+ ? document.getElementById(href.slice(1))
272
+ : null;
273
+ };
274
+
275
+ const isFullyVisible = (el: HTMLElement) => {
276
+ const rect = el.getBoundingClientRect();
277
+ const headerBottom =
278
+ document
279
+ .querySelector<HTMLElement>('.header')
280
+ ?.getBoundingClientRect().bottom ?? 0;
281
+ const top = Math.max(0, headerBottom);
282
+ return (
283
+ rect.top >= top &&
284
+ rect.left >= 0 &&
285
+ rect.bottom <= window.innerHeight &&
286
+ rect.right <= window.innerWidth
287
+ );
288
+ };
289
+
290
+ const reveal = (target: HTMLElement | null, kind: KindConfig) => {
291
+ if (!target || !kind.revealOnPin) return;
292
+ target.querySelector(`.${kind.numberClass}`)?.classList.add('eq-number-revealed');
293
+ };
294
+
295
+ function position(trigger: HTMLElement) {
296
+ const edge = 12;
297
+ const gap = 10;
298
+ const displayEquation = trigger.closest<HTMLElement>('.katex-display');
299
+ const safeRegion = displayEquation ?? trigger;
300
+ const regionRect = safeRegion.getBoundingClientRect();
301
+ const anchorRect = trigger.getBoundingClientRect();
302
+
303
+ const headerBottom =
304
+ document
305
+ .querySelector<HTMLElement>('.header')
306
+ ?.getBoundingClientRect().bottom ?? 0;
307
+ const verticalStart = Math.max(edge, headerBottom + edge);
308
+ const verticalEnd = window.innerHeight - edge;
309
+ const spaceAbove = regionRect.top - gap - verticalStart;
310
+ const spaceBelow = verticalEnd - regionRect.bottom - gap;
311
+
312
+ panel.style.maxHeight = `${Math.max(spaceAbove, spaceBelow, 0)}px`;
313
+ let rect = panel.getBoundingClientRect();
314
+ const placeBelow = spaceBelow >= rect.height || spaceBelow >= spaceAbove;
315
+ panel.style.maxHeight = `${Math.max(placeBelow ? spaceBelow : spaceAbove, 0)}px`;
316
+ rect = panel.getBoundingClientRect();
317
+
318
+ let top = placeBelow
319
+ ? regionRect.bottom + gap
320
+ : regionRect.top - gap - rect.height;
321
+ let left = anchorRect.left + anchorRect.width / 2 - rect.width / 2;
322
+
323
+ const article = document.querySelector<HTMLElement>('.sl-markdown-content');
324
+ const articleRect = article?.getBoundingClientRect();
325
+ const horizontalStart = Math.max(edge, articleRect?.left ?? edge);
326
+ const horizontalEnd = Math.min(
327
+ window.innerWidth - edge,
328
+ articleRect?.right ?? window.innerWidth - edge,
329
+ );
330
+
331
+ top = Math.max(verticalStart, Math.min(top, verticalEnd - rect.height));
332
+ left =
333
+ horizontalEnd - horizontalStart >= rect.width
334
+ ? Math.max(horizontalStart, Math.min(left, horizontalEnd - rect.width))
335
+ : Math.max(edge, Math.min(left, window.innerWidth - rect.width - edge));
336
+
337
+ panel.style.top = `${Math.round(top)}px`;
338
+ panel.style.left = `${Math.round(left)}px`;
339
+ }
340
+
341
+ function fillPanel(trigger: HTMLElement, target: HTMLElement, kind: KindConfig) {
342
+ const number = (target.dataset[kind.numberDataKey] ?? '') as string;
343
+ const label = kind.jumpLabel;
344
+ const href = trigger.getAttribute('href') ?? '';
345
+ // The number sits to the right of the content and is itself the jump link
346
+ // — for an equation just "(2.1)", for a table/figure the labelled form.
347
+ numberEl.textContent =
348
+ kind.jumpLabel === 'equation'
349
+ ? `(${number})`
350
+ : `${label.charAt(0).toUpperCase()}${label.slice(1)} ${number}`;
351
+ numberEl.href = href;
352
+ numberEl.title = `Jump to the ${label}`;
353
+ kind.fillContent(target, contentEl);
354
+ captionEl.textContent = kind.captionFor(target);
355
+ captionEl.hidden = captionEl.textContent.length === 0;
356
+ jumpLabelEl.textContent = label;
357
+ jumpEl.href = href;
358
+ }
359
+
360
+ function clearAll() {
361
+ if (activeTarget && activeKind) {
362
+ activeTarget.classList.remove(activeKind.highlightClass);
363
+ }
364
+ if (mode === 'panel') {
365
+ panel.hidden = true;
366
+ releaseActiveSurface(GROUP);
367
+ }
368
+ activeTrigger = null;
369
+ activeTarget = null;
370
+ activeKind = null;
371
+ mode = null;
372
+ pinned = false;
373
+ }
374
+
375
+ /**
376
+ * Pin the open card and hand the exclusivity group back: a pinned card is a
377
+ * little workspace the reader dwells in, so hovering a symbol *inside* it
378
+ * must be free to open its notation popup without the surface arbiter
379
+ * treating that as "attention moved elsewhere" and closing the card. The
380
+ * card still closes on its "×", on an outside click, or on Escape.
381
+ */
382
+ function pinPanel(target: HTMLElement | null, kind: KindConfig) {
383
+ pinned = true;
384
+ reveal(target, kind);
385
+ releaseActiveSurface(GROUP);
386
+ }
387
+
388
+ function show(trigger: HTMLElement, pin: boolean) {
389
+ const kind = kindOf(trigger);
390
+ if (!kind) return;
391
+
392
+ const crossPage = crossPageHref(trigger, kind);
393
+ if (crossPage) {
394
+ void showCrossPage(trigger, kind, crossPage);
395
+ return;
396
+ }
397
+
398
+ const target = targetFor(trigger, kind);
399
+ if (!target) return;
400
+
401
+ if (pinned && trigger !== activeTrigger) return;
402
+
403
+ if (trigger === activeTrigger) {
404
+ if (pin) pinPanel(activeTarget, kind);
405
+ return;
406
+ }
407
+
408
+ clearAll();
409
+ activeTrigger = trigger;
410
+ activeTarget = target;
411
+ activeKind = kind;
412
+
413
+ if (isFullyVisible(target)) {
414
+ // Never reached with `pin: true` — the click handler only calls
415
+ // `show(trigger, true)` once it has already checked the float is
416
+ // off screen; an on-screen reference is left to navigate natively.
417
+ mode = 'highlight';
418
+ target.classList.add(kind.highlightClass);
419
+ return;
420
+ }
421
+
422
+ mode = 'panel';
423
+ fillPanel(trigger, target, kind);
424
+ claimActiveSurface(GROUP, clearAll);
425
+ panel.style.visibility = 'hidden';
426
+ panel.hidden = false;
427
+ position(trigger);
428
+ panel.style.visibility = 'visible';
429
+ if (pin) pinPanel(target, kind);
430
+ }
431
+
432
+ /**
433
+ * The cross-page counterpart of `show`: the target float lives on another
434
+ * lesson page, so there is nothing in this document to highlight in place.
435
+ * Fetch and parse that page once (`fetchDoc` caches it), pull the
436
+ * `#eq-…`/`#tab-…`/`#fig-…` node out of it, and fill the same card — the
437
+ * formula, table body, or (for a `previewable` figure) figure body, cloned
438
+ * across documents by `fillPanel`'s `importNode`-based helpers. A slow
439
+ * fetch that resolves after the pointer has moved on is discarded.
440
+ */
441
+ async function showCrossPage(
442
+ trigger: HTMLElement,
443
+ kind: KindConfig,
444
+ ref: { page: string; id: string },
445
+ ) {
446
+ if (pinned && trigger !== activeTrigger) return;
447
+ if (trigger === activeTrigger) return;
448
+
449
+ clearAll();
450
+ activeTrigger = trigger;
451
+ activeKind = kind;
452
+
453
+ let sourceDoc: Document;
454
+ try {
455
+ sourceDoc = await fetchDoc(ref.page);
456
+ } catch {
457
+ if (activeTrigger === trigger) clearAll();
458
+ return;
459
+ }
460
+ if (activeTrigger !== trigger) return;
461
+
462
+ const target = sourceDoc.getElementById(ref.id);
463
+ if (!target) {
464
+ clearAll();
465
+ return;
466
+ }
467
+
468
+ activeTarget = target; // a detached node owned by `sourceDoc`
469
+ mode = 'panel';
470
+ ensureKatexStylesheet(sourceDoc);
471
+ fillPanel(trigger, target, kind);
472
+ claimActiveSurface(GROUP, clearAll);
473
+ panel.style.visibility = 'hidden';
474
+ panel.hidden = false;
475
+ position(trigger);
476
+ panel.style.visibility = 'visible';
477
+ }
478
+
479
+ function scheduleLeave() {
480
+ if (pinned) return;
481
+ if (leaveTimer !== null) window.cancelAnimationFrame(leaveTimer);
482
+ leaveTimer = window.requestAnimationFrame(() => {
483
+ leaveTimer = null;
484
+ if (pinned || !activeTrigger) return;
485
+ const stillActive =
486
+ activeTrigger.matches(':hover') ||
487
+ activeTrigger === document.activeElement ||
488
+ panel.matches(':hover') ||
489
+ panel.contains(document.activeElement);
490
+ if (!stillActive) clearAll();
491
+ });
492
+ }
493
+
494
+ document.addEventListener('pointerover', (event) => {
495
+ if (event.pointerType === 'touch') return;
496
+ const trigger = closestElement(event.target, TRIGGER_SELECTOR);
497
+ if (!trigger || !refSet.has(trigger)) return;
498
+ if (trigger === closestElement(event.relatedTarget, TRIGGER_SELECTOR)) return;
499
+ show(trigger, false);
500
+ });
501
+
502
+ document.addEventListener('pointerout', (event) => {
503
+ if (event.pointerType === 'touch') return;
504
+ const trigger = closestElement(event.target, TRIGGER_SELECTOR);
505
+ if (!trigger || !refSet.has(trigger)) return;
506
+ if (trigger === closestElement(event.relatedTarget, TRIGGER_SELECTOR)) return;
507
+ scheduleLeave();
508
+ });
509
+
510
+ document.addEventListener('focusin', (event) => {
511
+ const trigger = closestElement(event.target, TRIGGER_SELECTOR);
512
+ if (trigger && refSet.has(trigger)) show(trigger, false);
513
+ });
514
+
515
+ document.addEventListener('focusout', (event) => {
516
+ const trigger = closestElement(event.target, TRIGGER_SELECTOR);
517
+ if (trigger && refSet.has(trigger)) scheduleLeave();
518
+ });
519
+
520
+ panel.addEventListener('pointerleave', scheduleLeave);
521
+
522
+ document.addEventListener('click', (event) => {
523
+ if (closeButton && closestElement(event.target, '[data-hover-panel-close]')) {
524
+ event.preventDefault();
525
+ clearAll();
526
+ return;
527
+ }
528
+
529
+ const trigger = closestElement(event.target, TRIGGER_SELECTOR);
530
+ if (trigger && refSet.has(trigger)) {
531
+ const kind = kindOf(trigger);
532
+ if (!kind) return;
533
+
534
+ const isCrossPage = crossPageHref(trigger, kind) !== null;
535
+
536
+ // A card that is already open for this trigger — same-page *or*
537
+ // cross-page — pins in place instead of navigating. The only way to
538
+ // the float itself is now the card's own number/"Jump to …" link.
539
+ if (mode === 'panel' && activeTrigger === trigger) {
540
+ event.preventDefault();
541
+ pinPanel(isCrossPage ? null : targetFor(trigger, kind), kind);
542
+ return;
543
+ }
544
+
545
+ if (isCrossPage) {
546
+ // No card open yet (keyboard, or a click that beat the hover): open
547
+ // it — pinned — rather than leave the page.
548
+ event.preventDefault();
549
+ const ref = crossPageHref(trigger, kind)!;
550
+ void showCrossPage(trigger, kind, ref).then(() => {
551
+ if (activeTrigger === trigger && mode === 'panel') {
552
+ pinPanel(null, kind);
553
+ }
554
+ });
555
+ return;
556
+ }
557
+
558
+ const target = targetFor(trigger, kind);
559
+ if (target && !isFullyVisible(target)) {
560
+ event.preventDefault();
561
+ show(trigger, true);
562
+ }
563
+ // Already fully visible: let the native anchor navigation through —
564
+ // `KeyedFloatEnhancer.astro` picks up the `hashchange` it produces.
565
+ return;
566
+ }
567
+
568
+ if (closestElement(event.target, '[data-panel-jump], [data-panel-number]')) {
569
+ // Real navigation to the float is the point of these links; just drop
570
+ // our own state so a stray pointerout doesn't fight it.
571
+ clearAll();
572
+ return;
573
+ }
574
+
575
+ if (mode === 'panel' && event.target instanceof Node) {
576
+ // An outside click closes the card — but a click landing in a notation
577
+ // popup that a card symbol opened is "inside" as far as the reader is
578
+ // concerned.
579
+ if (
580
+ !panel.contains(event.target) &&
581
+ !closestElement(event.target, '.notation-popup')
582
+ ) {
583
+ clearAll();
584
+ }
585
+ }
586
+ });
587
+
588
+ document.addEventListener('keydown', (event) => {
589
+ if (event.key === 'Escape' && mode) clearAll();
590
+ });
591
+
592
+ window.addEventListener(
593
+ 'resize',
594
+ () => {
595
+ if (mode === 'panel' && activeTrigger) position(activeTrigger);
596
+ },
597
+ { passive: true },
598
+ );
599
+ window.addEventListener(
600
+ 'scroll',
601
+ () => {
602
+ if (mode === 'panel' && activeTrigger) position(activeTrigger);
603
+ },
604
+ { passive: true, capture: true },
605
+ );
606
+ }
607
+
608
+ setup();
609
+ document.addEventListener('astro:page-load', setup);
610
+ </script>
611
+
612
+ <style>
613
+ @layer starlight.components {
614
+ .float-ref-panel-body {
615
+ display: grid;
616
+ gap: 0.6rem;
617
+ }
618
+
619
+ .float-ref-panel-main {
620
+ display: flex;
621
+ align-items: baseline;
622
+ justify-content: space-between;
623
+ gap: 0.75rem;
624
+ }
625
+
626
+ .float-ref-panel-number {
627
+ flex: none;
628
+ align-self: flex-start;
629
+ margin: 0;
630
+ color: var(--sl-color-gray-3);
631
+ font-weight: 600;
632
+ font-variant-numeric: tabular-nums;
633
+ text-decoration: none;
634
+ }
635
+
636
+ .float-ref-panel-number:hover,
637
+ .float-ref-panel-number:focus-visible {
638
+ color: var(--sl-color-text-accent);
639
+ text-decoration: underline;
640
+ }
641
+
642
+ .float-ref-panel-content {
643
+ min-width: 0;
644
+ overflow-x: auto;
645
+ color: var(--sl-color-text);
646
+ }
647
+
648
+ .float-ref-panel-content:empty {
649
+ display: none;
650
+ }
651
+
652
+ .float-ref-panel-content :global(.katex-display) {
653
+ margin-block: 0;
654
+ }
655
+
656
+ .float-ref-panel-content :global(table) {
657
+ margin: 0;
658
+ }
659
+
660
+ .float-ref-panel-caption {
661
+ margin: 0;
662
+ color: var(--sl-color-gray-3);
663
+ font-size: var(--sl-text-xs);
664
+ }
665
+
666
+ .float-ref-panel-jump {
667
+ justify-self: start;
668
+ font-size: var(--sl-text-xs);
669
+ }
670
+ }
671
+ </style>