explico 0.8.0 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (230) hide show
  1. package/CHANGELOG.md +1257 -1
  2. package/README.md +60 -15
  3. package/dist/compiler/astro.d.ts +6 -1
  4. package/dist/compiler/cli.d.ts +46 -2
  5. package/dist/compiler/cli.js +70 -4
  6. package/dist/compiler/collections.d.ts +35 -2
  7. package/dist/compiler/collections.js +68 -5
  8. package/dist/compiler/course-config.d.ts +3 -194
  9. package/dist/compiler/course-config.js +78 -5
  10. package/dist/compiler/exports/standalone-html.d.ts +6 -1
  11. package/dist/compiler/knowledge-version.js +1 -1
  12. package/dist/compiler/lesson-derivation.d.ts +27 -10
  13. package/dist/compiler/lesson-derivation.js +23 -8
  14. package/dist/compiler/logging.d.ts +4 -1
  15. package/dist/compiler/logging.js +4 -1
  16. package/dist/compiler/manifest.d.ts +204 -31
  17. package/dist/compiler/manifest.js +270 -55
  18. package/dist/compiler/resolution-report.d.ts +6 -1
  19. package/dist/course-config-Dsnri9Tk.d.ts +464 -0
  20. package/dist/reference/base-accents.d.mts +26 -0
  21. package/dist/reference/base-accents.mjs +31 -0
  22. package/dist/reference/base-roman-labels.d.mts +2 -2
  23. package/dist/reference/base-roman-labels.mjs +2 -2
  24. package/dist/reference/consistency.d.ts +14 -2
  25. package/dist/reference/consistency.js +108 -4
  26. package/dist/reference/delimiter-size.d.ts +3 -2
  27. package/dist/reference/diagrams-validate.d.ts +7 -2
  28. package/dist/reference/diagrams-validate.js +9 -1
  29. package/dist/reference/equations-validate.d.ts +8 -5
  30. package/dist/reference/equations-validate.js +1 -0
  31. package/dist/reference/equations.d.mts +60 -31
  32. package/dist/reference/equations.mjs +83 -43
  33. package/dist/reference/examples-validate.d.ts +31 -0
  34. package/dist/reference/examples-validate.js +48 -0
  35. package/dist/reference/figures-validate.d.ts +1 -1
  36. package/dist/reference/figures-validate.js +4 -1
  37. package/dist/reference/folds-validate.d.ts +33 -0
  38. package/dist/reference/folds-validate.js +32 -0
  39. package/dist/reference/gate-abbreviations.d.ts +3 -1
  40. package/dist/reference/gate-abbreviations.js +1 -0
  41. package/dist/reference/gate-course-rules.d.ts +32 -0
  42. package/dist/reference/gate-course-rules.js +85 -0
  43. package/dist/reference/gate-heading-length.d.ts +5 -2
  44. package/dist/reference/gate-heading-length.js +45 -21
  45. package/dist/reference/gate-heading-structure.d.ts +41 -0
  46. package/dist/reference/gate-heading-structure.js +113 -0
  47. package/dist/reference/gate-math-punctuation.d.ts +71 -0
  48. package/dist/reference/gate-math-punctuation.js +251 -0
  49. package/dist/reference/gate-math.d.ts +22 -6
  50. package/dist/reference/gate-math.js +42 -8
  51. package/dist/reference/gate-part-pages.d.ts +50 -0
  52. package/dist/reference/gate-part-pages.js +118 -0
  53. package/dist/reference/gate-prose-notation.d.ts +21 -11
  54. package/dist/reference/gate-prose-notation.js +25 -7
  55. package/dist/reference/katex-delimiters.mjs +20 -7
  56. package/dist/reference/keyed-float-validate.d.ts +27 -10
  57. package/dist/reference/keyed-float-validate.js +15 -1
  58. package/dist/reference/keyed-jsx-floats.d.mts +11 -5
  59. package/dist/reference/keyed-jsx-floats.mjs +73 -20
  60. package/dist/reference/knowledge-checks-validate.d.ts +1 -1
  61. package/dist/reference/knowledge-checks.d.mts +3 -1
  62. package/dist/reference/knowledge-checks.mjs +9 -4
  63. package/dist/reference/lab-math-scope.d.ts +1 -0
  64. package/dist/reference/lab-math-slot.d.ts +34 -0
  65. package/dist/reference/lab-math-slot.js +6 -0
  66. package/dist/reference/math-glyphs.d.mts +80 -4
  67. package/dist/reference/math-glyphs.mjs +572 -37
  68. package/dist/reference/page-notation.d.ts +6 -1
  69. package/dist/reference/raw-floats-validate.js +1 -1
  70. package/dist/reference/raw-floats.mjs +1 -1
  71. package/dist/reference/rehype-heading-anchors.d.mts +14 -0
  72. package/dist/reference/rehype-heading-anchors.mjs +116 -17
  73. package/dist/reference/rehype-table-header-labels.d.mts +6 -1
  74. package/dist/reference/rehype-table-header-labels.mjs +21 -3
  75. package/dist/reference/remark-asides.d.mts +3 -0
  76. package/dist/reference/remark-asides.mjs +180 -0
  77. package/dist/reference/remark-memo.d.mts +3 -1
  78. package/dist/reference/remark-memo.mjs +42 -11
  79. package/dist/reference/remark-notation.d.mts +60 -10
  80. package/dist/reference/remark-notation.mjs +387 -36
  81. package/dist/reference/render-lab-math.d.ts +4 -26
  82. package/dist/reference/render-lab-math.js +4 -4
  83. package/dist/reference/sections-validate.d.ts +27 -0
  84. package/dist/reference/sections-validate.js +41 -0
  85. package/dist/reference/sections.d.mts +97 -0
  86. package/dist/reference/sections.mjs +306 -0
  87. package/dist/reference/tables-validate.d.ts +1 -1
  88. package/dist/reference/types.d.ts +17 -1
  89. package/dist/reference/unit-words.d.ts +37 -0
  90. package/dist/reference/unit-words.js +170 -0
  91. package/dist/rules/catalog.d.ts +176 -0
  92. package/dist/rules/catalog.js +983 -0
  93. package/dist/rules/course-rules.d.ts +115 -0
  94. package/dist/rules/course-rules.js +292 -0
  95. package/dist/rules/course-rules.schema.json +138 -0
  96. package/dist/rules/in-effect.d.ts +53 -0
  97. package/dist/rules/in-effect.js +142 -0
  98. package/dist/rules/rules.schema.json +1072 -0
  99. package/dist/rules/settings.d.ts +3 -0
  100. package/dist/rules/settings.js +290 -0
  101. package/dist/rules/waivers.d.ts +53 -0
  102. package/dist/rules/waivers.js +132 -0
  103. package/dist/session/PreferenceStore.d.ts +5 -0
  104. package/dist/session/PreferenceStore.js +9 -0
  105. package/docs/VERSIONING.md +5 -3
  106. package/docs/authoring/README.md +232 -40
  107. package/docs/authoring/agents.md +55 -9
  108. package/docs/authoring/components.md +178 -22
  109. package/docs/authoring/migrating.md +1152 -2
  110. package/docs/authoring/notation.md +576 -322
  111. package/docs/authoring/prose.md +551 -0
  112. package/docs/authoring/retired.md +35 -0
  113. package/docs/authoring/structure-rules.md +422 -40
  114. package/docs/authoring/structure.md +445 -62
  115. package/docs/authoring/versioning.md +20 -28
  116. package/docs/authoring/visualizations.md +142 -20
  117. package/package.json +2 -2
  118. package/src/compiler/cli.ts +116 -6
  119. package/src/compiler/collections.ts +111 -7
  120. package/src/compiler/course-config.ts +273 -34
  121. package/src/compiler/knowledge-version.ts +1 -1
  122. package/src/compiler/lesson-derivation.ts +54 -15
  123. package/src/compiler/logging.ts +8 -2
  124. package/src/compiler/manifest.ts +573 -104
  125. package/src/components/BlockHeading.astro +113 -0
  126. package/src/components/CourseContents.astro +221 -0
  127. package/src/components/CurriculumMap.astro +5 -6
  128. package/src/components/FloatIndex.astro +200 -0
  129. package/src/components/HoverPanel.astro +431 -79
  130. package/src/components/PartBridge.astro +27 -0
  131. package/src/components/PartOverview.astro +401 -0
  132. package/src/components/PartScope.astro +27 -0
  133. package/src/components/abbreviation/AbbreviationLayer.astro +49 -51
  134. package/src/components/assessments/AssessmentRunner.tsx +8 -5
  135. package/src/components/assessments/AssessmentSet.astro +23 -26
  136. package/src/components/block-heading.ts +146 -0
  137. package/src/components/card-arrow.ts +243 -0
  138. package/src/components/card-drag.ts +631 -0
  139. package/src/components/card-jump.ts +45 -0
  140. package/src/components/card-manager.ts +1589 -0
  141. package/src/components/card-memory.ts +177 -0
  142. package/src/components/card-placement.ts +504 -0
  143. package/src/components/card-side.ts +336 -0
  144. package/src/components/card-travel.ts +197 -0
  145. package/src/components/citation/CitationLayer.astro +77 -76
  146. package/src/components/diagram/Diagram.astro +161 -316
  147. package/src/components/disclosure/course-disclosure.ts +24 -0
  148. package/src/components/disclosure/hover-preview.ts +122 -42
  149. package/src/components/disclosure/persisted-disclosure.ts +3 -3
  150. package/src/components/disclosure/print-as-shown.ts +252 -0
  151. package/src/components/examples/CompactExample.astro +3 -1
  152. package/src/components/examples/CompactExamples.astro +101 -94
  153. package/src/components/figure/Figure.astro +174 -274
  154. package/src/components/figure/FloatCaption.astro +87 -0
  155. package/src/components/figure/figure-state.ts +58 -0
  156. package/src/components/figure/figure-tools.ts +87 -0
  157. package/src/components/float-name.ts +101 -0
  158. package/src/components/fold/Fold.astro +125 -0
  159. package/src/components/fold/fold-id.ts +40 -0
  160. package/src/components/memo/Memo.astro +162 -231
  161. package/src/components/memo/ShortNote.astro +6 -6
  162. package/src/components/notation/NotationFormula.astro +5 -1
  163. package/src/components/notation/NotationGlossary.astro +3 -4
  164. package/src/components/notation/NotationLayer.astro +169 -162
  165. package/src/components/notation/NotationMath.astro +10 -1
  166. package/src/components/reference/FloatRefLayer.astro +485 -423
  167. package/src/components/starlight/AboutPanel.astro +5 -4
  168. package/src/components/starlight/CollapsibleSections.astro +207 -93
  169. package/src/components/starlight/DiagramEnhancer.astro +15 -138
  170. package/src/components/starlight/HeadingAnchorEnhancer.astro +193 -1
  171. package/src/components/starlight/KeyedFloatEnhancer.astro +100 -9
  172. package/src/components/starlight/LayoutHeader.astro +298 -19
  173. package/src/components/starlight/LayoutPageSidebar.astro +99 -12
  174. package/src/components/starlight/LayoutPageTitle.astro +120 -0
  175. package/src/components/starlight/LayoutSidebar.astro +229 -11
  176. package/src/components/starlight/LessonExportActions.astro +53 -72
  177. package/src/components/starlight/LessonFooter.astro +26 -4
  178. package/src/components/starlight/section-parts.ts +15 -2
  179. package/src/components/starlight/section-summary.ts +181 -0
  180. package/src/components/table/Table.astro +95 -76
  181. package/src/components/table/TableOverflowExpander.astro +65 -28
  182. package/src/content-config.ts +40 -4
  183. package/src/reference/base-accents.mjs +31 -0
  184. package/src/reference/base-roman-labels.mjs +2 -2
  185. package/src/reference/consistency.ts +176 -5
  186. package/src/reference/delimiter-size.ts +3 -2
  187. package/src/reference/diagrams-validate.ts +17 -1
  188. package/src/reference/equations-validate.ts +10 -5
  189. package/src/reference/equations.mjs +83 -43
  190. package/src/reference/examples-validate.ts +97 -0
  191. package/src/reference/figures-validate.ts +6 -1
  192. package/src/reference/folds-validate.ts +65 -0
  193. package/src/reference/gate-abbreviations.ts +1 -0
  194. package/src/reference/gate-course-rules.ts +134 -0
  195. package/src/reference/gate-heading-length.ts +66 -35
  196. package/src/reference/gate-heading-structure.ts +193 -0
  197. package/src/reference/gate-math-punctuation.ts +381 -0
  198. package/src/reference/gate-math.ts +69 -10
  199. package/src/reference/gate-part-pages.ts +217 -0
  200. package/src/reference/gate-prose-notation.ts +59 -12
  201. package/src/reference/katex-delimiters.mjs +20 -7
  202. package/src/reference/keyed-float-validate.ts +45 -11
  203. package/src/reference/keyed-jsx-floats.mjs +73 -20
  204. package/src/reference/knowledge-checks-validate.ts +1 -1
  205. package/src/reference/knowledge-checks.mjs +9 -4
  206. package/src/reference/lab-math-slot.ts +36 -0
  207. package/src/reference/math-glyphs.mjs +572 -37
  208. package/src/reference/raw-floats-validate.ts +1 -1
  209. package/src/reference/raw-floats.mjs +1 -1
  210. package/src/reference/rehype-heading-anchors.mjs +116 -17
  211. package/src/reference/rehype-table-header-labels.mjs +21 -3
  212. package/src/reference/remark-asides.mjs +180 -0
  213. package/src/reference/remark-memo.mjs +42 -11
  214. package/src/reference/remark-notation.mjs +387 -36
  215. package/src/reference/render-lab-math.ts +16 -31
  216. package/src/reference/sections-validate.ts +81 -0
  217. package/src/reference/sections.mjs +306 -0
  218. package/src/reference/tables-validate.ts +2 -1
  219. package/src/reference/types.ts +17 -0
  220. package/src/reference/unit-words.ts +233 -0
  221. package/src/rules/catalog.ts +1298 -0
  222. package/src/rules/course-rules.ts +456 -0
  223. package/src/rules/in-effect.ts +235 -0
  224. package/src/rules/settings.ts +448 -0
  225. package/src/rules/waivers.ts +249 -0
  226. package/src/session/PreferenceStore.ts +15 -0
  227. package/src/styles/global.css +1467 -163
  228. package/src/styles/surfaces.css +182 -0
  229. package/src/components/hover-panel.ts +0 -339
  230. package/src/components/popup-stack.ts +0 -345
