explico 0.11.1 → 0.13.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 (298) hide show
  1. package/CHANGELOG.md +1292 -1
  2. package/README.md +91 -13
  3. package/dist/compiler/astro.d.ts +11 -4
  4. package/dist/compiler/astro.js +48 -15
  5. package/dist/compiler/cli.d.ts +27 -12
  6. package/dist/compiler/cli.js +96 -25
  7. package/dist/compiler/collections.d.ts +33 -2
  8. package/dist/compiler/collections.js +91 -19
  9. package/dist/compiler/content-schema.d.ts +31 -0
  10. package/dist/compiler/content-schema.js +82 -0
  11. package/dist/compiler/content-schemas.d.ts +422 -0
  12. package/dist/compiler/content-schemas.js +229 -0
  13. package/dist/compiler/course-config.d.ts +1 -1
  14. package/dist/compiler/course-config.js +30 -2
  15. package/dist/compiler/exports/combined-html.d.ts +97 -0
  16. package/dist/compiler/exports/combined-html.js +492 -0
  17. package/dist/compiler/exports/download-index.d.ts +9 -0
  18. package/dist/compiler/exports/download-index.js +33 -0
  19. package/dist/compiler/exports/export-chapters.d.ts +98 -0
  20. package/dist/compiler/exports/export-chapters.js +164 -0
  21. package/dist/compiler/exports/export-pages.d.ts +48 -0
  22. package/dist/compiler/exports/export-pages.js +22 -0
  23. package/dist/compiler/exports/html-tree.d.ts +22 -0
  24. package/dist/compiler/exports/html-tree.js +47 -0
  25. package/dist/compiler/exports/latex/capture.d.ts +106 -0
  26. package/dist/compiler/exports/latex/capture.js +435 -0
  27. package/dist/compiler/exports/latex/compile.d.ts +73 -0
  28. package/dist/compiler/exports/latex/compile.js +115 -0
  29. package/dist/compiler/exports/latex/document.d.ts +102 -0
  30. package/dist/compiler/exports/latex/document.js +349 -0
  31. package/dist/compiler/exports/latex/explico.cls +415 -0
  32. package/dist/compiler/exports/latex/numbering.d.ts +18 -0
  33. package/dist/compiler/exports/latex/numbering.js +57 -0
  34. package/dist/compiler/exports/latex/serialize.d.ts +112 -0
  35. package/dist/compiler/exports/latex/serialize.js +606 -0
  36. package/dist/compiler/exports/latex-export.d.ts +44 -0
  37. package/dist/compiler/exports/latex-export.js +169 -0
  38. package/dist/compiler/exports/markdown/serialize.d.ts +51 -0
  39. package/dist/compiler/exports/markdown/serialize.js +527 -0
  40. package/dist/compiler/exports/markdown-export.d.ts +39 -0
  41. package/dist/compiler/exports/markdown-export.js +240 -0
  42. package/dist/compiler/exports/standalone-html.d.ts +15 -5
  43. package/dist/compiler/exports/standalone-html.js +23 -51
  44. package/dist/compiler/exports/static-tree.d.ts +253 -0
  45. package/dist/compiler/exports/static-tree.js +625 -0
  46. package/dist/compiler/exports/zip.d.ts +16 -0
  47. package/dist/compiler/exports/zip.js +72 -0
  48. package/dist/compiler/lesson-derivation.d.ts +8 -2
  49. package/dist/compiler/lesson-derivation.js +6 -7
  50. package/dist/compiler/manifest.d.ts +115 -8
  51. package/dist/compiler/manifest.js +221 -39
  52. package/dist/compiler/render-diagnostics.d.ts +65 -0
  53. package/dist/compiler/render-diagnostics.js +56 -0
  54. package/dist/compiler/resolution-report.d.ts +3 -1
  55. package/dist/{course-config-Dsnri9Tk.d.ts → course-config-rRtME5UV.d.ts} +56 -13
  56. package/dist/curriculum/validation.d.ts +9 -3
  57. package/dist/curriculum/validation.js +35 -17
  58. package/dist/reference/base-abbreviations.d.mts +2 -2
  59. package/dist/reference/base-abbreviations.mjs +2 -2
  60. package/dist/reference/base-operators.d.mts +8 -2
  61. package/dist/reference/base-operators.mjs +8 -10
  62. package/dist/reference/base-roman-labels.d.mts +4 -3
  63. package/dist/reference/base-roman-labels.mjs +4 -3
  64. package/dist/reference/citation-format.d.mts +35 -0
  65. package/dist/reference/citation-format.mjs +74 -0
  66. package/dist/reference/consistency.d.ts +5 -9
  67. package/dist/reference/consistency.js +34 -38
  68. package/dist/reference/curriculum-alignment.js +3 -24
  69. package/dist/reference/derived-entries.d.ts +45 -0
  70. package/dist/reference/derived-entries.js +50 -0
  71. package/dist/reference/derived-notation.d.mts +125 -0
  72. package/dist/reference/derived-notation.mjs +121 -0
  73. package/dist/reference/diagram-fit.d.ts +45 -0
  74. package/dist/reference/diagram-fit.js +54 -0
  75. package/dist/reference/diagram-layout.d.ts +59 -10
  76. package/dist/reference/diagram-layout.js +85 -24
  77. package/dist/reference/diagrams-validate.d.ts +1 -1
  78. package/dist/reference/equations-validate.d.ts +1 -1
  79. package/dist/reference/equations-validate.js +26 -1
  80. package/dist/reference/equations.d.mts +27 -0
  81. package/dist/reference/equations.mjs +120 -22
  82. package/dist/reference/examples-validate.d.ts +1 -1
  83. package/dist/reference/figure-fit.d.ts +70 -0
  84. package/dist/reference/figure-fit.js +60 -0
  85. package/dist/reference/figure-pictures.d.ts +27 -0
  86. package/dist/reference/figure-pictures.js +49 -0
  87. package/dist/reference/figure-symbols.d.ts +32 -0
  88. package/dist/reference/figure-symbols.js +55 -0
  89. package/dist/reference/figures-validate.d.ts +1 -1
  90. package/dist/reference/gate-abbreviations.d.ts +1 -1
  91. package/dist/reference/gate-abbreviations.js +1 -1
  92. package/dist/reference/gate-course-rules.d.ts +4 -3
  93. package/dist/reference/gate-course-rules.js +9 -4
  94. package/dist/reference/gate-heading-length.d.ts +2 -0
  95. package/dist/reference/gate-heading-length.js +7 -5
  96. package/dist/reference/gate-heading-structure.js +26 -0
  97. package/dist/reference/gate-indices.d.ts +35 -0
  98. package/dist/reference/gate-indices.js +299 -0
  99. package/dist/reference/gate-math.d.ts +32 -2
  100. package/dist/reference/gate-math.js +68 -12
  101. package/dist/reference/gate-mdx-expression.d.ts +17 -0
  102. package/dist/reference/gate-mdx-expression.js +29 -0
  103. package/dist/reference/gate-non-chapter-pages.d.ts +39 -0
  104. package/dist/reference/gate-non-chapter-pages.js +156 -0
  105. package/dist/reference/gate-part-pages.d.ts +4 -2
  106. package/dist/reference/gate-part-pages.js +2 -1
  107. package/dist/reference/gate-prop-text.d.ts +28 -0
  108. package/dist/reference/gate-prop-text.js +106 -0
  109. package/dist/reference/gate-prose-notation.d.ts +3 -1
  110. package/dist/reference/gate-prose-notation.js +4 -2
  111. package/dist/reference/gate-retired-keys.d.ts +35 -0
  112. package/dist/reference/gate-retired-keys.js +58 -0
  113. package/dist/reference/gloss.d.ts +2 -0
  114. package/dist/reference/katex-measure.d.ts +17 -0
  115. package/dist/reference/katex-measure.js +223 -0
  116. package/dist/reference/keyed-float-validate.js +12 -0
  117. package/dist/reference/keyed-jsx-floats.d.mts +2 -2
  118. package/dist/reference/keyed-jsx-floats.mjs +11 -14
  119. package/dist/reference/lab-katex-validate.d.ts +43 -0
  120. package/dist/reference/lab-katex-validate.js +96 -0
  121. package/dist/reference/lab-math-scope.d.ts +1 -0
  122. package/dist/reference/lab-math-scope.js +13 -2
  123. package/dist/reference/lint-ignore.js +1 -1
  124. package/dist/reference/math-glyphs.d.mts +94 -2
  125. package/dist/reference/math-glyphs.mjs +851 -46
  126. package/dist/reference/mixed-content.d.ts +8 -3
  127. package/dist/reference/mixed-content.js +4 -2
  128. package/dist/reference/page-notation.d.ts +39 -7
  129. package/dist/reference/page-notation.js +62 -10
  130. package/dist/reference/raw-floats.mjs +37 -13
  131. package/dist/reference/references.d.ts +11 -1
  132. package/dist/reference/references.js +6 -0
  133. package/dist/reference/registry.js +115 -11
  134. package/dist/reference/rehype-fail-katex-errors.d.mts +9 -0
  135. package/dist/reference/rehype-fail-katex-errors.mjs +19 -0
  136. package/dist/reference/rehype-heading-anchors.mjs +11 -1
  137. package/dist/reference/remark-citation.mjs +0 -0
  138. package/dist/reference/remark-notation.mjs +275 -23
  139. package/dist/reference/render-lab-math.d.ts +13 -2
  140. package/dist/reference/render-lab-math.js +18 -6
  141. package/dist/reference/sections-validate.d.ts +28 -3
  142. package/dist/reference/sections-validate.js +31 -4
  143. package/dist/reference/sections.d.mts +24 -6
  144. package/dist/reference/sections.mjs +92 -17
  145. package/dist/reference/tables-validate.d.ts +1 -1
  146. package/dist/reference/tables-validate.js +24 -1
  147. package/dist/reference/text-measure.d.ts +10 -0
  148. package/dist/reference/text-measure.js +41 -0
  149. package/dist/reference/text-widths.d.ts +10 -0
  150. package/dist/reference/text-widths.js +1238 -0
  151. package/dist/reference/types.d.ts +64 -8
  152. package/dist/rules/catalog.js +223 -32
  153. package/dist/rules/in-effect.d.ts +1 -1
  154. package/dist/rules/rules.schema.json +100 -27
  155. package/dist/rules/settings.d.ts +1 -1
  156. package/dist/rules/settings.js +7 -0
  157. package/dist/session/PreferenceStore.d.ts +18 -1
  158. package/dist/session/PreferenceStore.js +29 -1
  159. package/docs/authoring/README.md +33 -21
  160. package/docs/authoring/agents.md +38 -7
  161. package/docs/authoring/components.md +115 -10
  162. package/docs/authoring/migrating.md +878 -0
  163. package/docs/authoring/notation.md +285 -30
  164. package/docs/authoring/retired.md +1 -0
  165. package/docs/authoring/structure-rules.md +84 -45
  166. package/docs/authoring/structure.md +67 -2
  167. package/docs/authoring/visualizations.md +167 -25
  168. package/package.json +12 -7
  169. package/src/compiler/astro.ts +72 -21
  170. package/src/compiler/cli.ts +143 -35
  171. package/src/compiler/collections.ts +158 -27
  172. package/src/compiler/content-schema.ts +108 -0
  173. package/src/compiler/content-schemas.ts +391 -0
  174. package/src/compiler/course-config.ts +86 -15
  175. package/src/compiler/exports/combined-html.ts +669 -0
  176. package/src/compiler/exports/download-index.ts +36 -0
  177. package/src/compiler/exports/export-chapters.ts +233 -0
  178. package/src/compiler/exports/export-pages.ts +44 -0
  179. package/src/compiler/exports/html-tree.ts +62 -0
  180. package/src/compiler/exports/latex/capture.ts +651 -0
  181. package/src/compiler/exports/latex/compile.ts +168 -0
  182. package/src/compiler/exports/latex/document.ts +479 -0
  183. package/src/compiler/exports/latex/explico.cls +415 -0
  184. package/src/compiler/exports/latex/numbering.ts +74 -0
  185. package/src/compiler/exports/latex/serialize.ts +787 -0
  186. package/src/compiler/exports/latex/texlive-packages.txt +76 -0
  187. package/src/compiler/exports/latex-export.ts +255 -0
  188. package/src/compiler/exports/markdown/serialize.ts +632 -0
  189. package/src/compiler/exports/markdown-export.ts +329 -0
  190. package/src/compiler/exports/standalone-html.ts +39 -70
  191. package/src/compiler/exports/static-tree.ts +944 -0
  192. package/src/compiler/exports/zip.ts +88 -0
  193. package/src/compiler/lesson-derivation.ts +13 -8
  194. package/src/compiler/manifest.ts +387 -40
  195. package/src/compiler/render-diagnostics.ts +111 -0
  196. package/src/components/CourseContents.astro +6 -0
  197. package/src/components/HoverPanel.astro +204 -6
  198. package/src/components/PartOverview.astro +18 -9
  199. package/src/components/abbreviation/AbbreviationGlossary.astro +6 -2
  200. package/src/components/abbreviation/AbbreviationLayer.astro +21 -12
  201. package/src/components/assessments/AssessmentRunner.tsx +7 -1
  202. package/src/components/assessments/AssessmentSet.astro +14 -1
  203. package/src/components/block-heading.ts +38 -0
  204. package/src/components/card-arrow.ts +18 -4
  205. package/src/components/card-carry.ts +406 -0
  206. package/src/components/card-drag.ts +1 -30
  207. package/src/components/card-fetch.ts +87 -0
  208. package/src/components/card-manager.ts +1222 -38
  209. package/src/components/card-memory.ts +5 -1
  210. package/src/components/card-placement.ts +211 -13
  211. package/src/components/card-side.ts +281 -31
  212. package/src/components/citation/CitationLayer.astro +45 -40
  213. package/src/components/diagram/Diagram.astro +123 -44
  214. package/src/components/disclosure/print-as-shown.ts +42 -6
  215. package/src/components/figure/Figure.astro +17 -1
  216. package/src/components/figure/FloatCaption.astro +4 -17
  217. package/src/components/figure/figure-state.ts +26 -17
  218. package/src/components/float-toolbar.ts +274 -0
  219. package/src/components/memo/Memo.astro +21 -10
  220. package/src/components/memo/ShortNote.astro +4 -0
  221. package/src/components/notation/NotationGlossary.astro +22 -17
  222. package/src/components/notation/NotationLayer.astro +300 -79
  223. package/src/components/notation/NotationMath.astro +8 -1
  224. package/src/components/notation/render-notation-math.ts +51 -5
  225. package/src/components/readable-text.ts +25 -0
  226. package/src/components/reference/FloatRefLayer.astro +162 -89
  227. package/src/components/starlight/HeadingAnchorEnhancer.astro +13 -8
  228. package/src/components/starlight/KeyedFloatEnhancer.astro +28 -9
  229. package/src/components/starlight/LayoutPageSidebar.astro +17 -3
  230. package/src/components/starlight/LayoutSocialIcons.astro +79 -29
  231. package/src/components/starlight/LessonExportActions.astro +384 -310
  232. package/src/components/starlight/LessonFooter.astro +66 -8
  233. package/src/components/starlight/PrintController.astro +28 -0
  234. package/src/components/starlight/TextSizeControl.astro +137 -0
  235. package/src/components/starlight/print-controller.ts +380 -0
  236. package/src/components/starlight/section-summary.ts +13 -5
  237. package/src/components/table/Table.astro +14 -22
  238. package/src/components/table/table-truncation.ts +15 -0
  239. package/src/content-config.ts +20 -312
  240. package/src/curriculum/validation.ts +72 -24
  241. package/src/reference/base-abbreviations.mjs +2 -2
  242. package/src/reference/base-operators.mjs +8 -10
  243. package/src/reference/base-roman-labels.mjs +4 -3
  244. package/src/reference/citation-format.mjs +74 -0
  245. package/src/reference/consistency.ts +47 -61
  246. package/src/reference/curriculum-alignment.ts +4 -41
  247. package/src/reference/derived-entries.ts +68 -0
  248. package/src/reference/derived-notation.mjs +121 -0
  249. package/src/reference/diagram-fit.ts +104 -0
  250. package/src/reference/diagram-layout.ts +182 -51
  251. package/src/reference/diagrams-validate.ts +2 -1
  252. package/src/reference/equations-validate.ts +41 -2
  253. package/src/reference/equations.mjs +120 -22
  254. package/src/reference/examples-validate.ts +1 -0
  255. package/src/reference/figure-fit.ts +139 -0
  256. package/src/reference/figure-pictures.ts +96 -0
  257. package/src/reference/figure-symbols.ts +117 -0
  258. package/src/reference/figures-validate.ts +2 -1
  259. package/src/reference/gate-abbreviations.ts +1 -1
  260. package/src/reference/gate-course-rules.ts +22 -7
  261. package/src/reference/gate-heading-length.ts +25 -7
  262. package/src/reference/gate-heading-structure.ts +40 -1
  263. package/src/reference/gate-indices.ts +470 -0
  264. package/src/reference/gate-math.ts +102 -13
  265. package/src/reference/gate-mdx-expression.ts +45 -0
  266. package/src/reference/gate-non-chapter-pages.ts +241 -0
  267. package/src/reference/gate-part-pages.ts +1 -1
  268. package/src/reference/gate-prop-text.ts +145 -0
  269. package/src/reference/gate-prose-notation.ts +19 -5
  270. package/src/reference/gate-retired-keys.ts +101 -0
  271. package/src/reference/gloss.ts +2 -0
  272. package/src/reference/katex-measure.ts +384 -0
  273. package/src/reference/keyed-float-validate.ts +22 -0
  274. package/src/reference/keyed-jsx-floats.mjs +11 -14
  275. package/src/reference/lab-katex-validate.ts +156 -0
  276. package/src/reference/lab-math-scope.ts +15 -1
  277. package/src/reference/lint-ignore.ts +4 -1
  278. package/src/reference/math-glyphs.mjs +851 -46
  279. package/src/reference/mixed-content.ts +11 -2
  280. package/src/reference/page-notation.ts +114 -14
  281. package/src/reference/raw-floats.mjs +37 -13
  282. package/src/reference/references.ts +14 -0
  283. package/src/reference/registry.ts +170 -9
  284. package/src/reference/rehype-fail-katex-errors.mjs +19 -0
  285. package/src/reference/rehype-heading-anchors.mjs +11 -1
  286. package/src/reference/remark-citation.mjs +0 -0
  287. package/src/reference/remark-notation.mjs +275 -23
  288. package/src/reference/render-lab-math.ts +40 -5
  289. package/src/reference/sections-validate.ts +67 -3
  290. package/src/reference/sections.mjs +92 -17
  291. package/src/reference/tables-validate.ts +40 -2
  292. package/src/reference/text-measure.ts +63 -0
  293. package/src/reference/text-widths.ts +133 -0
  294. package/src/reference/types.ts +75 -8
  295. package/src/rules/catalog.ts +241 -31
  296. package/src/rules/settings.ts +10 -0
  297. package/src/session/PreferenceStore.ts +53 -0
  298. package/src/styles/global.css +322 -83
