explico 0.4.0 → 0.5.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 (143) hide show
  1. package/CHANGELOG.md +273 -1
  2. package/README.md +103 -8
  3. package/dist/compiler/astro.d.ts +9 -1
  4. package/dist/compiler/astro.js +182 -3
  5. package/dist/compiler/cli.d.ts +13 -0
  6. package/dist/compiler/cli.js +28 -4
  7. package/dist/compiler/collections.d.ts +3 -2
  8. package/dist/compiler/collections.js +28 -1
  9. package/dist/compiler/course-config.d.ts +58 -1
  10. package/dist/compiler/course-config.js +28 -1
  11. package/dist/compiler/exports/standalone-html.d.ts +34 -0
  12. package/dist/compiler/exports/standalone-html.js +803 -0
  13. package/dist/compiler/manifest.d.ts +90 -6
  14. package/dist/compiler/manifest.js +150 -17
  15. package/dist/compiler/resolution-report.d.ts +4 -0
  16. package/dist/reference/base-abbreviations.d.mts +30 -0
  17. package/dist/reference/base-abbreviations.mjs +60 -0
  18. package/dist/reference/base-operators.d.mts +15 -0
  19. package/dist/reference/base-operators.mjs +40 -0
  20. package/dist/reference/base-roman-labels.d.mts +35 -0
  21. package/dist/reference/base-roman-labels.mjs +43 -0
  22. package/dist/reference/consistency.d.ts +8 -2
  23. package/dist/reference/consistency.js +50 -3
  24. package/dist/reference/diagrams-validate.d.ts +21 -0
  25. package/dist/reference/diagrams-validate.js +13 -0
  26. package/dist/reference/equations.d.mts +10 -15
  27. package/dist/reference/equations.mjs +67 -14
  28. package/dist/reference/gate-abbreviations.d.ts +17 -0
  29. package/dist/reference/gate-abbreviations.js +64 -0
  30. package/dist/reference/gate-math.d.ts +28 -5
  31. package/dist/reference/gate-math.js +5 -2
  32. package/dist/reference/gate-prose-notation.d.ts +60 -0
  33. package/dist/reference/gate-prose-notation.js +125 -0
  34. package/dist/reference/keyed-float-validate.d.ts +5 -5
  35. package/dist/reference/keyed-jsx-floats.d.mts +7 -12
  36. package/dist/reference/keyed-jsx-floats.mjs +50 -25
  37. package/dist/reference/knowledge-checks-validate.d.ts +35 -0
  38. package/dist/reference/knowledge-checks-validate.js +78 -0
  39. package/dist/reference/knowledge-checks.d.mts +25 -0
  40. package/dist/reference/knowledge-checks.mjs +45 -0
  41. package/dist/reference/lab-math-scope.d.ts +9 -1
  42. package/dist/reference/lab-math-scope.js +32 -4
  43. package/dist/reference/math-glyphs.d.mts +30 -0
  44. package/dist/reference/math-glyphs.mjs +367 -0
  45. package/dist/reference/mdx-attributes.d.mts +13 -0
  46. package/dist/reference/mdx-attributes.mjs +38 -0
  47. package/dist/reference/mixed-content.d.ts +17 -0
  48. package/dist/reference/mixed-content.js +29 -0
  49. package/dist/reference/page-notation.d.ts +52 -0
  50. package/dist/reference/page-notation.js +171 -0
  51. package/dist/reference/raw-floats-validate.d.ts +18 -0
  52. package/dist/reference/raw-floats-validate.js +53 -0
  53. package/dist/reference/raw-floats.d.mts +18 -0
  54. package/dist/reference/raw-floats.mjs +129 -0
  55. package/dist/reference/rehype-heading-anchors.d.mts +1 -1
  56. package/dist/reference/rehype-heading-anchors.mjs +107 -1
  57. package/dist/reference/rehype-table-header-labels.d.mts +26 -0
  58. package/dist/reference/rehype-table-header-labels.mjs +152 -0
  59. package/dist/reference/remark-abbreviations.d.mts +7 -0
  60. package/dist/reference/remark-abbreviations.mjs +174 -0
  61. package/dist/reference/remark-memo.d.mts +16 -0
  62. package/dist/reference/remark-memo.mjs +339 -0
  63. package/dist/reference/remark-notation.d.mts +57 -9
  64. package/dist/reference/remark-notation.mjs +354 -42
  65. package/dist/reference/remark-short-note.d.mts +4 -0
  66. package/dist/reference/remark-short-note.mjs +237 -0
  67. package/dist/reference/types.d.ts +16 -1
  68. package/dist/reference/units-vocab.js +4 -0
  69. package/docs/VERSIONING.md +33 -4
  70. package/docs/authoring/README.md +8 -0
  71. package/docs/authoring/agents.md +17 -2
  72. package/docs/authoring/components.md +135 -0
  73. package/docs/authoring/migrating.md +92 -7
  74. package/docs/authoring/notation.md +560 -1
  75. package/docs/authoring/structure.md +120 -8
  76. package/docs/authoring/visualizations.md +36 -3
  77. package/package.json +5 -2
  78. package/src/compiler/astro.ts +208 -3
  79. package/src/compiler/cli.ts +29 -2
  80. package/src/compiler/collections.ts +45 -4
  81. package/src/compiler/course-config.ts +92 -0
  82. package/src/compiler/exports/standalone-html.ts +963 -0
  83. package/src/compiler/manifest.ts +268 -22
  84. package/src/components/HoverPanel.astro +45 -4
  85. package/src/components/abbreviation/AbbreviationGlossary.astro +70 -0
  86. package/src/components/abbreviation/AbbreviationLayer.astro +127 -0
  87. package/src/components/assessments/AssessmentRunner.tsx +52 -17
  88. package/src/components/assessments/AssessmentSet.astro +98 -14
  89. package/src/components/assessments/RenderedContent.tsx +55 -0
  90. package/src/components/assessments/assessment-randomization.ts +16 -0
  91. package/src/components/diagram/Diagram.astro +444 -83
  92. package/src/components/disclosure/hover-preview.ts +202 -0
  93. package/src/components/disclosure/persisted-disclosure.ts +66 -0
  94. package/src/components/examples/CompactExamples.astro +2 -0
  95. package/src/components/figure/Figure.astro +279 -10
  96. package/src/components/memo/Memo.astro +453 -0
  97. package/src/components/memo/ShortNote.astro +223 -0
  98. package/src/components/notation/NotationFormula.astro +18 -1
  99. package/src/components/notation/NotationLayer.astro +105 -8
  100. package/src/components/notation/render-notation-math.ts +39 -2
  101. package/src/components/notation/safe-math-to-react.ts +50 -0
  102. package/src/components/reference/FloatRefLayer.astro +233 -15
  103. package/src/components/starlight/CollapsibleSections.astro +461 -0
  104. package/src/components/starlight/DiagramEnhancer.astro +385 -0
  105. package/src/components/starlight/HeadingAnchorEnhancer.astro +5 -17
  106. package/src/components/starlight/KeyedFloatEnhancer.astro +81 -3
  107. package/src/components/starlight/LayoutHeader.astro +50 -0
  108. package/src/components/starlight/LayoutSocialIcons.astro +41 -2
  109. package/src/components/starlight/LessonExportActions.astro +436 -0
  110. package/src/components/starlight/LessonFooter.astro +27 -2
  111. package/src/components/starlight/section-parts.ts +31 -0
  112. package/src/components/table/Table.astro +293 -7
  113. package/src/components/table/TableOverflowExpander.astro +489 -0
  114. package/src/content-config.ts +20 -0
  115. package/src/reference/base-abbreviations.mjs +60 -0
  116. package/src/reference/base-operators.mjs +40 -0
  117. package/src/reference/base-roman-labels.mjs +43 -0
  118. package/src/reference/consistency.ts +73 -2
  119. package/src/reference/diagrams-validate.ts +40 -0
  120. package/src/reference/equations.mjs +67 -14
  121. package/src/reference/gate-abbreviations.ts +128 -0
  122. package/src/reference/gate-math.ts +27 -7
  123. package/src/reference/gate-prose-notation.ts +224 -0
  124. package/src/reference/keyed-float-validate.ts +5 -5
  125. package/src/reference/keyed-jsx-floats.mjs +50 -25
  126. package/src/reference/knowledge-checks-validate.ts +130 -0
  127. package/src/reference/knowledge-checks.mjs +45 -0
  128. package/src/reference/lab-math-scope.ts +45 -3
  129. package/src/reference/math-glyphs.mjs +367 -0
  130. package/src/reference/mdx-attributes.mjs +38 -0
  131. package/src/reference/mixed-content.ts +69 -0
  132. package/src/reference/page-notation.ts +252 -0
  133. package/src/reference/raw-floats-validate.ts +103 -0
  134. package/src/reference/raw-floats.mjs +129 -0
  135. package/src/reference/rehype-heading-anchors.mjs +107 -1
  136. package/src/reference/rehype-table-header-labels.mjs +152 -0
  137. package/src/reference/remark-abbreviations.mjs +174 -0
  138. package/src/reference/remark-memo.mjs +339 -0
  139. package/src/reference/remark-notation.mjs +354 -42
  140. package/src/reference/remark-short-note.mjs +237 -0
  141. package/src/reference/types.ts +16 -0
  142. package/src/reference/units-vocab.ts +4 -0
  143. package/src/styles/global.css +996 -50