@@ -179,11 +179,12 @@ const knowledgeLine = knowledge.date
179
179
  max-height: min(70vh, 34rem);
180
180
  overflow-y: auto;
181
181
  padding: 1rem;
182
- border: 1px solid var(--sl-color-gray-5);
183
- border-top: 0.25rem solid var(--sl-color-accent);
184
- border-radius: 0.65rem;
182
+ /* Course logistics: the meta tier's surface, border and radius
183
+ (`surfaces.css`), plus the overlay shadow every floating card has. */
184
+ border: var(--ex-edge-border);
185
+ border-radius: var(--ex-radius-meta);
185
186
  color: var(--sl-color-text);
186
- background: var(--sl-color-bg);
187
+ background: var(--ex-surface-tint-neutral);
187
188
  box-shadow: var(--sl-shadow-lg);
188
189
  font-size: var(--sl-text-xs);
189
190
  line-height: 1.5;
@@ -32,10 +32,15 @@
32
32
  * together, matching how `rehype-heading-anchors` emits them) into
33
33
  * `<summary>` breaks axe's `no-focusable-content` check — a `<summary>` must
34
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.
35
+ * is patched up. So the anchor stays a sibling of `<summary>` rather than a
36
+ * child — the same reason a float's caption line keeps its links outside any
37
+ * `<summary>` — reusing the existing `.sl-heading-wrapper` div as that
38
+ * sibling-holding container.
39
+ *
40
+ * A `<Memo>` and a `<Fold>` are folded here too: each bar is an `<h6>` in a
41
+ * heading wrapper of its own (`Memo.astro`, `Fold.astro`), with its default
42
+ * and its `rememberState` on the wrapper (`data-default-open`,
43
+ * `data-remember-state="false"` to keep the default on every visit).
39
44
  *