@@ -25,7 +25,12 @@
25
25
  * - a card's grip drags it anywhere between the sidebars, or into the side
26
26
  * column, and its arrow keys move it (`./card-drag.ts`);
27
27
  * - the kept cards are remembered when the page is left and come back when
28
- * the reader does, even after a browser restart (`./card-memory.ts`).
28
+ * the reader does, even after a browser restart (`./card-memory.ts`);
29
+ * - with "Carry cards" on, a kept card stays on screen, held at the window's
30
+ * top or bottom edge once its place scrolls past it (`heldAt`,
31
+ * `./card-placement.ts`, rules K2–K4), and comes along to every page of
32
+ * the course: attached to its trigger again on its own page, rebuilt from
33
+ * that page anywhere else (`./card-carry.ts`, K5).
29
34
  *
30
35
  * The registry's rules are pure functions over plain `CardState` records
31
36
  * (`escapeTarget`, `chainOf`, `closedByOpening`, …) so `tests/unit/` can check
@@ -39,7 +44,6 @@ import {
39
44
  movedAt,
40
45
  movedSpot,
41
46
  nudgeSpot,
42
- sidebarsBetween,
43
47
  type CardDrag,
44
48
  type DragHost,
45
49
  type MovedSpot,
@@ -51,38 +55,78 @@ import {
51
55
  writeRememberedCards,
52
56
  type RememberedCard,
53
57
  } from './card-memory';
58
+ import {
59
+ capturePicture,
60
+ carriedKeyOf,
61
+ decodeCarried,
62
+ encodeCarried,
63
+ forgetCarried,
64
+ openedAtText,
65
+ readCardConfig,
66
+ readCarried,
67
+ readCarry,
68
+ restorePicture,
69
+ sectionPlace,
70
+ stashOpenedAt,
71
+ takeOpenedAt,
72
+ writeCarried,
73
+ writeCarry,
74
+ type CarriedCard,
75
+ type CarriedSet,
76
+ type OpenedAt,
77
+ } from './card-carry';
78
+ import { isThisPage, pageTitleOf } from './card-fetch';
79
+ import { wireJumpArrow } from './card-jump';
80
+ import { readableText } from './readable-text';
81
+ import { animatedScrollIntoView } from './scroll-animation';
54
82
  import {
55
83
  anchoredSpot,
56
84
  articleColumn,
57
85
  cardLayer,
86
+ heldAt,
58
87
  moveTo,
59
88
  placeElement,
60
89
  reanchorElement,
61
90
  scrollContainersOf,
62
91
  watchCardLayout,
63
92
  type CardAnchor,
93
+ type Held,
94
+ type HoldCard,
95
+ type HoldEdge,
64
96
  type SideSpot,
65
97
  } from './card-placement';
66
98
  import {
67
99
  articleContainers,
68
100
  articleLeft,
101
+ closedRoom,
69
102
  columnLeft,
70
- createKeepLeftToggle,
103
+ createSideControls,
71
104
  headerBottom,
72
105
  layoutColumn,
73
106
  lineTopOf,
74
107
  measureSide,
108
+ offeredWidths,
109
+ railLeftEdge,
75
110
  readKeepLeft,
111
+ readSideWidth,
112
+ readSideWidthOffered,
76
113
  remToPx,
77
114
  roomAt,
78
115
  sideColumn,
116
+ sideWidthPixels,
117
+ sidebarsBetween,
79
118
  slideArticle,
119
+ SIDE_WIDTH_OPTIONS,
80
120
  writeKeepLeft,
121
+ writeSideWidth,
122
+ writeSideWidthOffered,
81
123
  type ColumnEntry,
82
- type KeepLeftToggle,
83
124
  type SideColumn,
125
+ type SideControls,
126
+ type SideControlsState,
84
127
  type SideMeasure,
85
128
  type SideOptions,
129
+ type SideWidthPreset,
86
130
  } from './card-side';
87
131
  import { startTrip, type Box, type Point, type Trip } from './card-travel';
88
132
 
@@ -111,6 +155,8 @@ export const CARD_CONSTANTS = {
111
155
  travelTripMaxMs: 2500,
112
156
  /** C8: how long a kept card flashes (ms). */
113
157
  flashMs: 500,
158
+ /** K7: how long the trigger an "Opened at" link lands on stays lit (ms). */
159
+ originFlashMs: 1200,
114
160
  /** S1: the side column's gaps to the article and to the rail (px). */
115
161
  sideGap: 16,
116
162
  /** S1: the side card's narrowest and widest. */
@@ -261,6 +307,34 @@ export interface CardSource {
261
307
  render(item: string, trigger: HTMLElement): HTMLElement | null | Promise<HTMLElement | null>;
262
308
  /** After any of this layer's cards opens, closes or changes mode: the open items. */
263
309
  onChange?(open: ReadonlyMap<string, CardMode>): void;
310
+ /**
311
+ * The item's own element on this page, which a card may hang from as from
312
+ * a trigger (`keepCardFor`): a float's number, for its toolbar's Pin and
313
+ * Dock. It is no trigger: hovering or clicking it opens nothing.
314
+ */
315
+ ownAnchor?(item: string): HTMLElement | null;
316
+ /**
317
+ * K5: the card for `item`, which a trigger on `page` opened, built for
318
+ * another page from `page`, fetched (`./card-fetch.ts`). Links in it go to
319
+ * `page`. Without it, the layer's cards do not travel.
320
+ */
321
+ build?(item: string, page: string): HTMLElement | null | Promise<HTMLElement | null>;
322
+ /**
323
+ * K5: `item`'s name on another page, when its own name means something only
324
+ * on `page`: a citation's number, a reference to `page`'s own equation. The
325
+ * card's key there is `layer:awayItem`. Default: `item` itself.
326
+ */
327
+ awayItem?(item: string, page: string): string;
328
+ /**
329
+ * D4, K6: the glyph a trigger shows, comparable across pages (a notation
330
+ * entry's LaTeX), or `null`. A card from another page for a symbol of
331
+ * that page's own (`data-card-scope="local"`, its glyph in
332
+ * `data-card-glyph`) points at a trigger with its glyph with a dashed
333
+ * arrow: the same letters, defined elsewhere.
334
+ */
335
+ glyphOf?(trigger: HTMLElement): string | null;
336
+ /** D4: every trigger on the page outside the cards whose glyph is `glyph`. */
337
+ lookalikes?(glyph: string): HTMLElement[];
264
338
  }
265
339
 
266
340
  let shellSerial = 0;
@@ -283,6 +357,16 @@ export function cloneCardShell(template: HTMLTemplateElement): HTMLElement | nul
283
357
  // The manager.
284
358
  // ---------------------------------------------------------------------------
285
359
 
360
+ /** K5: a card carried here from another page, where its trigger is. */
361
+ interface Away {
362
+ /** The carried card as the set names it: its page, its item there, its occurrence, its place there. */
363
+ readonly saved: CarriedCard;
364
+ /** K2: the edge it is held at here. */
365
+ edge: HoldEdge;
366
+ /** Its left against the article column's (px). */
367
+ dx: number;
368
+ }
369
+
286
370
  interface CardRecord extends CardState {
287
371
  readonly key: string;
288
372
  readonly item: string;
@@ -301,6 +385,21 @@ interface CardRecord extends CardState {
301
385
  dropAt: Point | null;
302
386
  /** S2, D3: a docked card's top minus its trigger's line top; a drop sets its own. */
303
387
  sideDy: number;
388
+ /** S11: a docked card's own width, from its edge; `null` takes the column's. */
389
+ width: number | null;
390
+ /** K2: where the card is held at the window's edge, while it is. */
391
+ held: Held | null;
392
+ /** K2: the place the layout gave it with the page, kept while it is held. */
393
+ natural: { readonly left: string; readonly top: string; readonly maxHeight: string } | null;
394
+ /** K3: its height whole and its header row's, measured while it shows whole. */
395
+ fullHeight: number;
396
+ headerHeight: number;
397
+ /** K3: the lowest-ranked held card collapses first: its order, renewed when the reader expands it. */
398
+ rank: number;
399
+ /** K5: carried here from another page, with no trigger on this one; `null` for a card attached here. */
400
+ away: Away | null;
401
+ /** K7: where the card was opened, once it is kept. */
402
+ openedAt: OpenedAt | null;
304
403
  graceTimer: ReturnType<typeof setTimeout> | null;
305
404
  flashTimer: ReturnType<typeof setTimeout> | null;
306
405
  unwatch: () => void;
@@ -356,7 +455,10 @@ let slid = 0;
356
455
  let pointedKey: string | null = null;
357
456
  /** S9: "Keep the article left", as the reader last set it. */
358
457
  let keepLeft = false;
359
- let keepLeftToggle: KeepLeftToggle | null = null;
458
+ let sideControls: SideControls | null = null;
459
+ /** S11: the width menu's choice, and whether the menu is offered yet. */
460
+ let sideWidth: SideWidthPreset = 'default';
461
+ let sideWidthOffered = false;
360
462
  /** S5: the one arrow, from a kept card to an occurrence of its item. */
361
463
  let arrow: ArrowOverlay | null = null;
362
464
  /** S4: the animations moving cards and the article now. */
@@ -369,6 +471,28 @@ let dragged: {
369
471
  readonly measure: SideMeasure | null;
370
472
  readonly column: SideColumn | null;
371
473
  } | null = null;
474
+ /** K1: "Carry cards", as the reader last set it. */
475
+ let carry = false;
476
+ /** S9, S11: the side column the controls row sits at the top of, while it is in use. */
477
+ let columnInUse: { readonly left: number; readonly width: SideControlsState['width'] } | null = null;
478
+ /** K2: whether the one scroll listener that holds cards is on. */
479
+ let holding = false;
480
+ /** K5: no carrying in a standalone export, which has no other pages to fetch. */
481
+ let standalone = false;
482
+ /** K5: the course's base path, which names its carried set. */
483
+ let base = '/';
484
+ /** K5: carried cards from other pages, waiting for their layer, then for their build. */
485
+ let awayQueue: CarriedCard[] = [];
486
+ const awayBuilding = new Set<CarriedCard>();
487
+ /** K5: every carried card from another page this page restored, to find a child's parent. */
488
+ let awayEntries: CarriedCard[] = [];
489
+ /** C10, K3: the order a restored card had, so cards arriving late keep their place. */
490
+ const restoreOrder = new Map<RememberedCard, number>();
491
+ /** K5: the set this page last stored, to tell when another page changed it (Back, Forward). */
492
+ let lastStored: string | null = null;
493
+ let saveTimer: ReturnType<typeof setTimeout> | null = null;
494
+ /** K5: no saving while the cards are put back from a stored set. */
495
+ let quiet = false;
372
496
  /** Cards this page had open when the reader last left it, not restored yet. */
373
497
  let remembered: RememberedCard[] = [];
374
498
  let rememberedPointed: string | null = null;
@@ -431,6 +555,7 @@ function wanted(card: CardRecord): boolean {
431
555
  }
432
556
 
433
557
  function notify(source: CardSource): void {
558
+ scheduleSave();
434
559
  if (!source.onChange) return;
435
560
  const open = new Map<string, CardMode>();
436
561
  for (const card of ordered()) if (card.source === source) open.set(card.item, card.mode);
@@ -439,6 +564,8 @@ function notify(source: CardSource): void {
439
564
 
440
565
  function syncMode(card: CardRecord): void {
441
566
  card.el.dataset.cardMode = card.mode;
567
+ showOpenedAt(card);
568
+ syncMark(card);
442
569
  for (const pin of card.el.querySelectorAll<HTMLElement>('[data-card-pin]')) {
443
570
  pin.setAttribute('aria-pressed', String(card.mode === 'pinned'));
444
571
  }
@@ -463,7 +590,10 @@ function setMode(card: CardRecord, mode: CardMode): void {
463
590
  card.moved = null;
464
591
  card.dropAt = null;
465
592
  }
466
- if (mode !== 'side') card.sideDy = -CARD_CONSTANTS.sideLineOffset;
593
+ if (mode !== 'side') {
594
+ card.sideDy = -CARD_CONSTANTS.sideLineOffset;
595
+ card.el.style.width = '';
596
+ }
467
597
  if (isKept(mode)) {
468
598
  cancelGrace(card);
469
599
  if (travel?.card === card) endTravel();
@@ -472,6 +602,8 @@ function setMode(card: CardRecord, mode: CardMode): void {
472
602
  }
473
603
  syncMode(card);
474
604
  notify(card.source);
605
+ if (!arranging) syncControls();
606
+ hold();
475
607
  }
476
608
 
477
609
  /** P4: the card clicked, focused or opened last is on top. */
@@ -580,6 +712,12 @@ function watch(card: CardRecord): void {
580
712
 
581
713
  /** P2, P6: place `card` next to its trigger, clear of every other open card. */
582
714
  function place(card: CardRecord): void {
715
+ release(card);
716
+ if (card.away) {
717
+ placeAway(card);
718
+ watch(card);
719
+ return;
720
+ }
583
721
  const others = ordered()
584
722
  .filter((other) => other !== card)
585
723
  .map((other) => other.el);
@@ -593,10 +731,32 @@ function place(card: CardRecord): void {
593
731
  watch(card);
594
732
  }
595
733
 
734
+ /**
735
+ * S11: each width menu size in pixels, in the card's text; Default is S1's
736
+ * widest. Measured once per root font size (the reader's text size, the
737
+ * browser's zoom), not on every layout pass.
738
+ */
739
+ let sideWidthCache: { readonly font: string; readonly widths: Map<SideWidthPreset, number> } | null =
740
+ null;
741
+ function sideWidths(): Map<SideWidthPreset, number> {
742
+ const font = getComputedStyle(document.documentElement).fontSize;
743
+ if (sideWidthCache?.font !== font) {
744
+ sideWidthCache = {
745
+ font,
746
+ widths: sideWidthPixels(cardLayer(LAYER_VARIABLES), remToPx(CARD_CONSTANTS.sideWidthMax)),
747
+ };
748
+ }
749
+ return sideWidthCache.widths;
750
+ }
751
+
752
+ /** S1, S11: the widest a docked card gets is the width menu's size. */
596
753
  const sideOptions = (): SideOptions => ({
597
754
  gap: CARD_CONSTANTS.sideGap,
598
755
  minWidth: remToPx(CARD_CONSTANTS.sideWidthMin),
599
- maxWidth: remToPx(CARD_CONSTANTS.sideWidthMax),
756
+ maxWidth:
757
+ sideWidth === 'default'
758
+ ? remToPx(CARD_CONSTANTS.sideWidthMax)
759
+ : (sideWidths().get(sideWidth) ?? remToPx(CARD_CONSTANTS.sideWidthMax)),
600
760
  });
601
761
 
602
762
  /**
@@ -637,12 +797,39 @@ function placeMoved(card: CardRecord): void {
637
797
  moveTo(card.el, keepBetween(at.x, width, sidebarsBetween(CARD_CONSTANTS.edge)), at.y);
638
798
  }
639
799
 
800
+ /**
801
+ * P1: a card beside a trigger the reader folded away — its section collapsed,
802
+ * its fold closed — has nothing to stand next to, and the text that moved up
803
+ * into the trigger's place would run under it. It waits out of sight, keeping
804
+ * its size, and comes back beside its trigger when the trigger shows again.
805
+ * Returns whether the card is folded away.
806
+ */
807
+ function foldAway(card: CardRecord): boolean {
808
+ const { trigger } = card;
809
+ // A closed `<details>` hides its content with `content-visibility: hidden`,
810
+ // which keeps the trigger's last box: only `checkVisibility` sees it gone.
811
+ // A trigger in a card that is itself folded away goes with it.
812
+ const folded =
813
+ trigger.isConnected &&
814
+ (!(typeof trigger.checkVisibility === 'function'
815
+ ? trigger.checkVisibility()
816
+ : trigger.getClientRects().length > 0) ||
817
+ trigger.closest('[data-card-folded]') !== null);
818
+ card.el.toggleAttribute('data-card-folded', folded);
819
+ return folded;
820
+ }
821
+
640
822
  /** P1: a card that is not docked goes back to its place after a layout change. */
641
823
  function reanchor(card: CardRecord, measure: SideMeasure | null): void {
824
+ if (card.away) {
825
+ placeAway(card);
826
+ return;
827
+ }
642
828
  if (card.mode === 'moved') {
643
829
  placeMoved(card);
644
830
  return;
645
831
  }
832
+ if (foldAway(card)) return;
646
833
  const { anchor } = card;
647
834
  if (!anchor) return;
648
835
  if (anchor.column) {
@@ -684,7 +871,8 @@ function columnTops(
684
871
  extra: ColumnEntry | null = null,
685
872
  ): { left: number; docked: CardRecord[]; tops: Map<string, number>; origin: DOMRect } {
686
873
  const skip = (card: CardRecord) => card === dragged?.card || card.key === extra?.key;
687
- const docked = ordered().filter((card) => card.mode === 'side' && !skip(card));
874
+ // K5: a card from another page has no line here; it is held at its edge.
875
+ const docked = ordered().filter((card) => card.mode === 'side' && !skip(card) && !card.away);
688
876
  const left = columnLeft(column, slide, CARD_CONSTANTS.sideGap);
689
877
  const right = left + column.width;
690
878
  // Page coordinates, so the layout does not depend on the scroll position.
@@ -727,10 +915,52 @@ function layoutDocked(column: SideColumn, first: CardRecord | null): void {
727
915
  const maxHeight = sideMaxHeight();
728
916
  for (const card of docking) card.el.style.maxHeight = `${maxHeight}px`;
729
917
  if (first) pointedKey = first.key;
918
+ for (const card of docking) card.el.style.width = card.width ? `${card.width}px` : '';
730
919
  const { left, docked, tops, origin } = columnTops(column, slid, pointedKey);
731
920
  for (const card of docked) {
732
921
  moveTo(card.el, left, (tops.get(card.key) ?? 0) + origin.top);
733
922
  }
923
+ // K5: a card from another page takes the column's edge; its edge of the window holds it.
924
+ for (const card of docking) {
925
+ if (card.away) moveTo(card.el, left, card.el.getBoundingClientRect().top);
926
+ }
927
+ coverRail();
928
+ }
929
+
930
+ /** S11: the width menu, once the reader has resized a card and a wider size fits. */
931
+ function widthMenu(): SideControlsState['width'] {
932
+ if (!sideWidthOffered) return null;
933
+ const widths = sideWidths();
934
+ const offered = offeredWidths(widths, closedRoom(CARD_CONSTANTS.sideGap));
935
+ if (offered.length < 2 && sideWidth === 'default') return null;
936
+ const custom = ordered().some((card) => card.mode === 'side' && card.width !== null);
937
+ return {
938
+ offered: offered.includes(sideWidth) ? offered : [...offered, sideWidth],
939
+ value: custom ? 'custom' : sideWidth,
940
+ characters: new Map(SIDE_WIDTH_OPTIONS.map((option) => [option.id, option.characters])),
941
+ };
942
+ }
943
+
944
+ /** S11: a size from the menu, for every docked card and the next ones. */
945
+ function chooseSideWidth(preset: SideWidthPreset): void {
946
+ sideWidth = preset;
947
+ writeSideWidth(preset);
948
+ for (const card of cards.values()) card.width = null;
949
+ arrange({ from: motionFrom() });
950
+ }
951
+
952
+ /**
953
+ * S11: a docked card wider than the room reaches over the rail (your width,
954
+ * or a size the menu offers only with both panels collapsed). The rail then
955
+ * goes under the card layer, so the card and its edge's handle stay whole;
956
+ * the header still covers both.
957
+ */
958
+ function coverRail(): void {
959
+ const railLeft = railLeftEdge();
960
+ const over = ordered().some(
961
+ (card) => card.mode === 'side' && card.el.getBoundingClientRect().right > railLeft + 0.5,
962
+ );
963
+ document.documentElement.toggleAttribute('data-card-over-rail', over);
734
964
  }
735
965
 
736
966
  interface Motion {
@@ -814,6 +1044,9 @@ function arrange(options: ArrangeOptions = {}): void {
814
1044
  arranging = true;
815
1045
  try {
816
1046
  stopMotion();
1047
+ // K2: every card back with the page while the layout measures it.
1048
+ releaseAll();
1049
+ for (const card of cards.values()) card.headerHeight = 0;
817
1050
  const docked = ordered().filter((card) => card.mode === 'side');
818
1051
  const needed =
819
1052
  docked.length > 0 || keepLeft || ordered().some((card) => card.anchor?.column);
@@ -835,10 +1068,11 @@ function arrange(options: ArrangeOptions = {}): void {
835
1068
  slid = !column ? 0 : keepLeft ? column.maxSlide : docking > 0 ? column.slide : 0;
836
1069
  slideArticle(slid, column?.maxSlide ?? 0);
837
1070
  if (column) {
838
- cardLayer(LAYER_VARIABLES).style.setProperty(
839
- '--explico-card-side-width',
840
- `${column.width}px`,
841
- );
1071
+ // S11: Default keeps S1's width, at most the room; a wider size is the
1072
+ // reader's choice, kept even where it reaches over the rail.
1073
+ const width =
1074
+ sideWidth === 'default' ? column.width : Math.max(column.width, sideOptions().maxWidth);
1075
+ cardLayer(LAYER_VARIABLES).style.setProperty('--explico-card-side-width', `${width}px`);
842
1076
  }
843
1077
  // The card being dragged goes where the pointer takes it, nowhere else.
844
1078
  for (const card of ordered()) {
@@ -848,11 +1082,13 @@ function arrange(options: ArrangeOptions = {}): void {
848
1082
  }
849
1083
  if (column) layoutDocked(column, options.first ?? null);
850
1084
  for (const card of fresh) if (isOpen(card) && card !== dragged?.card) place(card);
851
- keepLeftToggle?.update({
852
- visible: !!column && (keepLeft || docking > 0),
853
- on: keepLeft,
854
- left: column ? columnLeft(column, slid, CARD_CONSTANTS.sideGap) : 0,
855
- });
1085
+ columnInUse =
1086
+ column && (keepLeft || docking > 0)
1087
+ ? { left: columnLeft(column, slid, CARD_CONSTANTS.sideGap), width: widthMenu() }
1088
+ : null;
1089
+ syncControls();
1090
+ if (!column || docking === 0) document.documentElement.removeAttribute('data-card-over-rail');
1091
+ holdNow();
856
1092
  arrow?.redraw();
857
1093
  if (options.from) play(options.from);
858
1094
  } finally {
@@ -864,6 +1100,58 @@ function arrange(options: ArrangeOptions = {}): void {
864
1100
  }
865
1101
  }
866
1102
 
1103
+ /**
1104
+ * S9, S11, K1: the controls row. At the top of the side column while it is
1105
+ * in use, with "Article left", the width menu and "Carry cards"; else
1106
+ * "Carry cards" alone at the article's top right, while a card is kept (D7).
1107
+ */
1108
+ function syncControls(): void {
1109
+ if (!sideControls) return;
1110
+ const anyKept = ordered().some((card) => isKept(card.mode));
1111
+ const article = document
1112
+ .querySelector<HTMLElement>('.main-pane .sl-markdown-content')
1113
+ ?.getBoundingClientRect();
1114
+ const windowRight = document.documentElement.clientWidth;
1115
+ sideControls.update({
1116
+ visible: !!columnInUse || anyKept,
1117
+ at: columnInUse
1118
+ ? { left: columnInUse.left }
1119
+ : {
1120
+ right:
1121
+ article && article.width > 0
1122
+ ? Math.max(0, windowRight - article.right)
1123
+ : CARD_CONSTANTS.edge,
1124
+ },
1125
+ keepLeft: { visible: !!columnInUse, on: keepLeft },
1126
+ carry: { visible: anyKept && !standalone, on: carry },
1127
+ width: columnInUse?.width ?? null,
1128
+ });
1129
+ }
1130
+
1131
+ /** K1: the reader's "Carry cards". */
1132
+ function setCarry(on: boolean): void {
1133
+ carry = on;
1134
+ writeCarry(on);
1135
+ if (on) {
1136
+ // K5: the cards kept here start their journey.
1137
+ saveCarried();
1138
+ } else {
1139
+ // K5: the journeys end; the cards from other pages go with them.
1140
+ awayQueue = [];
1141
+ awayBuilding.clear();
1142
+ awayStranded = [];
1143
+ for (const card of ordered().reverse()) if (isOpen(card) && card.away) close(card);
1144
+ forgetCarried(base);
1145
+ lastStored = null;
1146
+ }
1147
+ for (const card of cards.values()) {
1148
+ showOpenedAt(card);
1149
+ syncMark(card);
1150
+ }
1151
+ syncControls();
1152
+ hold();
1153
+ }
1154
+
867
1155
  /** P1: the layout changed. While cards or the article are moving, wait until they stop. */
868
1156
  function reanchorAll(): void {
869
1157
  if (motion.size > 0) {
@@ -873,6 +1161,202 @@ function reanchorAll(): void {
873
1161
  arrange();
874
1162
  }
875
1163
 
1164
+ // ---------------------------------------------------------------------------
1165
+ // K2–K4: kept cards held at the window's edges while the reader carries them.
1166
+ // ---------------------------------------------------------------------------
1167
+
1168
+ /** K2: a held card back with the page, where the layout last put it. */
1169
+ function release(card: CardRecord): void {
1170
+ const { natural, el } = card;
1171
+ card.held = null;
1172
+ if (!natural) return;
1173
+ card.natural = null;
1174
+ el.style.position = '';
1175
+ el.style.left = natural.left;
1176
+ el.style.top = natural.top;
1177
+ el.style.maxHeight = natural.maxHeight;
1178
+ el.removeAttribute('data-card-held');
1179
+ el.removeAttribute('data-card-collapsed');
1180
+ }
1181
+
1182
+ function releaseAll(): void {
1183
+ for (const card of cards.values()) release(card);
1184
+ }
1185
+
1186
+ /** K3: how tall the card is collapsed to its header row. */
1187
+ function collapsedHeight(card: CardRecord): number {
1188
+ const { el } = card;
1189
+ if (el.hasAttribute('data-card-collapsed')) return el.getBoundingClientRect().height;
1190
+ el.setAttribute('data-card-collapsed', '');
1191
+ const height = el.getBoundingClientRect().height;
1192
+ el.removeAttribute('data-card-collapsed');
1193
+ return height;
1194
+ }
1195
+
1196
+ /**
1197
+ * K2: where a kept card is with the page, in the viewport, at its full
1198
+ * height: where it is, or, while held, the place the layout gave it.
1199
+ */
1200
+ function naturalBox(card: CardRecord, layer: DOMRect): Box {
1201
+ const rect = card.el.getBoundingClientRect();
1202
+ const { natural, held } = card;
1203
+ if (!natural) {
1204
+ card.fullHeight = rect.height;
1205
+ return rect;
1206
+ }
1207
+ if (held && !held.collapsed && held.maxHeight === null) card.fullHeight = rect.height;
1208
+ const left = layer.left + (Number.parseFloat(natural.left) || 0);
1209
+ const top = layer.top + (Number.parseFloat(natural.top) || 0);
1210
+ return { left, top, right: left + rect.width, bottom: top + card.fullHeight };
1211
+ }
1212
+
1213
+ /** K2: the window under the header, and the side controls row a docked card stacks under. */
1214
+ function holdBand(): { top: number; bottom: number; blocks: Box[] } {
1215
+ const row = sideControls?.box() ?? null;
1216
+ return {
1217
+ top: headerBottom() + CARD_CONSTANTS.edge,
1218
+ bottom: document.documentElement.clientHeight - CARD_CONSTANTS.edge,
1219
+ blocks: row ? [row] : [],
1220
+ };
1221
+ }
1222
+
1223
+ /** K2: fix a card at its edge of the window, or give it back to the page. */
1224
+ function applyHeld(card: CardRecord, held: Held | null, layer: DOMRect): void {
1225
+ if (!held) {
1226
+ release(card);
1227
+ return;
1228
+ }
1229
+ const { el } = card;
1230
+ const natural = card.natural ?? {
1231
+ left: el.style.left,
1232
+ top: el.style.top,
1233
+ maxHeight: el.style.maxHeight,
1234
+ };
1235
+ card.natural = natural;
1236
+ card.held = held;
1237
+ el.style.position = 'fixed';
1238
+ el.style.left = `${Math.round(layer.left + (Number.parseFloat(natural.left) || 0))}px`;
1239
+ el.style.top = `${Math.round(held.top)}px`;
1240
+ el.style.maxHeight =
1241
+ held.maxHeight === null ? natural.maxHeight : `${Math.round(held.maxHeight)}px`;
1242
+ el.dataset.cardHeld = held.edge;
1243
+ el.toggleAttribute('data-card-collapsed', held.collapsed);
1244
+ }
1245
+
1246
+ /** K2: the one scroll listener that holds cards, on only while carry is on and a card is kept. */
1247
+ function listenForHold(on: boolean): void {
1248
+ if (on === holding) return;
1249
+ holding = on;
1250
+ if (on) window.addEventListener('scroll', onHoldScroll, { passive: true });
1251
+ else window.removeEventListener('scroll', onHoldScroll);
1252
+ }
1253
+
1254
+ function onHoldScroll(): void {
1255
+ hold();
1256
+ arrow?.redraw();
1257
+ }
1258
+
1259
+ /** K2–K4, after anything that changes the kept cards. A layout pass holds them once it is done. */
1260
+ function hold(): void {
1261
+ if (!arranging) holdNow();
1262
+ }
1263
+
1264
+ /**
1265
+ * K2–K4: with carry on, every kept card whose place has scrolled past an
1266
+ * edge of the window is held at that edge, stacked and collapsed as
1267
+ * `heldAt` says; the others follow the page. A hovered card a kept child
1268
+ * holds open (C9) counts as kept. K4: a card opened from inside a held card
1269
+ * moves with it, and collapses with it. The card being dragged goes where
1270
+ * the pointer takes it.
1271
+ */
1272
+ function holdNow(): void {
1273
+ const kept = carry
1274
+ ? ordered().filter((card) => !isTransient(cards, card.key) && card !== dragged?.card)
1275
+ : [];
1276
+ const keptKeys = new Set(kept.map((card) => card.key));
1277
+ for (const card of cards.values()) if (!keptKeys.has(card.key)) release(card);
1278
+ listenForHold(kept.length > 0);
1279
+ if (kept.length === 0) {
1280
+ makeRoom(0);
1281
+ return;
1282
+ }
1283
+ const layer = cardLayer(LAYER_VARIABLES).getBoundingClientRect();
1284
+ // K4: up to the outermost kept card it was opened inside; a card from
1285
+ // another page (K5) has no trigger inside another and is held on its own.
1286
+ const rootOf = (card: CardRecord): CardRecord => {
1287
+ let root = card;
1288
+ for (
1289
+ let parent = card.away || !card.parentKey ? undefined : cards.get(card.parentKey);
1290
+ parent && keptKeys.has(parent.key);
1291
+ parent = parent.away || !parent.parentKey ? undefined : cards.get(parent.parentKey)
1292
+ ) {
1293
+ root = parent;
1294
+ }
1295
+ return root;
1296
+ };
1297
+ const boxes = new Map(kept.map((card) => [card, naturalBox(card, layer)] as const));
1298
+ const roots = kept.filter((card) => rootOf(card) === card);
1299
+ const input: HoldCard[] = roots.map((card) => {
1300
+ if (!card.headerHeight) card.headerHeight = collapsedHeight(card);
1301
+ return {
1302
+ key: card.key,
1303
+ box: boxes.get(card) ?? card.el.getBoundingClientRect(),
1304
+ headerHeight: card.headerHeight,
1305
+ order: card.order,
1306
+ rank: card.rank,
1307
+ edge: card.away?.edge ?? null,
1308
+ };
1309
+ });
1310
+ const band = holdBand();
1311
+ const spots = heldAt(input, band, CARD_CONSTANTS.gap);
1312
+ // K5: a card from another page held at the bottom edge never leaves it.
1313
+ // The page grows by their stack, so its end scrolls clear of them.
1314
+ let room = 0;
1315
+ for (const card of roots) {
1316
+ const spot = spots.get(card.key);
1317
+ if (card.away && spot?.edge === 'bottom') {
1318
+ room = Math.max(room, band.bottom - spot.top + CARD_CONSTANTS.edge);
1319
+ }
1320
+ }
1321
+ makeRoom(room);
1322
+ for (const card of kept) {
1323
+ const root = rootOf(card);
1324
+ const spot = spots.get(root.key) ?? null;
1325
+ if (root === card || !spot) {
1326
+ applyHeld(card, spot, layer);
1327
+ continue;
1328
+ }
1329
+ const shift = spot.top - (boxes.get(root)?.top ?? 0);
1330
+ applyHeld(
1331
+ card,
1332
+ {
1333
+ edge: spot.edge,
1334
+ top: (boxes.get(card)?.top ?? 0) + shift,
1335
+ collapsed: spot.collapsed,
1336
+ maxHeight: null,
1337
+ },
1338
+ layer,
1339
+ );
1340
+ }
1341
+ }
1342
+
1343
+ /** K5: the room at the end of the page that cards from other pages held at the bottom take. */
1344
+ let carriedRoom = 0;
1345
+ function makeRoom(px: number): void {
1346
+ const room = Math.ceil(px);
1347
+ if (room === carriedRoom) return;
1348
+ carriedRoom = room;
1349
+ const root = document.documentElement;
1350
+ if (room > 0) root.style.setProperty('--explico-carried-room', `${room}px`);
1351
+ else root.style.removeProperty('--explico-carried-room');
1352
+ }
1353
+
1354
+ /** K3: a collapsed card the reader opens counts as the newest from now on. */
1355
+ function expand(card: CardRecord): void {
1356
+ card.rank = ++opened;
1357
+ hold();
1358
+ }
1359
+
876
1360
  /** S6, S7: the Side button — into the side column, or back next to the trigger. */
877
1361
  function toggleSide(card: CardRecord): void {
878
1362
  const from = motionFrom();
@@ -896,17 +1380,69 @@ const awayFromTrigger = (mode: CardMode | undefined): boolean =>
896
1380
  mode === 'side' || mode === 'moved';
897
1381
 
898
1382
  /** S5: the arrow from a kept card to an occurrence of its item. Not while the card is dragged. */
899
- function pointAt(card: CardRecord, target: HTMLElement): void {
1383
+ function pointAt(card: CardRecord, target: HTMLElement, lookalike = false): void {
900
1384
  if (!arrow || !isKept(card.mode) || dragged?.card === card) return;
901
- arrow.show(card.el, target);
1385
+ arrow.show(card.el, target, { lookalike });
1386
+ }
1387
+
1388
+ /**
1389
+ * D4, K6: a card carried in from another page for one of that page's own
1390
+ * symbols, whose glyph is `glyph`: the same letters here mean something
1391
+ * else.
1392
+ */
1393
+ const looksLike = (card: CardRecord, glyph: string): boolean =>
1394
+ !!card.away && card.el.dataset.cardScope === 'local' && card.el.dataset.cardGlyph === glyph;
1395
+
1396
+ /** D4: the reader points at `hit`; the newest card from another page whose symbol looks like it points back, dashed. */
1397
+ function pointLookalike(hit: Hit): void {
1398
+ const glyph = hit.source.glyphOf?.(hit.trigger);
1399
+ const own = cards.get(hit.key);
1400
+ // A kept card of its own already points at it, solid (C8, S5).
1401
+ if (!glyph || (own && isKept(own.mode))) return;
1402
+ const card = ordered()
1403
+ .reverse()
1404
+ .find((candidate) => candidate.key !== hit.key && looksLike(candidate, glyph));
1405
+ if (card) pointAt(card, hit.trigger, true);
1406
+ }
1407
+
1408
+ /**
1409
+ * S5, K5, D4: pointing at a card from another page shows the nearest place
1410
+ * on this page it belongs to: an occurrence of its item (a shared symbol, a
1411
+ * reference to the same float), else, for a page's own symbol, one that
1412
+ * only looks like it, dashed.
1413
+ */
1414
+ function pointFromAway(card: CardRecord): void {
1415
+ const glyph = card.el.dataset.cardScope === 'local' ? card.el.dataset.cardGlyph : undefined;
1416
+ const same = occurrencesOf(card.source, new Set([card.item]), null).get(card.item) ?? [];
1417
+ const alike = same.length === 0 && glyph ? (card.source.lookalikes?.(glyph) ?? []) : [];
1418
+ const box = card.el.getBoundingClientRect();
1419
+ const middle = (box.top + box.bottom) / 2;
1420
+ let nearest: HTMLElement | null = null;
1421
+ let distance = Number.POSITIVE_INFINITY;
1422
+ for (const target of same.length > 0 ? same : alike) {
1423
+ const rect = target.getClientRects()[0];
1424
+ if (!rect) continue;
1425
+ const far = Math.abs((rect.top + rect.bottom) / 2 - middle);
1426
+ if (far < distance) {
1427
+ nearest = target;
1428
+ distance = far;
1429
+ }
1430
+ }
1431
+ if (nearest) pointAt(card, nearest, same.length === 0);
902
1432
  }
903
1433
 
904
1434
  /** C8: move a card to another occurrence of its item. A docked card re-aligns to its line. */
905
1435
  function reattach(card: CardRecord, trigger: HTMLElement): void {
1436
+ if (card.away) {
1437
+ // K5: a card from another page, attached here: this page is its page now.
1438
+ card.away = null;
1439
+ card.el.removeAttribute('data-card-away');
1440
+ }
906
1441
  card.trigger = trigger;
907
1442
  card.parentKey = cardContaining(trigger)?.key ?? null;
908
1443
  if (card.mode !== 'side') {
909
1444
  place(card);
1445
+ hold();
910
1446
  return;
911
1447
  }
912
1448
  const from = motionFrom();
@@ -960,6 +1496,12 @@ const dragHost: DragHost = {
960
1496
  begin(el) {
961
1497
  const card = cardContaining(el);
962
1498
  if (!card || card.el !== el) return null;
1499
+ if (card.held) {
1500
+ // K2: a held card leaves its edge from where it is.
1501
+ const rect = el.getBoundingClientRect();
1502
+ release(card);
1503
+ moveTo(el, rect.left, rect.top);
1504
+ }
963
1505
  const measure = measureSide();
964
1506
  const column = measure ? sideColumn(measure, sideOptions()) : null;
965
1507
  const home = homeSpot(card, measure);
@@ -992,6 +1534,15 @@ const dragHost: DragHost = {
992
1534
  return;
993
1535
  }
994
1536
  const from = motionFrom();
1537
+ if (card.away) {
1538
+ // K5: a card from another page goes to the edge it was dropped nearer.
1539
+ awayAt(
1540
+ card,
1541
+ outcome.kind === 'moved' ? outcome.left : card.el.getBoundingClientRect().left,
1542
+ outcome.top,
1543
+ );
1544
+ scheduleSave();
1545
+ }
995
1546
  if (outcome.kind === 'side') {
996
1547
  // D3: at the drop height; S2 resolves overlaps, the article slides (S3).
997
1548
  setMode(card, 'side');
@@ -1029,10 +1580,12 @@ const dragHost: DragHost = {
1029
1580
  grab,
1030
1581
  );
1031
1582
  const from = motionFrom();
1583
+ if (card.away) awayAt(card, at.x, at.y);
1032
1584
  setMode(card, 'moved');
1033
1585
  card.dropAt = at;
1034
1586
  raise(card);
1035
1587
  arrange({ from });
1588
+ scheduleSave();
1036
1589
  },
1037
1590
  home(el) {
1038
1591
  const card = cardContaining(el);
@@ -1095,6 +1648,8 @@ function close(card: CardRecord): void {
1095
1648
  if (undocking) arrange({ from });
1096
1649
  // C9: the parent was kept open by this child; check it again.
1097
1650
  if (parent && isOpen(parent) && parent.mode === 'hover') graceCheck(parent);
1651
+ if (!arranging) syncControls();
1652
+ hold();
1098
1653
  }
1099
1654
 
1100
1655
  /** C7: close the transient cards outside the chain of `hostKey`. */
@@ -1105,10 +1660,22 @@ function closeOutside(hostKey: string | null): void {
1105
1660
  }
1106
1661
  }
1107
1662
 
1108
- function mount(hit: Hit, el: HTMLElement, mode: CardMode): void {
1663
+ interface MountOptions {
1664
+ /** C10, K3: the order a restored card had. */
1665
+ readonly order?: number;
1666
+ /** K5: a card carried from another page. */
1667
+ readonly away?: Away;
1668
+ /** C9: the card it was opened from, when its trigger is not inside it here (K5). */
1669
+ readonly parentKey?: string | null;
1670
+ /** K7: where a restored card was opened. */
1671
+ readonly openedAt?: OpenedAt | null;
1672
+ }
1673
+
1674
+ function mount(hit: Hit, el: HTMLElement, mode: CardMode, options: MountOptions = {}): void {
1109
1675
  el.dataset.cardKey = hit.key;
1110
1676
  el.hidden = false;
1111
1677
  el.style.visibility = 'hidden';
1678
+ el.toggleAttribute('data-card-away', !!options.away);
1112
1679
  cardLayer(LAYER_VARIABLES).append(el);
1113
1680
  const card: CardRecord = {
1114
1681
  key: hit.key,
@@ -1116,13 +1683,24 @@ function mount(hit: Hit, el: HTMLElement, mode: CardMode): void {
1116
1683
  source: hit.source,
1117
1684
  el,
1118
1685
  trigger: hit.trigger,
1119
- parentKey: cardContaining(hit.trigger)?.key ?? null,
1686
+ parentKey:
1687
+ options.parentKey !== undefined
1688
+ ? options.parentKey
1689
+ : (cardContaining(hit.trigger)?.key ?? null),
1120
1690
  mode,
1121
- order: ++opened,
1691
+ order: options.order ?? ++opened,
1122
1692
  anchor: null,
1123
1693
  moved: null,
1124
1694
  dropAt: null,
1125
1695
  sideDy: -CARD_CONSTANTS.sideLineOffset,
1696
+ width: null,
1697
+ held: null,
1698
+ natural: null,
1699
+ fullHeight: 0,
1700
+ headerHeight: 0,
1701
+ rank: options.order ?? opened,
1702
+ away: options.away ?? null,
1703
+ openedAt: options.openedAt ?? null,
1126
1704
  graceTimer: null,
1127
1705
  flashTimer: null,
1128
1706
  unwatch: noop,
@@ -1144,6 +1722,8 @@ function mount(hit: Hit, el: HTMLElement, mode: CardMode): void {
1144
1722
  if (isOpen(card) && card.mode === 'hover') graceCheck(card);
1145
1723
  });
1146
1724
  notify(card.source);
1725
+ if (isKept(mode) && !arranging) syncControls();
1726
+ hold();
1147
1727
  }
1148
1728
 
1149
1729
  function settle(key: string, token: number, el: HTMLElement | null): void {
@@ -1220,11 +1800,12 @@ function onPointerOver(event: PointerEvent): void {
1220
1800
  const host = cardContaining(event.target);
1221
1801
  if (
1222
1802
  host &&
1223
- awayFromTrigger(host.mode) &&
1803
+ (awayFromTrigger(host.mode) || host.away) &&
1224
1804
  arrow?.card !== host.el &&
1225
1805
  !(event.relatedTarget instanceof Node && host.el.contains(event.relatedTarget))
1226
1806
  ) {
1227
- pointAt(host, host.trigger);
1807
+ if (host.away) pointFromAway(host);
1808
+ else pointAt(host, host.trigger);
1228
1809
  }
1229
1810
  const hit = findTrigger(event.target);
1230
1811
  if (!hit) return;
@@ -1241,6 +1822,7 @@ function onPointerOver(event: PointerEvent): void {
1241
1822
  }
1242
1823
  }
1243
1824
  hoverOpen(hit);
1825
+ pointLookalike(hit);
1244
1826
  }
1245
1827
 
1246
1828
  function onPointerOut(event: PointerEvent): void {
@@ -1272,14 +1854,17 @@ function onFocusIn(event: FocusEvent): void {
1272
1854
  const card = cardContaining(event.target);
1273
1855
  if (card) raise(card);
1274
1856
  const visible = event.target instanceof Element && event.target.matches(':focus-visible');
1275
- if (card && awayFromTrigger(card.mode) && visible && arrow?.card !== card.el) {
1276
- pointAt(card, card.trigger);
1857
+ if (card?.held?.collapsed && visible) expand(card); // K3: and so does the keyboard
1858
+ if (card && (awayFromTrigger(card.mode) || card.away) && visible && arrow?.card !== card.el) {
1859
+ if (card.away) pointFromAway(card);
1860
+ else pointAt(card, card.trigger);
1277
1861
  }
1278
1862
  const hit = findTrigger(event.target);
1279
1863
  if (!hit || hit.trigger === refocusing) return;
1280
1864
  // C1: keyboard focus opens a card; the focus a click leaves behind does not.
1281
1865
  if (!visible) return;
1282
1866
  hoverOpen(hit);
1867
+ pointLookalike(hit);
1283
1868
  }
1284
1869
 
1285
1870
  function onFocusOut(event: FocusEvent): void {
@@ -1290,9 +1875,23 @@ function onFocusOut(event: FocusEvent): void {
1290
1875
  if (card && card.trigger === hit.trigger && card.mode === 'hover') graceCheck(card);
1291
1876
  }
1292
1877
 
1293
- /** C3, C4, C6, C8, S6, D5: clicks on a card's controls and on triggers. */
1878
+ /** C3, C4, C6, C8, S6, D5, K7: clicks on a card's controls and on triggers. */
1294
1879
  function onClick(event: MouseEvent): void {
1295
1880
  const host = cardContaining(event.target);
1881
+ const openedAt =
1882
+ host && event.target instanceof Element
1883
+ ? event.target.closest<HTMLAnchorElement>('a[data-card-opened-at]')
1884
+ : null;
1885
+ if (host && openedAt) {
1886
+ // A modified click keeps its native meaning: the anchor, in a new tab.
1887
+ if (event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) {
1888
+ return;
1889
+ }
1890
+ event.preventDefault();
1891
+ event.stopPropagation();
1892
+ goToOpened(host, openedAt);
1893
+ return;
1894
+ }
1296
1895
  const control =
1297
1896
  host && event.target instanceof Element
1298
1897
  ? event.target.closest<HTMLElement>('[data-card-pin], [data-card-close], [data-card-side]')
@@ -1312,6 +1911,11 @@ function onClick(event: MouseEvent): void {
1312
1911
  }
1313
1912
  return;
1314
1913
  }
1914
+ // K3: a click on a collapsed card opens it.
1915
+ if (host?.held?.collapsed) {
1916
+ expand(host);
1917
+ return;
1918
+ }
1315
1919
 
1316
1920
  const hit = findTrigger(event.target);
1317
1921
  if (!hit) return;
@@ -1396,6 +2000,16 @@ function occurrencesOf(
1396
2000
  if (list) list.push(el);
1397
2001
  else found.set(item, [el]);
1398
2002
  }
2003
+ // An item's own anchor comes after its triggers, so their indices hold.
2004
+ if (!scope && source.ownAnchor) {
2005
+ for (const item of items) {
2006
+ const own = source.ownAnchor(item);
2007
+ if (!own || layer.contains(own)) continue;
2008
+ const list = found.get(item);
2009
+ if (list) list.push(own);
2010
+ else found.set(item, [own]);
2011
+ }
2012
+ }
1399
2013
  return found;
1400
2014
  }
1401
2015
 
@@ -1434,6 +2048,7 @@ function rememberCards(): void {
1434
2048
  anchor: card.anchor,
1435
2049
  moved: card.mode === 'moved' ? card.moved : null,
1436
2050
  sideDy: card.mode === 'side' ? card.sideDy : null,
2051
+ width: card.width,
1437
2052
  });
1438
2053
  }
1439
2054
  writeRememberedCards({
@@ -1444,10 +2059,14 @@ function rememberCards(): void {
1444
2059
 
1445
2060
  /** Open a remembered card as it was: in its mode, at its place. */
1446
2061
  function revive(hit: Hit, el: HTMLElement, saved: RememberedCard): void {
1447
- mount(hit, el, saved.mode === 'hover' ? 'hover' : 'pinned');
2062
+ mount(hit, el, saved.mode === 'hover' ? 'hover' : 'pinned', {
2063
+ order: restoreOrder.get(saved),
2064
+ openedAt: 'openedAt' in saved ? (saved as CarriedCard).openedAt : null,
2065
+ });
1448
2066
  const card = cards.get(hit.key);
1449
2067
  if (!card || card.el !== el) return;
1450
2068
  if (saved.anchor) card.anchor = saved.anchor;
2069
+ card.width = saved.width ?? null;
1451
2070
  if (saved.mode === 'side') {
1452
2071
  setMode(card, 'side');
1453
2072
  if (saved.sideDy !== null) card.sideDy = saved.sideDy;
@@ -1511,6 +2130,429 @@ function restoreRemembered(): void {
1511
2130
  }
1512
2131
  }
1513
2132
 
2133
+ // ---------------------------------------------------------------------------
2134
+ // K7: "Opened at", back to where a kept card was opened (`./card-carry.ts`).
2135
+ // ---------------------------------------------------------------------------
2136
+
2137
+ /**
2138
+ * K8: while carrying is on, a kept card says whether its meaning holds on
2139
+ * every page — a globe: shared notation, citations, abbreviations, numbered
2140
+ * references — or only on its own page: a page and "this page", for one of
2141
+ * that page's own symbols (`data-card-scope="local"`). The page is the one
2142
+ * the card was read off (`data-card-defined-in`), which need not be where it
2143
+ * was opened: a symbol in an equation from another lesson is that lesson's.
2144
+ * Never the pin's icon.
2145
+ */
2146
+ function syncMark(card: CardRecord): void {
2147
+ const mark = card.el.querySelector<HTMLElement>('[data-card-mark]');
2148
+ if (!mark) return;
2149
+ mark.hidden = !(carry && isKept(card.mode));
2150
+ if (mark.hidden) return;
2151
+ const own = card.el.dataset.cardScope === 'local';
2152
+ mark.dataset.cardMark = own ? 'page' : 'global';
2153
+ const where = card.el.dataset.cardDefinedIn;
2154
+ const label = !own
2155
+ ? 'Means the same on every page of the course'
2156
+ : where
2157
+ ? `Defined on ${where}: it means this only there`
2158
+ : 'Defined on this page: it means this only here';
2159
+ mark.title = label;
2160
+ mark.setAttribute('aria-label', label);
2161
+ }
2162
+
2163
+ /** K7: the place on its page a trigger sits in: a numbered equation, else its section. */
2164
+ function placeOf(trigger: HTMLElement): Pick<OpenedAt, 'anchor' | 'label' | 'name'> {
2165
+ const equation = trigger.closest<HTMLElement>('.keyed-equation[id]');
2166
+ const number = equation?.dataset.eqNumber;
2167
+ if (equation && number) {
2168
+ return { anchor: equation.id, label: `(${number})`, name: equation.dataset.eqShort ?? '' };
2169
+ }
2170
+ const list = trigger.closest<HTMLElement>('.notation-list[id]');
2171
+ if (list) return { anchor: list.id, label: 'the notation list', name: '' };
2172
+ // The last section heading before it in the article: a numbered section,
2173
+ // worked example or knowledge check, or, where the course shows no
2174
+ // numbers, an outline heading. A fold's bar and a memo are no sections.
2175
+ const article = document.querySelector<HTMLElement>('.main-pane .sl-markdown-content');
2176
+ const isSection = (candidate: HTMLElement) =>
2177
+ !!(candidate.dataset.secNumber || candidate.dataset.checkNumber || candidate.dataset.exNumber) ||
2178
+ (/^H[234]$/.test(candidate.tagName) &&
2179
+ !candidate.id.startsWith('fold-') &&
2180
+ !candidate.dataset.memoNumber &&
2181
+ !candidate.closest('figure, aside'));
2182
+ let heading: HTMLElement | null = null;
2183
+ for (const candidate of article?.querySelectorAll<HTMLElement>(
2184
+ 'h2[id], h3[id], h4[id], h5[id], h6[id]',
2185
+ ) ?? []) {
2186
+ if (!(candidate.compareDocumentPosition(trigger) & Node.DOCUMENT_POSITION_FOLLOWING)) break;
2187
+ if (isSection(candidate)) heading = candidate;
2188
+ }
2189
+ // Before the first section: the page's own heading.
2190
+ if (!heading) return { anchor: '_top', label: 'the start', name: '' };
2191
+ const title = readableText(heading.querySelector('.block-heading__title') ?? heading);
2192
+ return { anchor: heading.id, ...sectionPlace(heading.dataset, title) };
2193
+ }
2194
+
2195
+ /**
2196
+ * K7: where `card` was opened: its trigger's place on this page, or, for a
2197
+ * card opened from inside another, where the outermost one was opened.
2198
+ */
2199
+ function ensureOpenedAt(card: CardRecord): OpenedAt | null {
2200
+ if (card.openedAt) return card.openedAt;
2201
+ const parent = card.parentKey ? cards.get(card.parentKey) : undefined;
2202
+ if (parent) {
2203
+ card.openedAt = ensureOpenedAt(parent);
2204
+ return card.openedAt;
2205
+ }
2206
+ const { trigger } = card;
2207
+ if (card.away || !trigger.isConnected) return null;
2208
+ const occurrence =
2209
+ occurrencesOf(card.source, new Set([card.item]), null).get(card.item)?.indexOf(trigger) ?? -1;
2210
+ if (occurrence < 0) return null;
2211
+ card.openedAt = {
2212
+ page: window.location.pathname,
2213
+ title: pageTitleOf(document) ?? '',
2214
+ ...placeOf(trigger),
2215
+ layer: card.source.layer,
2216
+ item: card.item,
2217
+ occurrence,
2218
+ };
2219
+ return card.openedAt;
2220
+ }
2221
+
2222
+ /**
2223
+ * K7: a kept card's "Opened at" link, beside its "Go to …", while carrying
2224
+ * is on; none on a hovered card. Where it was opened is recorded on keeping
2225
+ * either way, for a journey started later.
2226
+ */
2227
+ function showOpenedAt(card: CardRecord): void {
2228
+ let link = card.el.querySelector<HTMLAnchorElement>('a[data-card-opened-at]');
2229
+ const at = isKept(card.mode) ? ensureOpenedAt(card) : null;
2230
+ if (!at || !carry) {
2231
+ if (link) link.hidden = true;
2232
+ return;
2233
+ }
2234
+ if (!link) {
2235
+ link = document.createElement('a');
2236
+ link.className = 'hover-panel-action';
2237
+ link.dataset.cardOpenedAt = '';
2238
+ const label = document.createElement('span');
2239
+ label.dataset.openedAtLabel = '';
2240
+ const jump = document.createElement('span');
2241
+ jump.setAttribute('aria-hidden', 'true');
2242
+ jump.dataset.jumpArrow = '';
2243
+ link.append(label, jump);
2244
+ const actions = [...card.el.querySelectorAll<HTMLElement>('.hover-panel-action')];
2245
+ const last = actions[actions.length - 1];
2246
+ if (last) {
2247
+ last.after(link);
2248
+ } else {
2249
+ const row = document.createElement('p');
2250
+ row.append(link);
2251
+ card.el.querySelector('.hover-panel-scroll')?.append(row);
2252
+ }
2253
+ }
2254
+ const text = openedAtText(at);
2255
+ const label = link.querySelector<HTMLElement>('[data-opened-at-label]');
2256
+ if (label) label.textContent = text.label;
2257
+ link.title = text.title;
2258
+ link.href = isThisPage(at.page) ? `#${at.anchor}` : `${at.page}#${at.anchor}`;
2259
+ link.hidden = false;
2260
+ wireJumpArrow(link);
2261
+ }
2262
+
2263
+ /** K7: the trigger a card was opened at, on this page. */
2264
+ function triggerAt(at: OpenedAt): HTMLElement | null {
2265
+ const source = sources.find((candidate) => candidate.layer === at.layer);
2266
+ if (!source) return null;
2267
+ return occurrencesOf(source, new Set([at.item]), null).get(at.item)?.[at.occurrence] ?? null;
2268
+ }
2269
+
2270
+ /** K7: light a trigger up a moment, where an "Opened at" link brought the reader. */
2271
+ function flashOrigin(trigger: HTMLElement): void {
2272
+ trigger.classList.remove('is-card-origin');
2273
+ void trigger.offsetWidth; // restart the animation
2274
+ trigger.classList.add('is-card-origin');
2275
+ setTimeout(() => trigger.classList.remove('is-card-origin'), CARD_CONSTANTS.originFlashMs);
2276
+ }
2277
+
2278
+ /** K7: to the trigger itself, its section's anchor in the address bar. */
2279
+ function scrollToOrigin(trigger: HTMLElement, anchor: string): void {
2280
+ animatedScrollIntoView(trigger, { block: 'center' }, () => {
2281
+ if (window.location.hash !== `#${anchor}`) history.pushState(null, '', `#${anchor}`);
2282
+ flashOrigin(trigger);
2283
+ });
2284
+ }
2285
+
2286
+ /** K7: follow a card's "Opened at" link: here, to the trigger; elsewhere, there, then to it. */
2287
+ function goToOpened(card: CardRecord, link: HTMLAnchorElement): void {
2288
+ const at = card.openedAt;
2289
+ if (!at) return;
2290
+ if (!isThisPage(at.page)) {
2291
+ stashOpenedAt(at);
2292
+ window.location.assign(link.href);
2293
+ return;
2294
+ }
2295
+ const trigger = triggerAt(at);
2296
+ if (trigger && trigger.getClientRects().length > 0) {
2297
+ scrollToOrigin(trigger, at.anchor);
2298
+ } else {
2299
+ // Folded away: its section's anchor, as without JavaScript.
2300
+ window.location.hash = at.anchor;
2301
+ }
2302
+ }
2303
+
2304
+ /** K7: the trigger an "Opened at" link on another page brought the reader here for. */
2305
+ let arriving: OpenedAt | null = null;
2306
+
2307
+ /** K7: once its layer is here, scroll to it. */
2308
+ function arrive(): void {
2309
+ const at = arriving;
2310
+ if (!at || !sources.some((source) => source.layer === at.layer)) return;
2311
+ arriving = null;
2312
+ requestAnimationFrame(() => {
2313
+ const trigger = triggerAt(at);
2314
+ if (trigger && trigger.getClientRects().length > 0) scrollToOrigin(trigger, at.anchor);
2315
+ });
2316
+ }
2317
+
2318
+ // ---------------------------------------------------------------------------
2319
+ // K5: carrying the kept cards to every page of the course (`./card-carry.ts`).
2320
+ // ---------------------------------------------------------------------------
2321
+
2322
+ /** K5: the article column's left edge, against which a card's place across the window travels. */
2323
+ const articleLeftEdge = (): number => articleColumn(document.body)?.left ?? 0;
2324
+
2325
+ /**
2326
+ * K5: a card from another page, across the window where it was against the
2327
+ * article, at its edge of the window; `holdNow` keeps it there.
2328
+ */
2329
+ function placeAway(card: CardRecord): void {
2330
+ const { away } = card;
2331
+ if (!away) return;
2332
+ const between = sidebarsBetween(CARD_CONSTANTS.edge);
2333
+ const { width, height } = card.el.getBoundingClientRect();
2334
+ const x = keepBetween(articleLeftEdge() + away.dx, width, between);
2335
+ const band = holdBand();
2336
+ moveTo(card.el, x, away.edge === 'top' ? band.top : band.bottom - height);
2337
+ }
2338
+
2339
+ /** K5: a card from another page, put at (`left`, `top`): the edge it is nearer, its place across. */
2340
+ function awayAt(card: CardRecord, left: number, top: number): void {
2341
+ const { away } = card;
2342
+ if (!away) return;
2343
+ const { height } = card.el.getBoundingClientRect();
2344
+ away.edge = top + height / 2 < document.documentElement.clientHeight / 2 ? 'top' : 'bottom';
2345
+ away.dx = Math.round(left - articleLeftEdge());
2346
+ }
2347
+
2348
+ const awayItemOf = (layer: string, saved: CarriedCard): string =>
2349
+ sources.find((source) => source.layer === layer)?.awayItem?.(saved.item, saved.page) ??
2350
+ saved.item;
2351
+
2352
+ /** K5: open a card rebuilt from its page, as it was there: in its mode, at its edge. */
2353
+ function mountAway(source: CardSource, saved: CarriedCard, el: HTMLElement): void {
2354
+ const item = awayItemOf(source.layer, saved);
2355
+ const key = `${source.layer}:${item}`;
2356
+ if (!carry || cards.has(key)) return;
2357
+ if (saved.picture) restorePicture(el, saved.picture);
2358
+ const parent = saved.parentKey
2359
+ ? awayEntries.find((entry) => entry.page === saved.page && keyOf(entry) === saved.parentKey)
2360
+ : undefined;
2361
+ mount(
2362
+ // Its trigger is on its own page: this one stands in for it, never connected.
2363
+ { source, trigger: document.createElement('span'), item, key },
2364
+ el,
2365
+ saved.mode === 'hover' ? 'hover' : 'pinned',
2366
+ {
2367
+ order: restoreOrder.get(saved),
2368
+ away: { saved, edge: saved.edge, dx: saved.dx },
2369
+ openedAt: saved.openedAt ?? null,
2370
+ parentKey: parent ? `${parent.layer}:${awayItemOf(parent.layer, parent)}` : null,
2371
+ },
2372
+ );
2373
+ const card = cards.get(key);
2374
+ if (!card || card.el !== el) return;
2375
+ card.width = saved.width ?? null;
2376
+ if (saved.mode === 'side') setMode(card, 'side');
2377
+ else if (saved.mode === 'moved') setMode(card, 'moved');
2378
+ }
2379
+
2380
+ /** K5: carried cards whose page could not be fetched; carried on, never retried on this page. */
2381
+ let awayStranded: CarriedCard[] = [];
2382
+
2383
+ /** K5: rebuild every waiting card from another page whose layer has registered. */
2384
+ function restoreAway(): void {
2385
+ for (const saved of [...awayQueue]) {
2386
+ const source = sources.find((candidate) => candidate.layer === saved.layer);
2387
+ if (!source) continue; // its layer has not registered yet
2388
+ awayQueue = awayQueue.filter((other) => other !== saved);
2389
+ if (!source.build) continue; // the layer's cards do not travel
2390
+ awayBuilding.add(saved);
2391
+ const done = (el: HTMLElement | null) => {
2392
+ awayBuilding.delete(saved);
2393
+ // Its page no longer has it: the journey ends.
2394
+ if (!el) return;
2395
+ mountAway(source, saved, el);
2396
+ arrange();
2397
+ };
2398
+ let built: HTMLElement | null | Promise<HTMLElement | null>;
2399
+ try {
2400
+ built = source.build(saved.item, saved.page);
2401
+ } catch {
2402
+ built = null;
2403
+ }
2404
+ if (built instanceof Promise) {
2405
+ built.then(done, () => {
2406
+ awayBuilding.delete(saved);
2407
+ awayStranded.push(saved);
2408
+ });
2409
+ } else {
2410
+ done(built);
2411
+ }
2412
+ }
2413
+ }
2414
+
2415
+ /** K5: the cards this page carries on, in their order, with the ones still on their way. */
2416
+ function carriedSet(): CarriedSet {
2417
+ const here = window.location.pathname;
2418
+ const article = articleLeftEdge();
2419
+ const entries: { readonly order: number; readonly card: CarriedCard }[] = [];
2420
+ // Each card saved so far, by its key here: its key and page in the set.
2421
+ const stored = new Map<string, { readonly key: string; readonly page: string }>();
2422
+ const lookups = new Map<string, Map<string, HTMLElement[]>>();
2423
+ const kept = ordered().filter((card) => !isTransient(cards, card.key));
2424
+ for (const card of kept) {
2425
+ const parent = card.parentKey ? stored.get(card.parentKey) : undefined;
2426
+ // A card opened from inside another travels only with it.
2427
+ if (card.parentKey !== null && !parent) continue;
2428
+ const original = card.away?.saved;
2429
+ let page: string;
2430
+ let item: string;
2431
+ let occurrence: number;
2432
+ if (original) {
2433
+ ({ page, item, occurrence } = original);
2434
+ } else {
2435
+ if (!card.trigger.isConnected) continue;
2436
+ const scope = card.parentKey ? (cards.get(card.parentKey)?.el ?? null) : null;
2437
+ const lookup = `${card.source.layer}|${card.parentKey ?? ''}`;
2438
+ let triggers = lookups.get(lookup);
2439
+ if (!triggers) {
2440
+ const items = new Set(
2441
+ kept
2442
+ .filter(
2443
+ (other) =>
2444
+ other.source === card.source && other.parentKey === card.parentKey && !other.away,
2445
+ )
2446
+ .map((other) => other.item),
2447
+ );
2448
+ triggers = occurrencesOf(card.source, items, scope);
2449
+ lookups.set(lookup, triggers);
2450
+ }
2451
+ occurrence = triggers.get(card.item)?.indexOf(card.trigger) ?? -1;
2452
+ if (occurrence < 0) continue;
2453
+ // A card opened inside another counts its occurrence there, on that card's page.
2454
+ page = parent?.page ?? here;
2455
+ item = card.item;
2456
+ }
2457
+ const rect = card.el.getBoundingClientRect();
2458
+ const dx = card.away?.dx ?? Math.round(rect.left - article);
2459
+ const edge: HoldEdge =
2460
+ card.away?.edge ??
2461
+ card.held?.edge ??
2462
+ (rect.top + rect.height / 2 < document.documentElement.clientHeight / 2 ? 'top' : 'bottom');
2463
+ const picture = capturePicture(card.el) ?? original?.picture;
2464
+ const entry: CarriedCard = {
2465
+ layer: card.source.layer,
2466
+ item,
2467
+ occurrence,
2468
+ parentKey: parent?.key ?? null,
2469
+ mode: card.mode,
2470
+ anchor: original ? original.anchor : card.anchor,
2471
+ moved:
2472
+ card.mode === 'moved'
2473
+ ? ((original ? original.moved : card.moved) ?? { x: dx, y: 0 })
2474
+ : null,
2475
+ sideDy: card.mode === 'side' ? (original ? original.sideDy : card.sideDy) : null,
2476
+ width: card.width,
2477
+ page,
2478
+ edge,
2479
+ dx,
2480
+ ...(picture ? { picture } : {}),
2481
+ ...(card.openedAt ? { openedAt: card.openedAt } : {}),
2482
+ };
2483
+ stored.set(card.key, { key: keyOf(entry), page });
2484
+ entries.push({ order: card.order, card: entry });
2485
+ }
2486
+ // Cards still on their way here travel on as they came.
2487
+ const saved = new Set(entries.map((entry) => carriedKeyOf(entry.card)));
2488
+ for (const pending of [...awayQueue, ...awayBuilding, ...awayStranded]) {
2489
+ if (saved.has(carriedKeyOf(pending))) continue;
2490
+ entries.push({ order: restoreOrder.get(pending) ?? Number.MAX_SAFE_INTEGER, card: pending });
2491
+ }
2492
+ entries.sort((a, b) => a.order - b.order);
2493
+ return {
2494
+ cards: entries.map((entry) => entry.card),
2495
+ pointed: pointedKey ? (stored.get(pointedKey)?.key ?? null) : null,
2496
+ };
2497
+ }
2498
+
2499
+ /** K5: store the carried set now, when it changed: this tab's, and the course's last. */
2500
+ function saveCarried(): void {
2501
+ if (saveTimer !== null) clearTimeout(saveTimer);
2502
+ saveTimer = null;
2503
+ if (!carry || quiet) return;
2504
+ // As it would come back: a reload that changed nothing writes nothing.
2505
+ const fresh = carriedSet();
2506
+ const set = decodeCarried(encodeCarried(fresh)) ?? fresh;
2507
+ if (encodeCarried(set) === lastStored) return;
2508
+ lastStored = writeCarried(base, set);
2509
+ }
2510
+
2511
+ /** K5: after any change to the kept cards, store the set once the change is done. */
2512
+ function scheduleSave(): void {
2513
+ if (!carry || quiet || saveTimer !== null) return;
2514
+ saveTimer = setTimeout(saveCarried, 0);
2515
+ }
2516
+
2517
+ /** K5: put back a stored set: its cards from this page at their triggers, the others from theirs. */
2518
+ function restoreCarriedSet(set: CarriedSet): void {
2519
+ restoreOrder.clear();
2520
+ for (const [index, card] of set.cards.entries()) restoreOrder.set(card, opened + index + 1);
2521
+ opened += set.cards.length;
2522
+ remembered = set.cards.filter((card) => isThisPage(card.page));
2523
+ awayQueue = set.cards.filter((card) => !isThisPage(card.page));
2524
+ awayEntries = [...awayQueue];
2525
+ awayStranded = [];
2526
+ rememberedPointed = set.pointed;
2527
+ lastStored = encodeCarried(set);
2528
+ }
2529
+
2530
+ /**
2531
+ * K5: a page the browser brings back as it was left (Back, Forward) has the
2532
+ * cards it had then; when another page changed the set since, it takes the
2533
+ * set as it is now.
2534
+ */
2535
+ function resyncCarried(): void {
2536
+ if (!carry) return;
2537
+ const set = readCarried(base);
2538
+ if (encodeCarried(set) === lastStored) return;
2539
+ quiet = true;
2540
+ try {
2541
+ for (const card of ordered().reverse()) if (isOpen(card)) close(card);
2542
+ } finally {
2543
+ quiet = false;
2544
+ }
2545
+ restoreCarriedSet(set);
2546
+ restoreRemembered();
2547
+ restoreAway();
2548
+ }
2549
+
2550
+ /** As the page is left: the carried set, or, with carrying off, this page's card memory. */
2551
+ function leavePage(): void {
2552
+ if (carry) saveCarried();
2553
+ else rememberCards();
2554
+ }
2555
+
1514
2556
  function install(): void {
1515
2557
  if (layout) return;
1516
2558
  layout = watchCardLayout(reanchorAll);
@@ -1529,22 +2571,48 @@ function install(): void {
1529
2571
  stepLarge: CARD_CONSTANTS.keyboardStepLarge,
1530
2572
  layer,
1531
2573
  });
1532
- keepLeftToggle = createKeepLeftToggle(layer, (on) => {
1533
- keepLeft = on;
1534
- writeKeepLeft(on);
1535
- arrange({ from: motionFrom() });
2574
+ sideControls = createSideControls(layer, {
2575
+ onKeepLeft: (on) => {
2576
+ keepLeft = on;
2577
+ writeKeepLeft(on);
2578
+ arrange({ from: motionFrom() });
2579
+ },
2580
+ onCarry: setCarry,
2581
+ onWidth: chooseSideWidth,
1536
2582
  });
2583
+ sideWidth = readSideWidth();
2584
+ sideWidthOffered = readSideWidthOffered();
2585
+ document.addEventListener('pointerdown', onResizeDown, true);
2586
+ document.addEventListener('keydown', onResizeKey);
2587
+ document.addEventListener('dblclick', onResizeReset);
2588
+ // K1: off unless the reader or the course turned it on; K5: never in a
2589
+ // standalone export, which has no other pages.
2590
+ const config = readCardConfig();
2591
+ base = config.base;
2592
+ standalone = document.documentElement.hasAttribute('data-explico-standalone');
2593
+ carry = !standalone && readCarry(config.carry);
1537
2594
  // S9: a reader who keeps the article left finds it there on every page.
1538
2595
  keepLeft = readKeepLeft();
1539
2596
  if (keepLeft) arrange();
1540
- // The cards the reader left this page with, restored as each layer registers.
1541
- const page = readRememberedCards();
1542
- remembered = [...page.cards];
1543
- rememberedPointed = page.pointed;
1544
- window.addEventListener('pagehide', rememberCards);
2597
+ // The cards the reader carries (K5), or, with carrying off, the ones this
2598
+ // page had when the reader last left it; restored as each layer registers.
2599
+ if (carry) {
2600
+ restoreCarriedSet(readCarried(base));
2601
+ } else {
2602
+ const page = readRememberedCards();
2603
+ remembered = [...page.cards];
2604
+ rememberedPointed = page.pointed;
2605
+ }
2606
+ window.addEventListener('pagehide', leavePage);
1545
2607
  document.addEventListener('visibilitychange', () => {
1546
- if (document.visibilityState === 'hidden') rememberCards();
2608
+ if (document.visibilityState === 'hidden') leavePage();
1547
2609
  });
2610
+ window.addEventListener('pageshow', (event) => {
2611
+ if (event.persisted) resyncCarried();
2612
+ });
2613
+ // K7: an "Opened at" link from another page brought the reader here.
2614
+ const jump = takeOpenedAt();
2615
+ if (jump && isThisPage(jump.page)) arriving = jump;
1548
2616
  document.addEventListener('pointerover', onPointerOver);
1549
2617
  document.addEventListener('pointerout', onPointerOut);
1550
2618
  document.addEventListener('pointermove', onPointerMove, { passive: true });
@@ -1558,6 +2626,94 @@ function install(): void {
1558
2626
  window.addEventListener('resize', interruptTravel, { passive: true });
1559
2627
  }
1560
2628
 
2629
+ // ---------------------------------------------------------------------------
2630
+ // S11: a docked card's right edge widens it.
2631
+ // ---------------------------------------------------------------------------
2632
+
2633
+ /** The docked card a resize handle belongs to. */
2634
+ function resizeTarget(node: EventTarget | null): { card: CardRecord; handle: HTMLElement } | null {
2635
+ const handle = node instanceof Element ? node.closest<HTMLElement>('[data-card-resize]') : null;
2636
+ const card = handle ? cardContaining(handle) : null;
2637
+ return handle && card && card.mode === 'side' ? { card, handle } : null;
2638
+ }
2639
+
2640
+ /** The narrowest a docked card gets, and the widest: to the window's edge, past the rail (S11). */
2641
+ function resizeBounds(card: CardRecord): { min: number; max: number } {
2642
+ const left = card.el.getBoundingClientRect().left;
2643
+ return {
2644
+ min: remToPx(CARD_CONSTANTS.sideWidthMin),
2645
+ max: Math.max(0, document.documentElement.clientWidth - CARD_CONSTANTS.edge - left),
2646
+ };
2647
+ }
2648
+
2649
+ function setCardWidth(card: CardRecord, width: number | null): void {
2650
+ card.width = width === null ? null : Math.round(width);
2651
+ card.el.style.width = card.width === null ? '' : `${card.width}px`;
2652
+ coverRail();
2653
+ }
2654
+
2655
+ /** Resizing one card offers the width menu from then on. */
2656
+ function offerWidthMenu(): void {
2657
+ if (sideWidthOffered) return;
2658
+ sideWidthOffered = true;
2659
+ writeSideWidthOffered();
2660
+ }
2661
+
2662
+ function onResizeDown(event: PointerEvent): void {
2663
+ const target = resizeTarget(event.target);
2664
+ if (!target || event.button !== 0) return;
2665
+ event.preventDefault();
2666
+ event.stopPropagation();
2667
+ const { card, handle } = target;
2668
+ const startX = event.clientX;
2669
+ const startWidth = card.el.getBoundingClientRect().width;
2670
+ const { min, max } = resizeBounds(card);
2671
+ const root = document.documentElement;
2672
+ root.setAttribute('data-card-resizing', '');
2673
+ handle.setPointerCapture(event.pointerId);
2674
+ const move = (moveEvent: PointerEvent) => {
2675
+ setCardWidth(card, Math.min(max, Math.max(min, startWidth + moveEvent.clientX - startX)));
2676
+ };
2677
+ const end = () => {
2678
+ handle.removeEventListener('pointermove', move);
2679
+ root.removeAttribute('data-card-resizing');
2680
+ offerWidthMenu();
2681
+ arrange();
2682
+ };
2683
+ handle.addEventListener('pointermove', move);
2684
+ handle.addEventListener('pointerup', end, { once: true });
2685
+ handle.addEventListener('pointercancel', end, { once: true });
2686
+ }
2687
+
2688
+ function onResizeKey(event: KeyboardEvent): void {
2689
+ const target = resizeTarget(event.target);
2690
+ if (!target) return;
2691
+ const { card } = target;
2692
+ if (event.key === 'Home') {
2693
+ event.preventDefault();
2694
+ setCardWidth(card, null);
2695
+ arrange();
2696
+ return;
2697
+ }
2698
+ const step = event.shiftKey ? CARD_CONSTANTS.keyboardStepLarge : CARD_CONSTANTS.keyboardStep;
2699
+ const delta = event.key === 'ArrowRight' ? step : event.key === 'ArrowLeft' ? -step : 0;
2700
+ if (delta === 0) return;
2701
+ event.preventDefault();
2702
+ const { min, max } = resizeBounds(card);
2703
+ const now = card.el.getBoundingClientRect().width;
2704
+ setCardWidth(card, Math.min(max, Math.max(min, now + delta)));
2705
+ offerWidthMenu();
2706
+ arrange();
2707
+ }
2708
+
2709
+ /** A double click on the edge gives the card back the column's width. */
2710
+ function onResizeReset(event: MouseEvent): void {
2711
+ const target = resizeTarget(event.target);
2712
+ if (!target) return;
2713
+ setCardWidth(target.card, null);
2714
+ arrange();
2715
+ }
2716
+
1561
2717
  /**
1562
2718
  * Hand a layer's triggers to the manager. A source registered again under
1563
2719
  * the same `layer` replaces the old one and closes its cards.
@@ -1573,6 +2729,34 @@ export function registerCardSource(source: CardSource): void {
1573
2729
  sources.splice(at, 1, source);
1574
2730
  }
1575
2731
  restoreRemembered();
2732
+ restoreAway();
2733
+ arrive();
2734
+ }
2735
+
2736
+ /**
2737
+ * Open `layer`'s card for `anchor` already kept, as a click on a trigger
2738
+ * pins one: `'pinned'` next to `anchor`, or `'side'` beside the article (S7:
2739
+ * a note says so when there is no room). `anchor` is a trigger or the item's
2740
+ * own anchor (`CardSource.ownAnchor`), a float's number for its toolbar. A
2741
+ * card already open for the item moves to `anchor` and takes the mode.
2742
+ */
2743
+ export function keepCardFor(layer: string, anchor: HTMLElement, mode: 'pinned' | 'side'): void {
2744
+ const source = sources.find((candidate) => candidate.layer === layer);
2745
+ const item = source?.itemFor(anchor) ?? null;
2746
+ if (!source || item === null) return;
2747
+ const key = `${layer}:${item}`;
2748
+ let card = cards.get(key);
2749
+ if (card) {
2750
+ if (card.trigger !== anchor) reattach(card, anchor);
2751
+ if (card.mode === 'hover' || card.mode === 'moved') setMode(card, 'pinned');
2752
+ closeOutside(card.key);
2753
+ } else {
2754
+ request({ source, trigger: anchor, item, key }, 'pinned');
2755
+ card = cards.get(key);
2756
+ }
2757
+ if (!card) return;
2758
+ if ((mode === 'side') !== (card.mode === 'side')) toggleSide(card);
2759
+ raise(card);
1576
2760
  }
1577
2761
 
1578
2762
  /**