@@ -0,0 +1,461 @@
1
+ ---
2
+ /**
3
+ * Collapsible/persistent sections (docs/plans/collapsible-disclosure.md Part
4
+ * 3) — a runtime wrap over Starlight's static heading markup, the same
5
+ * progressive-enhancement approach `HeadingAnchorEnhancer.astro` and
6
+ * `KeyedFloatEnhancer.astro` already use, since `rehype-heading-anchors.mjs`
7
+ * doesn't nest a heading's following content under it at build time.
8
+ *
9
+ * **Authoring**: `rehype-heading-anchors.mjs` resolves each heading's
10
+ * collapsibility — a `[collapsible]`/`[collapsed]`/`[no-collapsible]` marker,
11
+ * or a site-wide default from `astro.config.mjs` when a heading has none —
12
+ * down to two plain attributes on the wrapper: `data-collapsible="true"` and
13
+ * `data-default-open="true"|"false"`. This component only ever reads those
14
+ * two; it knows nothing about markers, ranks, or site config. A heading
15
+ * without `data-collapsible` is left completely alone, exactly as if this
16
+ * component didn't exist — wrapping *every* heading (the site default for
17
+ * both `sections` and `subsections`) is a valid choice, but not the
18
+ * hardcoded one: most sections have no reason to hide, and wrapping one
19
+ * cascades width/font-size side effects into ordinary content (see the CSS
20
+ * overrides below and their comments for two that actually happened during
21
+ * development).
22
+ *
23
+ * For every opted-in heading, this wraps
24
+ * `[.sl-heading-wrapper, ...body-until-next-heading-of-equal-or-higher-rank]`
25
+ * (the same boundary `sectionParts()` in `./section-parts.ts` already finds
26
+ * for the deep-link flash) in a `<details class="sl-section-collapsible">`,
27
+ * with the body itself further wrapped in one `.sl-section-body` div — see
28
+ * below for why that inner wrapper exists.
29
+ *
30
+ * **Only the bare heading moves into `<summary>` — not its `.sl-anchor-link`
31
+ * copy-link icon.** Moving the whole `.sl-heading-wrapper` (heading + anchor
32
+ * together, matching how `rehype-heading-anchors` emits them) into
33
+ * `<summary>` breaks axe's `no-focusable-content` check — a `<summary>` must
34
+ * have no focusable descendants, full stop, no matter how its click handling
35
+ * is patched up. `Figure.astro` hit the same problem for its own title bar
36
+ * and solved it by keeping the anchor a sibling of `<summary>` rather than a
37
+ * child; this mirrors that, reusing the existing `.sl-heading-wrapper` div as
38
+ * that sibling-holding container.
39
+ *
40
+ * Headings are processed deepest-rank first (h6 before h2, etc.) so that by
41
+ * the time a shallower heading's `sectionParts()` walk runs, any nested
42
+ * child section is already wrapped in its own `<details>` and reads as one
43
+ * ordinary sibling — `sectionParts()`'s `querySelector(HEADINGS)` still finds
44
+ * the nested heading regardless of depth, so the rank check that stops the
45
+ * walk keeps working unchanged.
46
+ *
47
+ * **Three states, not two.** Besides `closed`/`open`, a collapsed section
48
+ * has a third `peeking` state entered on hover/focus: the *real* body content
49
+ * (not a clone — a clone couldn't stay in sync with anything interactive or
50
+ * later-hydrated inside it) renders at up to 10em tall, absolutely
51
+ * positioned so it overlays whatever comes next on the page without shifting
52
+ * it — then, if the reader clicks while peeking, it grows in place from
53
+ * that exact height to its full height, switching to normal flow so
54
+ * following content is pushed down in sync with the growth instead of
55
+ * jumping once the animation ends. A native closed `<details>`'s
56
+ * non-summary children can't be rendered at all (the HTML spec skips them
57
+ * outright, a rule no CSS can override — confirmed the hard way while
58
+ * building the print handling below), so `peeking` still has to force
59
+ * `details.open = true` to render anything; only `data-state` distinguishes
60
+ * a real expand from a peek. `wirePersistedDetails`'s own `toggle`-event
61
+ * listener is only fed a synthetic event on a genuine `open`/`closed`
62
+ * commit — never for a peek — so hovering never persists anything.
63
+ *
64
+ * Persistence goes through `../disclosure/persisted-disclosure` (keyed by
65
+ * heading `id` + page path, same convention as `Figure.astro`'s
66
+ * `figure:<id>` keys). This does *not* use `../disclosure/hover-preview`'s
67
+ * `attachHoverPeek` — that utility clones content into a single shared
68
+ * floating panel positioned near the trigger, built for a "reference card"
69
+ * feel; the peek here needs to be the real, in-place content, sized and
70
+ * positioned relative to its own section, animatable into a real expand.
71
+ *
72
+ * Wired only on lesson pages (see `LessonFooter.astro`), same gating as
73
+ * `HeadingAnchorEnhancer.astro`/`KeyedFloatEnhancer.astro` — and placed
74
+ * *before* `HeadingAnchorEnhancer` in that file so this component's own
75
+ * hashchange listener (which expands any collapsed ancestor section) runs
76
+ * before `HeadingAnchorEnhancer`'s flash/scroll listener, registered after
77
+ * it in document order.
78
+ */
79
+ ---
80
+
81
+ <script>
82
+ import { HEADINGS, sectionParts } from './section-parts';
83
+ import {
84
+ wirePersistedDetails,
85
+ savePersistedOpenState,
86
+ } from '../disclosure/persisted-disclosure';
87
+
88
+ (() => {
89
+ const root = document.querySelector('.sl-markdown-content');
90
+ if (!root) return;
91
+
92
+ const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
93
+
94
+ type State = 'closed' | 'peeking' | 'open';
95
+
96
+ // A collapsed section's peek caps at 10em, measured in the body's own
97
+ // font-size (reset to normal body text below, not the heading's larger
98
+ // size — see the CSS comment on `.sl-section-collapsible.sl-section-body`).
99
+ const peekMaxPx = (body: HTMLElement): number =>
100
+ (parseFloat(getComputedStyle(body).fontSize) || 16) * 10;
101
+
102
+ // A rapid state change (e.g. collapsing a section, then hovering it again
103
+ // before the collapse animation finishes) starts a *second* `growTo` on
104
+ // the same `body` while the first is still in flight. Without tracking
105
+ // this, the first animation's stale `finish()` still fires later (its
106
+ // `transitionend`/timeout don't know a newer call superseded them) and
107
+ // stomps on state a second, unrelated animation already moved on from —
108
+ // observed as a peek overlay that never closes after a collapse. Each
109
+ // `body` gets at most one live "cancel the previous run" callback.
110
+ const supersede = new WeakMap<HTMLElement, () => void>();
111
+
112
+ /**
113
+ * Animate `body`'s `max-height` from its current rendered height to
114
+ * `toPx`, calling `after` once the transition (or, with reduced motion,
115
+ * an immediate jump) finishes — unless a *later* `growTo` call on the
116
+ * same `body` supersedes this one first, in which case `after` never
117
+ * runs at all. Caller is responsible for anything that must happen
118
+ * *before* the animation starts (e.g. flipping `data-state` so
119
+ * `position` is already correct for the target state) — but not
120
+ * `overflow`: clipping to `hidden` for the animation's duration and
121
+ * clearing it again afterward (so the CSS class's own `overflow` value —
122
+ * `visible` for `open`, `auto` for `peeking` — takes back over) is
123
+ * entirely this function's job, every single call, so no call site can
124
+ * forget the cleanup half and leave a stray inline `overflow: hidden`
125
+ * behind (that exact bug once left a fully "open" section permanently
126
+ * clipped after a `peeking` animation elsewhere hadn't cleaned up after
127
+ * itself).
128
+ */
129
+ const growTo = (body: HTMLElement, toPx: number, after: () => void) => {
130
+ supersede.get(body)?.(); // cancel whatever this body was mid-animating
131
+ supersede.delete(body);
132
+
133
+ if (reduceMotion.matches) {
134
+ body.style.maxHeight = `${toPx}px`;
135
+ body.style.removeProperty('overflow');
136
+ after();
137
+ return;
138
+ }
139
+ const fromPx = body.getBoundingClientRect().height;
140
+ body.style.transition = 'none';
141
+ body.style.maxHeight = `${fromPx}px`;
142
+ body.style.overflow = 'hidden';
143
+ void body.offsetHeight; // force reflow so the jump above isn't animated
144
+ body.style.transition = '';
145
+
146
+ let done = false;
147
+ const cancel = () => {
148
+ if (done) return;
149
+ done = true;
150
+ window.clearTimeout(fallback);
151
+ body.removeEventListener('transitionend', onEnd);
152
+ body.style.removeProperty('overflow');
153
+ };
154
+ const finish = () => {
155
+ if (done) return;
156
+ cancel();
157
+ supersede.delete(body);
158
+ after();
159
+ };
160
+ const onEnd = (event: Event) => {
161
+ if (
162
+ event.target === body &&
163
+ (event as TransitionEvent).propertyName === 'max-height'
164
+ ) {
165
+ finish();
166
+ }
167
+ };
168
+ body.addEventListener('transitionend', onEnd);
169
+ // `fromPx === toPx` (nothing actually changes) never fires
170
+ // `transitionend` — this safety net still resolves `after`.
171
+ const fallback = window.setTimeout(finish, 260);
172
+ supersede.set(body, cancel);
173
+
174
+ requestAnimationFrame(() => {
175
+ body.style.maxHeight = `${toPx}px`;
176
+ });
177
+ };
178
+
179
+ for (const heading of Array.from(
180
+ root.querySelectorAll<HTMLElement>(HEADINGS),
181
+ )
182
+ .filter(
183
+ (heading) =>
184
+ !!heading.id &&
185
+ heading.closest<HTMLElement>('.sl-heading-wrapper')?.dataset
186
+ .collapsible === 'true',
187
+ )
188
+ // Deepest rank first — see the module comment above.
189
+ .sort((a, b) => Number(b.tagName[1]) - Number(a.tagName[1]))) {
190
+ const parts = sectionParts(heading);
191
+ const bodyParts = parts.slice(1);
192
+ const wrapper = heading.closest<HTMLElement>('.sl-heading-wrapper')!;
193
+
194
+ const details = document.createElement('details');
195
+ details.className = 'sl-section-collapsible';
196
+ details.dataset.state = 'closed' satisfies State;
197
+
198
+ const summary = document.createElement('summary');
199
+ summary.className = 'sl-section-summary';
200
+
201
+ const body = document.createElement('div');
202
+ body.className = 'sl-section-body';
203
+ for (const part of bodyParts) body.append(part);
204
+
205
+ // Capture the anchor link's position *before* moving the heading —
206
+ // once `heading` is gone, `wrapper`'s only remaining child is it.
207
+ const anchorLink = wrapper.querySelector<HTMLElement>(
208
+ ':scope > .sl-anchor-link',
209
+ );
210
+ summary.append(heading);
211
+ details.append(summary, body);
212
+
213
+ // The anchor link stays put as `details`'s sibling inside `wrapper`
214
+ // (see the module comment) — insert `details` right before it so
215
+ // heading/body content still comes first in DOM/tab order.
216
+ wrapper.classList.add('sl-section-titlebar');
217
+ if (anchorLink) wrapper.insertBefore(details, anchorLink);
218
+ else wrapper.append(details);
219
+
220
+ // `wirePersistedDetails` sets `details.open` *after* `details` is
221
+ // attached above, not before — deliberately never set `.open` on the
222
+ // still-detached element. At least one browser has been observed
223
+ // mis-computing a nested `<table>`'s intrinsic (auto-layout) width
224
+ // when a `<details>` is built pre-opened and inserted already-open,
225
+ // versus being opened only after attachment.
226
+ const defaultOpen = wrapper.dataset.defaultOpen !== 'false';
227
+ wirePersistedDetails(details, `section:${heading.id}`, defaultOpen);
228
+ details.dataset.state = (details.open ? 'open' : 'closed') satisfies State;
229
+ const persistKey = `section:${heading.id}`;
230
+
231
+ // A real (non-peek) commit: the only place a choice gets persisted —
232
+ // via `savePersistedOpenState`, called *synchronously* with the
233
+ // intended end state, not by waiting for the animation and reading
234
+ // back `details.open` (which has to stay `true` for the *entire*
235
+ // close animation — the browser refuses to render a closed
236
+ // `<details>`'s content at all, so an `open → closed` collapse can't
237
+ // flip the attribute to `false` until the shrink finishes — a
238
+ // deferred read-and-save would otherwise race a reader who navigates
239
+ // away mid-animation and save the wrong value). `data-state` flips to
240
+ // `next` *before* animating: for `closed`→`open` there's no prior
241
+ // `position: absolute` to transition out of, so timing doesn't
242
+ // matter; for `open`→`closed` the body stays `position: static`
243
+ // throughout (nothing to switch), so following content rises in step
244
+ // with the shrink. The one transition that *does* need mid-animation
245
+ // care — growing out of a peek — is `commitFromPeek` below, not this.
246
+ const commitOpenOrClosed = (next: 'open' | 'closed') => {
247
+ const goingOpen = next === 'open';
248
+ if (goingOpen) details.open = true; // render before animating/measuring
249
+ details.dataset.state = next satisfies State;
250
+ savePersistedOpenState(persistKey, goingOpen);
251
+ growTo(body, goingOpen ? body.scrollHeight : 0, () => {
252
+ details.open = goingOpen;
253
+ if (goingOpen) body.style.maxHeight = '';
254
+ });
255
+ };
256
+
257
+ // Promote an in-progress peek to a real, persisted expand — grown
258
+ // from the peek's *current* rendered height, not from zero. The
259
+ // `position: absolute` → `static` switch happens the instant before
260
+ // growing (with the peek height frozen as an explicit `max-height`
261
+ // first) so following content starts moving down from the exact
262
+ // moment the box re-enters normal flow, not after the growth
263
+ // animation completes.
264
+ const commitFromPeek = () => {
265
+ const peekPx = body.getBoundingClientRect().height;
266
+ body.style.transition = 'none';
267
+ body.style.maxHeight = `${peekPx}px`;
268
+ void body.offsetHeight; // reflow with the peek height frozen…
269
+ details.dataset.state = 'open' satisfies State; // …then re-enter flow at that same height
270
+ void body.offsetHeight; // reflow again before animating the grow
271
+ body.style.transition = '';
272
+ savePersistedOpenState(persistKey, true);
273
+ growTo(body, body.scrollHeight, () => {
274
+ details.open = true;
275
+ body.style.maxHeight = '';
276
+ });
277
+ };
278
+
279
+ const enterPeek = () => {
280
+ if (details.dataset.state !== 'closed') return;
281
+ details.open = true; // render — see the module comment
282
+ details.dataset.state = 'peeking' satisfies State;
283
+ growTo(body, Math.min(body.scrollHeight, peekMaxPx(body)), () => {});
284
+ };
285
+
286
+ const exitPeek = () => {
287
+ if (details.dataset.state !== 'peeking') return;
288
+ growTo(body, 0, () => {
289
+ details.dataset.state = 'closed' satisfies State;
290
+ details.open = false;
291
+ });
292
+ };
293
+
294
+ // Debounced leave, mirroring `FloatRefLayer.astro`'s `scheduleLeave`:
295
+ // a pointer move from the title into the peeked body itself must not
296
+ // close it. `:focus-visible`, not `:focus-within` — the same reasoning
297
+ // as `hover-preview.ts`'s own `scheduleLeave`: clicking `summary` to
298
+ // commit a peek (or to collapse an open section) leaves it focused as
299
+ // an ordinary side effect of the click, and that focus lingers
300
+ // (`:focus-within` stays true) long after the pointer has moved
301
+ // elsewhere — which, before this used `:focus-visible` instead, meant
302
+ // a peek entered right after such a click could never close on
303
+ // mouse-leave at all. `:focus-visible` is only true for
304
+ // keyboard/keyboard-equivalent focus, so a mouse click doesn't count,
305
+ // but a reader who actually tabbed here still keeps their peek open.
306
+ let leaveTimer: number | null = null;
307
+ const cancelLeave = () => {
308
+ if (leaveTimer !== null) {
309
+ window.cancelAnimationFrame(leaveTimer);
310
+ leaveTimer = null;
311
+ }
312
+ };
313
+ const scheduleLeave = () => {
314
+ cancelLeave();
315
+ leaveTimer = window.requestAnimationFrame(() => {
316
+ leaveTimer = null;
317
+ if (!details.matches(':hover') && !summary.matches(':focus-visible')) {
318
+ exitPeek();
319
+ }
320
+ });
321
+ };
322
+
323
+ summary.addEventListener('pointerenter', (event) => {
324
+ if (event.pointerType === 'touch') return;
325
+ cancelLeave();
326
+ enterPeek();
327
+ });
328
+ summary.addEventListener('focus', enterPeek);
329
+ details.addEventListener('pointerleave', (event) => {
330
+ if (event.pointerType === 'touch') return;
331
+ scheduleLeave();
332
+ });
333
+ details.addEventListener('focusout', scheduleLeave);
334
+
335
+ summary.addEventListener('click', (event) => {
336
+ // Fully own the open/close/peek state machine — never let the
337
+ // native toggle run (it only knows two states and would fight the
338
+ // grow-from-peek animation).
339
+ event.preventDefault();
340
+ cancelLeave();
341
+ if (details.dataset.state === 'peeking') commitFromPeek();
342
+ else if (details.dataset.state === 'open') commitOpenOrClosed('closed');
343
+ else commitOpenOrClosed('open');
344
+ });
345
+ }
346
+
347
+ // A closed native `<details>` doesn't just hide its non-`<summary>`
348
+ // children visually — the HTML spec skips rendering them outright, so a
349
+ // heading nested inside one isn't scrollable, focusable, or even present
350
+ // in layout until that ancestor opens. `includeSelf` controls whether the
351
+ // *targeted* heading's own section is forced open too, or left exactly
352
+ // as the reader last set it:
353
+ // - a fresh navigation with a hash already in the URL (an external/
354
+ // bookmarked deep link — see the initial call below, made once at
355
+ // startup, not from a `hashchange` event) is a reader's only way in,
356
+ // so it must fully open the destination, not just its ancestors.
357
+ // - an in-page jump — clicking an "On this page" sidebar link, a
358
+ // cross-reference, or a section's own permalink icon — is a reader
359
+ // already on the page choosing to look *at* a title; forcing its
360
+ // content open too would show content they may have deliberately
361
+ // collapsed, without the animation or the following-content reflow a
362
+ // real expand gets everywhere else. Only its ancestors (structurally
363
+ // unavoidable) open; the target itself is just scrolled to and
364
+ // flashed by `HeadingAnchorEnhancer`, same as an always-visible
365
+ // heading gets.
366
+ const expandAncestors = (id: string, includeSelf: boolean) => {
367
+ if (!id) return;
368
+ const target = document.getElementById(id);
369
+ if (!target) return;
370
+ let details = target.closest<HTMLDetailsElement>(
371
+ '.sl-section-collapsible',
372
+ );
373
+ if (details && !includeSelf) {
374
+ details =
375
+ details.parentElement?.closest<HTMLDetailsElement>(
376
+ '.sl-section-collapsible',
377
+ ) ?? null;
378
+ }
379
+ while (details) {
380
+ if (details.dataset.state !== 'open') {
381
+ details.dataset.state = 'open' satisfies State;
382
+ details.open = true;
383
+ details.dispatchEvent(new Event('toggle'));
384
+ }
385
+ details =
386
+ details.parentElement?.closest<HTMLDetailsElement>(
387
+ '.sl-section-collapsible',
388
+ ) ?? null;
389
+ }
390
+ };
391
+
392
+ const currentHashId = (): string => {
393
+ try {
394
+ return decodeURIComponent(location.hash.slice(1));
395
+ } catch {
396
+ return location.hash.slice(1);
397
+ }
398
+ };
399
+
400
+ expandAncestors(currentHashId(), true);
401
+ window.addEventListener('hashchange', () => {
402
+ expandAncestors(currentHashId(), false);
403
+ });
404
+
405
+ // Print must always show every section, regardless of on-screen
406
+ // collapse state. Unlike ordinary hidden content, a closed native
407
+ // `<details>`'s non-summary children aren't just styled hidden — the
408
+ // HTML spec has the browser skip rendering them outright, a rule no
409
+ // amount of author CSS (`display`, `content-visibility`, `!important`)
410
+ // can override. Only the `open` *attribute* controls it, so this has to
411
+ // force every section's real `open`/`data-state` for print in JS and
412
+ // restore afterward. `beforeprint`/`afterprint` cover an actual print
413
+ // dialog; `matchMedia('print')`'s `change` event additionally covers
414
+ // tools (headless browsers, some print-preview UIs) that flip the media
415
+ // type without firing those events.
416
+ let savedState: { open: boolean; state: string }[] | null = null;
417
+
418
+ const expandAllForPrint = () => {
419
+ if (document.querySelector('[data-explico-print-controller]')) return;
420
+ if (savedState) return; // already expanded for a print in progress
421
+ const all = Array.from(
422
+ document.querySelectorAll<HTMLDetailsElement>(
423
+ '.sl-section-collapsible',
424
+ ),
425
+ );
426
+ savedState = all.map((d) => ({
427
+ open: d.open,
428
+ state: d.dataset.state ?? 'closed',
429
+ }));
430
+ for (const d of all) {
431
+ d.open = true;
432
+ d.dataset.state = 'open' satisfies State;
433
+ (d.querySelector<HTMLElement>(':scope > .sl-section-body') ??
434
+ undefined)?.style.removeProperty('max-height');
435
+ }
436
+ };
437
+
438
+ const restoreAfterPrint = () => {
439
+ if (!savedState) return;
440
+ const all = document.querySelectorAll<HTMLDetailsElement>(
441
+ '.sl-section-collapsible',
442
+ );
443
+ all.forEach((d, i) => {
444
+ const saved = savedState?.[i];
445
+ if (!saved) return;
446
+ d.open = saved.open;
447
+ d.dataset.state = saved.state;
448
+ d.querySelector<HTMLElement>(':scope > .sl-section-body')?.style
449
+ .removeProperty('max-height');
450
+ });
451
+ savedState = null;
452
+ };
453
+
454
+ window.addEventListener('beforeprint', expandAllForPrint);
455
+ window.addEventListener('afterprint', restoreAfterPrint);
456
+ window.matchMedia('print').addEventListener('change', (event) => {
457
+ if (event.matches) expandAllForPrint();
458
+ else restoreAfterPrint();
459
+ });
460
+ })();
461
+ </script>