40
45
  * Headings are processed deepest-rank first (h6 before h2, etc.) so that by
41
46
  * the time a shallower heading's `sectionParts()` walk runs, any nested
@@ -69,6 +74,34 @@
69
74
  * feel; the peek here needs to be the real, in-place content, sized and
70
75
  * positioned relative to its own section, animatable into a real expand.
71
76
  *
77
+ * **A closed section must look closed on every screen.** The chevron alone
78
+ * isn't enough: on wide screens it sits in the gutter left of the title, and
79
+ * on a phone there is no gutter — it used to render off-screen, leaving a
80
+ * collapsed section indistinguishable from a plain heading. So `<summary>`
81
+ * also carries two `aria-hidden` spans after the heading:
82
+ * `.sl-section-hint` — "(tap to expand)" / "(click to expand)", narrow or
83
+ * touch screens only, where `global.css` also moves the chevron into the
84
+ * title line — and `.sl-section-stub`, a "Hidden here: 2 paragraphs, 1
85
+ * equation" line (`./section-summary.ts`) on every screen. Both show only
86
+ * while the section isn't `open`, and both are part of the tap target. The
87
+ * heading brings its outline number along (`data-sec-number`, printed by
88
+ * `global.css`): a narrow screen hides it on an open section but shows it on a
89
+ * closed one, which is where it says what is folded away.
90
+ *
91
+ * **Print follows the screen** (`../disclosure/print-as-shown.ts`): an open
92
+ * section prints in full, a closed one as its title plus a "(content
93
+ * collapsed — full text: …)" note with the section's permalink.
94
+ *
95
+ * **Carded sections** (docs/plans/titles-and-containers.md Part 2): a
96
+ * wrapper with `data-card="accent"|"meta"` — a `[card]` / `[card=meta]`
97
+ * heading marker, or a knowledge check (`AssessmentSet.astro`) — has its whole
98
+ * section, heading and body, moved into one `div.ex-section-card` carrying
99
+ * that tier's class from `surfaces.css`. Same boundary, same deepest-first
100
+ * order: a card nested in a section reads as one sibling holding its heading,
101
+ * so the parent's walk still sees it. A section that is both collapsible and
102
+ * carded is collapsed first, then its titlebar — which by then holds the
103
+ * whole section — goes into the card.
104
+ *
72
105
  * Wired only on lesson pages (see `LessonFooter.astro`), same gating as
73
106
  * `HeadingAnchorEnhancer.astro`/`KeyedFloatEnhancer.astro` — and placed
74
107
  * *before* `HeadingAnchorEnhancer` in that file so this component's own
@@ -80,10 +113,14 @@
80
113
 
81
114
  <script>
82
115
  import { HEADINGS, sectionParts } from './section-parts';
116
+ import { collectHiddenContent, hiddenContentParts } from './section-summary';
83
117
  import {
84
118
  wirePersistedDetails,
85
119
  savePersistedOpenState,
86
120
  } from '../disclosure/persisted-disclosure';
121
+ import { installPrintAsShownFallback } from '../disclosure/print-as-shown';
122
+ import { CARD_CONSTANTS } from '../card-manager';
123
+ import { startTrip, type Trip } from '../card-travel';
87
124
 
88
125
  (() => {
89
126
  const root = document.querySelector('.sl-markdown-content');
@@ -91,6 +128,20 @@
91
128
 
92
129
  const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
93
130
 
131
+ // Every section peek the pointer is travelling from, fed by one listener.
132
+ const peekTrips = new Set<Trip>();
133
+ document.addEventListener(
134
+ 'pointermove',
135
+ (event) => {
136
+ if (event.pointerType === 'touch') return;
137
+ for (const trip of [...peekTrips]) {
138
+ trip.move({ x: event.clientX, y: event.clientY });
139
+ if (!trip.active) peekTrips.delete(trip);
140
+ }
141
+ },
142
+ { passive: true },
143
+ );
144
+
94
145
  type State = 'closed' | 'peeking' | 'open';
95
146
 
96
147
  // A collapsed section's peek caps at 10em, measured in the body's own
@@ -176,20 +227,40 @@
176
227
  });
177
228
  };
178
229
 
179
- for (const heading of Array.from(
230
+ const CARD_TIERS = new Set(['accent', 'meta']);
231
+
232
+ const cardSection = (heading: HTMLElement, tier: string) => {
233
+ const parts = sectionParts(heading);
234
+ const card = document.createElement('div');
235
+ card.className = `ex-section-card ex-tier-${tier}`;
236
+ parts[0].before(card);
237
+ card.append(...parts);
238
+ };
239
+
240
+ const sectionHeadings = Array.from(
180
241
  root.querySelectorAll<HTMLElement>(HEADINGS),
181
242
  )
182
- .filter(
183
- (heading) =>
243
+ .filter((heading) => {
244
+ const wrapper = heading.closest<HTMLElement>('.sl-heading-wrapper');
245
+ return (
184
246
  !!heading.id &&
185
- heading.closest<HTMLElement>('.sl-heading-wrapper')?.dataset
186
- .collapsible === 'true',
187
- )
247
+ (wrapper?.dataset.collapsible === 'true' ||
248
+ CARD_TIERS.has(wrapper?.dataset.card ?? ''))
249
+ );
250
+ })
188
251
  // Deepest rank first — see the module comment above.
189
- .sort((a, b) => Number(b.tagName[1]) - Number(a.tagName[1]))) {
252
+ .sort((a, b) => Number(b.tagName[1]) - Number(a.tagName[1]));
253
+
254
+ for (const heading of sectionHeadings) {
255
+ const wrapper = heading.closest<HTMLElement>('.sl-heading-wrapper')!;
256
+ if (wrapper.dataset.collapsible === 'true') makeCollapsible(heading, wrapper);
257
+ const tier = wrapper.dataset.card ?? '';
258
+ if (CARD_TIERS.has(tier)) cardSection(heading, tier);
259
+ }
260
+
261
+ function makeCollapsible(heading: HTMLElement, wrapper: HTMLElement) {
190
262
  const parts = sectionParts(heading);
191
263
  const bodyParts = parts.slice(1);
192
- const wrapper = heading.closest<HTMLElement>('.sl-heading-wrapper')!;
193
264
 
194
265
  const details = document.createElement('details');
195
266
  details.className = 'sl-section-collapsible';
@@ -208,6 +279,43 @@
208
279
  ':scope > .sl-anchor-link',
209
280
  );
210
281
  summary.append(heading);
282
+
283
+ // What a *closed* section shows beyond its title (styles in
284
+ // `global.css`; see the module comment): a "(tap to expand)" hint on
285
+ // narrow/touch screens and a "Hidden here: …" line on every screen.
286
+ // Both are `aria-hidden` — the native `<details>` already announces
287
+ // "collapsed", and the heading alone stays the summary's name.
288
+ const hint = document.createElement('span');
289
+ hint.className = 'sl-section-hint';
290
+ hint.setAttribute('aria-hidden', 'true');
291
+ summary.append(hint);
292
+ // A `<Fold>` whose bar names its floats (`data-bar-names-floats`)
293
+ // counts them here rather than naming them twice.
294
+ const { counts, named } = collectHiddenContent(body, HEADINGS, {
295
+ nameFloats: wrapper.dataset.barNamesFloats === undefined,
296
+ });
297
+ const hiddenParts = hiddenContentParts(counts, named);
298
+ if (hiddenParts) {
299
+ // Outer span collapses as a grid row; the inner one clips the text.
300
+ const stub = document.createElement('span');
301
+ stub.className = 'sl-section-stub';
302
+ stub.setAttribute('aria-hidden', 'true');
303
+ const stubText = document.createElement('span');
304
+ stubText.append('Hidden here: ');
305
+ hiddenParts.forEach((part, index) => {
306
+ if (index > 0) stubText.append(', ');
307
+ if (typeof part === 'string') {
308
+ stubText.append(part);
309
+ return;
310
+ }
311
+ const number = document.createElement('b');
312
+ number.textContent = part.number;
313
+ stubText.append(number, ` ${part.short}`);
314
+ });
315
+ stub.append(stubText);
316
+ summary.append(stub);
317
+ }
318
+
211
319
  details.append(summary, body);
212
320
 
213
321
  // The anchor link stays put as `details`'s sibling inside `wrapper`
@@ -223,10 +331,17 @@
223
331
  // mis-computing a nested `<table>`'s intrinsic (auto-layout) width
224
332
  // when a `<details>` is built pre-opened and inserted already-open,
225
333
  // versus being opened only after attachment.
334
+ // `data-remember-state="false"` (a `<Memo rememberState={false}>`)
335
+ // keeps the default on every visit; everything else remembers.
226
336
  const defaultOpen = wrapper.dataset.defaultOpen !== 'false';
227
- wirePersistedDetails(details, `section:${heading.id}`, defaultOpen);
228
- details.dataset.state = (details.open ? 'open' : 'closed') satisfies State;
337
+ const remember = wrapper.dataset.rememberState !== 'false';
229
338
  const persistKey = `section:${heading.id}`;
339
+ if (remember) wirePersistedDetails(details, persistKey, defaultOpen);
340
+ else details.open = defaultOpen;
341
+ details.dataset.state = (details.open ? 'open' : 'closed') satisfies State;
342
+ const save = (open: boolean) => {
343
+ if (remember) savePersistedOpenState(persistKey, open);
344
+ };
230
345
 
231
346
  // A real (non-peek) commit: the only place a choice gets persisted —
232
347
  // via `savePersistedOpenState`, called *synchronously* with the
@@ -247,7 +362,7 @@
247
362
  const goingOpen = next === 'open';
248
363
  if (goingOpen) details.open = true; // render before animating/measuring
249
364
  details.dataset.state = next satisfies State;
250
- savePersistedOpenState(persistKey, goingOpen);
365
+ save(goingOpen);
251
366
  growTo(body, goingOpen ? body.scrollHeight : 0, () => {
252
367
  details.open = goingOpen;
253
368
  if (goingOpen) body.style.maxHeight = '';
@@ -269,7 +384,7 @@
269
384
  details.dataset.state = 'open' satisfies State; // …then re-enter flow at that same height
270
385
  void body.offsetHeight; // reflow again before animating the grow
271
386
  body.style.transition = '';
272
- savePersistedOpenState(persistKey, true);
387
+ save(true);
273
388
  growTo(body, body.scrollHeight, () => {
274
389
  details.open = true;
275
390
  body.style.maxHeight = '';
@@ -291,33 +406,60 @@
291
406
  });
292
407
  };
293
408
 
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;
409
+ // The peek closes when the reader leaves both the title and the peeked
410
+ // body — not the moment the pointer steps off them, but on the
411
+ // explanation cards' terms (rules C2 and C12 of
412
+ // docs/plans/hover-card-consistency.md, `../card-travel.ts`): heading
413
+ // back toward the body keeps it, anything else closes it after the
414
+ // cards' grace. `:focus-visible`, not `:focus-within` — the same
415
+ // reasoning as `hover-preview.ts`: clicking `summary` to commit a peek
416
+ // (or to collapse an open section) leaves it focused as an ordinary
417
+ // side effect of the click, and that focus lingers (`:focus-within`
418
+ // stays true) long after the pointer has moved elsewhere — which, before
419
+ // this used `:focus-visible` instead, meant a peek entered right after
420
+ // such a click could never close on mouse-leave at all.
421
+ // `:focus-visible` is only true for keyboard/keyboard-equivalent focus,
422
+ // so a mouse click doesn't count, but a reader who actually tabbed here
423
+ // still keeps their peek open.
424
+ let graceTimer: ReturnType<typeof setTimeout> | null = null;
425
+ let ownTrip: Trip | null = null;
426
+ const exitUnlessWanted = () => {
427
+ if (!details.matches(':hover') && !summary.matches(':focus-visible')) {
428
+ exitPeek();
429
+ }
430
+ };
307
431
  const cancelLeave = () => {
308
- if (leaveTimer !== null) {
309
- window.cancelAnimationFrame(leaveTimer);
310
- leaveTimer = null;
432
+ if (ownTrip) {
433
+ ownTrip.end();
434
+ peekTrips.delete(ownTrip);
435
+ ownTrip = null;
311
436
  }
437
+ if (graceTimer !== null) clearTimeout(graceTimer);
438
+ graceTimer = null;
312
439
  };
313
440
  const scheduleLeave = () => {
314
441
  cancelLeave();
315
- leaveTimer = window.requestAnimationFrame(() => {
316
- leaveTimer = null;
317
- if (!details.matches(':hover') && !summary.matches(':focus-visible')) {
318
- exitPeek();
319
- }
442
+ graceTimer = setTimeout(() => {
443
+ graceTimer = null;
444
+ exitUnlessWanted();
445
+ }, CARD_CONSTANTS.travelGraceMs);
446
+ };
447
+ const travelFrom = (apex: { x: number; y: number }) => {
448
+ cancelLeave();
449
+ const trip = startTrip(apex, {
450
+ graceMs: CARD_CONSTANTS.travelGraceMs,
451
+ restMs: CARD_CONSTANTS.travelRestMs,
452
+ tripMaxMs: CARD_CONSTANTS.travelTripMaxMs,
453
+ cardBox: () => body.getBoundingClientRect(),
454
+ triggerBox: () => summary.getBoundingClientRect(),
455
+ onExit: () => {
456
+ if (ownTrip === trip) cancelLeave();
457
+ exitUnlessWanted();
458
+ },
320
459
  });
460
+ if (!trip.active) return;
461
+ ownTrip = trip;
462
+ peekTrips.add(trip);
321
463
  };
322
464
 
323
465
  summary.addEventListener('pointerenter', (event) => {
@@ -326,9 +468,16 @@
326
468
  enterPeek();
327
469
  });
328
470
  summary.addEventListener('focus', enterPeek);
471
+ details.addEventListener('pointerenter', (event) => {
472
+ if (event.pointerType !== 'touch') cancelLeave();
473
+ });
329
474
  details.addEventListener('pointerleave', (event) => {
330
475
  if (event.pointerType === 'touch') return;
331
- scheduleLeave();
476
+ if (details.dataset.state === 'peeking') {
477
+ travelFrom({ x: event.clientX, y: event.clientY });
478
+ } else {
479
+ scheduleLeave();
480
+ }
332
481
  });
333
482
  details.addEventListener('focusout', scheduleLeave);
334
483
 
@@ -342,6 +491,20 @@
342
491
  else if (details.dataset.state === 'open') commitOpenOrClosed('closed');
343
492
  else commitOpenOrClosed('open');
344
493
  });
494
+
495
+ // The browser opens a closed `<details>` by itself to show a match of
496
+ // find-in-page or a fragment inside it. Every open this state machine
497
+ // makes sets `data-state` in the same task, so an open that finds it
498
+ // still `closed` is the browser's: follow it, as a real expand.
499
+ details.addEventListener('toggle', () => {
500
+ if (!details.open || details.dataset.state !== 'closed') return;
501
+ if (details.hasAttribute('data-explico-temporary-open')) return;
502
+ supersede.get(body)?.();
503
+ supersede.delete(body);
504
+ details.dataset.state = 'open' satisfies State;
505
+ body.style.removeProperty('max-height');
506
+ save(true);
507
+ });
345
508
  }
346
509
 
347
510
  // A closed native `<details>` doesn't just hide its non-`<summary>`
@@ -402,60 +565,11 @@
402
565
  expandAncestors(currentHashId(), false);
403
566
  });
404
567
 
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
- });
568
+ // Print follows the screen: a section the reader left open prints in
569
+ // full, a closed (or merely peeking) one prints its title plus a
570
+ // "(content collapsed …)" note. `LessonExportActions.astro` does this
571
+ // when the course has the print controller; this covers the browser's
572
+ // own print on a course without it.
573
+ installPrintAsShownFallback();
460
574
  })();
461
575
  </script>
@@ -2,8 +2,8 @@
2
2
  /**
3
3
  * Progressive behavior for `<Diagram>`:
4
4
  *
5
- * - a persistent, animated title disclosure;
6
- * - a compact horizontal expand control shown only when the canvas overflows;
5
+ * - the widen button ↔ at the end of the diagram's caption line (a
6
+ * `.float-tool`, like a table's), shown only while the canvas overflows;
7
7
  * - an in-page expanded surface that moves the real figure (never a clone) to
8
8
  * `<body>`, overlays its placeholder, and follows that placeholder while the
9
9
  * page scrolls — the same spatial model as `TableOverflowExpander.astro`.
@@ -16,15 +16,9 @@
16
16
  ---
17
17
 
18
18
  <script>
19
- import {
20
- savePersistedOpenState,
21
- wirePersistedDetails,
22
- } from '../disclosure/persisted-disclosure';
23
- import { claimActiveSurface, releaseActiveSurface } from '../hover-panel';
19
+ import { closeTransientCards } from '../card-manager';
24
20
 
25
- const GROUP = 'diagram-overflow';
26
21
  const EDGE_PX = 16;
27
- const ANIMATION_MS = 220;
28
22
 
29
23
  interface Expansion {
30
24
  readonly figure: HTMLElement;
@@ -56,8 +50,8 @@
56
50
 
57
51
  function captionFor(figure: HTMLElement): string {
58
52
  return (
59
- figure.querySelector<HTMLElement>('.diagram__summary')?.textContent?.trim() ??
60
- 'this diagram'
53
+ figure.querySelector<HTMLElement>(':scope > figcaption[data-float-name]')?.dataset
54
+ .floatName ?? 'this diagram'
61
55
  );
62
56
  }
63
57
 
@@ -74,8 +68,6 @@
74
68
  : `Expand ${captionFor(figure)} horizontally`,
75
69
  );
76
70
  button.title = expanded ? 'Shrink diagram' : 'Expand diagram horizontally';
77
- const srText = button.querySelector<HTMLElement>('.sr-only');
78
- if (srText) srText.textContent = expanded ? 'Shrink diagram' : 'Expand diagram';
79
71
  }
80
72
 
81
73
  function updateScrollFocusability(scroll: HTMLElement, figure: HTMLElement): void {
@@ -90,15 +82,12 @@
90
82
 
91
83
  function evaluateOverflow(
92
84
  figure: HTMLElement,
93
- details: HTMLDetailsElement,
94
- toolbar: HTMLElement,
95
85
  button: HTMLButtonElement,
96
86
  scroll: HTMLElement,
97
87
  ): void {
98
88
  if (active?.figure === figure) return;
99
- const overflows = details.open && scroll.scrollWidth > scroll.clientWidth + 1;
89
+ const overflows = scroll.scrollWidth > scroll.clientWidth + 1;
100
90
  figure.dataset.diagramOverflow = String(overflows);
101
- toolbar.hidden = !overflows;
102
91
  button.hidden = !overflows;
103
92
  updateScrollFocusability(scroll, figure);
104
93
  }
@@ -130,8 +119,8 @@
130
119
  // restored.
131
120
  if (active && active.figure !== figure) return;
132
121
 
133
- claimActiveSurface(GROUP, () => {});
134
- releaseActiveSurface(GROUP);
122
+ // A hovered card would linger over the expanded diagram; pinned ones stay.
123
+ closeTransientCards();
135
124
 
136
125
  const neededWidth = scroll.scrollWidth;
137
126
  const rect = figure.getBoundingClientRect();
@@ -183,13 +172,13 @@
183
172
  80,
184
173
  );
185
174
  figure.style.maxHeight = `${maxHeight}px`;
186
- const titleHeight =
175
+ const captionHeight =
187
176
  figure
188
- .querySelector<HTMLElement>('.diagram__summary')
177
+ .querySelector<HTMLElement>(':scope > figcaption')
189
178
  ?.getBoundingClientRect().height ?? 0;
190
179
  figure.style.setProperty(
191
180
  '--diagram-expanded-body-height',
192
- `${Math.max(maxHeight - titleHeight, 40)}px`,
181
+ `${Math.max(maxHeight - captionHeight, 40)}px`,
193
182
  );
194
183
  updateScrollFocusability(scroll, figure);
195
184
  };
@@ -212,126 +201,22 @@
212
201
  window.addEventListener('resize', reposition, { passive: true });
213
202
  }
214
203
 
215
- function wireDisclosure(
216
- figure: HTMLElement,
217
- details: HTMLDetailsElement,
218
- body: HTMLElement,
219
- evaluate: () => void,
220
- ): void {
221
- const summary = details.querySelector<HTMLElement>(':scope > summary');
222
- if (!summary) return;
223
- const defaultOpen = details.open;
224
- const remember = details.dataset.rememberState === 'true';
225
- if (remember) {
226
- wirePersistedDetails(details, `diagram:${figure.id}`, defaultOpen);
227
- }
228
- details.dataset.state = details.open ? 'open' : 'closed';
229
-
230
- // An external/bookmarked deep link reveals the complete diagram. Later
231
- // in-page jumps retain the reader's persisted minimized state.
232
- if (location.hash === `#${figure.id}`) {
233
- details.open = true;
234
- details.dataset.state = 'open';
235
- }
236
-
237
- const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
238
- let finishTimer: number | null = null;
239
- let transitionEnd: ((event: TransitionEvent) => void) | null = null;
240
-
241
- const cancelAnimation = () => {
242
- if (finishTimer !== null) window.clearTimeout(finishTimer);
243
- finishTimer = null;
244
- if (transitionEnd) body.removeEventListener('transitionend', transitionEnd);
245
- transitionEnd = null;
246
- body.style.removeProperty('transition');
247
- body.style.removeProperty('overflow');
248
- };
249
-
250
- const setOpen = (open: boolean) => {
251
- cancelAnimation();
252
- const current = body.getBoundingClientRect().height;
253
- if (open) details.open = true;
254
- details.dataset.state = open ? 'open' : 'closed';
255
- if (remember) savePersistedOpenState(`diagram:${figure.id}`, open);
256
-
257
- if (reduceMotion.matches) {
258
- details.open = open;
259
- body.style.maxHeight = open ? '' : '0px';
260
- if (active?.figure === figure) {
261
- active.placeholder.style.height = `${figure.getBoundingClientRect().height}px`;
262
- }
263
- evaluate();
264
- active?.reposition();
265
- return;
266
- }
267
-
268
- body.style.transition = 'none';
269
- body.style.maxHeight = `${current}px`;
270
- body.style.overflow = 'hidden';
271
- void body.offsetHeight;
272
- body.style.removeProperty('transition');
273
-
274
- const expandedLimit = Number.parseFloat(
275
- getComputedStyle(figure).getPropertyValue(
276
- '--diagram-expanded-body-height',
277
- ),
278
- );
279
- const target = open
280
- ? Math.min(
281
- body.scrollHeight,
282
- Number.isFinite(expandedLimit) ? expandedLimit : Number.POSITIVE_INFINITY,
283
- )
284
- : 0;
285
- const finish = () => {
286
- cancelAnimation();
287
- details.open = open;
288
- body.style.maxHeight = open ? '' : '0px';
289
- if (active?.figure === figure) {
290
- active.placeholder.style.height = `${figure.getBoundingClientRect().height}px`;
291
- }
292
- evaluate();
293
- active?.reposition();
294
- };
295
- transitionEnd = (event) => {
296
- if (event.target === body && event.propertyName === 'max-height') finish();
297
- };
298
- body.addEventListener('transitionend', transitionEnd);
299
- finishTimer = window.setTimeout(finish, ANIMATION_MS + 60);
300
- requestAnimationFrame(() => {
301
- body.style.maxHeight = `${target}px`;
302
- });
303
- };
304
-
305
- summary.addEventListener('click', (event) => {
306
- event.preventDefault();
307
- setOpen(details.dataset.state !== 'open');
308
- });
309
- }
310
-
311
204
  function setup(): void {
312
205
  for (const figure of document.querySelectorAll<HTMLElement>(
313
206
  '.diagram[data-diagram-expandable]',
314
207
  )) {
315
208
  if (figure.dataset.diagramExpandReady === 'true') continue;
316
- const details = figure.querySelector<HTMLDetailsElement>(
317
- '[data-diagram-details]',
318
- );
319
- const body = figure.querySelector<HTMLElement>('[data-diagram-body]');
320
- const toolbar = figure.querySelector<HTMLElement>('[data-diagram-toolbar]');
321
209
  const button = figure.querySelector<HTMLButtonElement>('[data-diagram-expand]');
322
210
  const scroll = figure.querySelector<HTMLElement>('.diagram-scroll');
323
- if (!details || !body || !toolbar || !button || !scroll) continue;
211
+ if (!button || !scroll) continue;
324
212
  figure.dataset.diagramExpandReady = 'true';
325
213
 
326
- const evaluate = () =>
327
- evaluateOverflow(figure, details, toolbar, button, scroll);
214
+ const evaluate = () => evaluateOverflow(figure, button, scroll);
328
215
  button.addEventListener('click', () => {
329
216
  if (active?.figure === figure) collapseExpansion();
330
217
  else expand(figure, button, scroll, evaluate);
331
218
  });
332
219
  new ResizeObserver(evaluate).observe(scroll);
333
- details.addEventListener('toggle', evaluate);
334
- wireDisclosure(figure, details, body, evaluate);
335
220
  updateExpandButton(figure, button, false);
336
221
  evaluate();
337
222
  }
@@ -359,16 +244,8 @@
359
244
  box-shadow: var(--sl-shadow-lg);
360
245
  }
361
246
 
362
- .diagram-is-expanded .diagram__titlebar,
363
- .diagram-is-expanded .diagram__details {
364
- max-height: inherit;
365
- }
366
-
367
- .diagram-is-expanded .diagram__details {
368
- display: flex;
369
- flex-direction: column;
370
- }
371
-
247
+ /* The caption line stays below the graph; the body takes the height the
248
+ caption leaves (`--diagram-expanded-body-height`) and scrolls. */
372
249
  .diagram-is-expanded .diagram__body {
373
250
  min-height: 0;
374
251
  max-height: var(--diagram-expanded-body-height);