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
@@ -0,0 +1,1589 @@
1
+ /**
2
+ * One interaction model for every explanation card (rules C1–C15 of
3
+ * `docs/plans/hover-card-consistency.md`): notation symbols and terms,
4
+ * citations, abbreviations, and references to equations, tables, figures,
5
+ * diagrams and sections.
6
+ *
7
+ * Each layer registers a `CardSource` — how to find its triggers, how to
8
+ * name the item a trigger points at, how to build a card — and this module
9
+ * runs the rest once, for all of them:
10
+ *
11
+ * - one card per key (`notation:motion.velocity`, `citation:3`), several at
12
+ * once in every layer;
13
+ * - hover or keyboard focus opens a card; a click on any trigger toggles its
14
+ * pin and never navigates; a click inside a card or elsewhere never closes
15
+ * a pinned one;
16
+ * - the travel zone (`./card-travel.ts`) keeps a hovered card reachable;
17
+ * - opening a card closes the hovered cards outside its hover chain, and a
18
+ * card opened from inside another is its child, across layers;
19
+ * - Escape closes one card per press, newest first;
20
+ * - placement (`./card-placement.ts`) puts every card against every other;
21
+ * - a kept card can move to the side column, level with its trigger's line,
22
+ * and the article slides left to make room for it (`./card-side.ts`);
23
+ * - an arrow runs from a kept card to the occurrence of its item the reader
24
+ * points at (`./card-arrow.ts`);
25
+ * - a card's grip drags it anywhere between the sidebars, or into the side
26
+ * column, and its arrow keys move it (`./card-drag.ts`);
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`).
29
+ *
30
+ * The registry's rules are pure functions over plain `CardState` records
31
+ * (`escapeTarget`, `chainOf`, `closedByOpening`, …) so `tests/unit/` can check
32
+ * them in Node. Nothing here touches the DOM until a source registers.
33
+ */
34
+ import { createArrowOverlay, type ArrowOverlay } from './card-arrow';
35
+ import {
36
+ dragBounds,
37
+ installCardDrag,
38
+ keepBetween,
39
+ movedAt,
40
+ movedSpot,
41
+ nudgeSpot,
42
+ sidebarsBetween,
43
+ type CardDrag,
44
+ type DragHost,
45
+ type MovedSpot,
46
+ } from './card-drag';
47
+ import {
48
+ keyOf,
49
+ readRememberedCards,
50
+ restorable,
51
+ writeRememberedCards,
52
+ type RememberedCard,
53
+ } from './card-memory';
54
+ import {
55
+ anchoredSpot,
56
+ articleColumn,
57
+ cardLayer,
58
+ moveTo,
59
+ placeElement,
60
+ reanchorElement,
61
+ scrollContainersOf,
62
+ watchCardLayout,
63
+ type CardAnchor,
64
+ type SideSpot,
65
+ } from './card-placement';
66
+ import {
67
+ articleContainers,
68
+ articleLeft,
69
+ columnLeft,
70
+ createKeepLeftToggle,
71
+ headerBottom,
72
+ layoutColumn,
73
+ lineTopOf,
74
+ measureSide,
75
+ readKeepLeft,
76
+ remToPx,
77
+ roomAt,
78
+ sideColumn,
79
+ slideArticle,
80
+ writeKeepLeft,
81
+ type ColumnEntry,
82
+ type KeepLeftToggle,
83
+ type SideColumn,
84
+ type SideMeasure,
85
+ type SideOptions,
86
+ } from './card-side';
87
+ import { startTrip, type Box, type Point, type Trip } from './card-travel';
88
+
89
+ /**
90
+ * Every tunable number of the card system, in one place (the Constants
91
+ * table of the plan, which records the values tuned in Part 3).
92
+ */
93
+ export const CARD_CONSTANTS = {
94
+ /** P2: the gap between a card and its safe region, and between cards side by side (px). */
95
+ gap: 10,
96
+ /** P2: the room a card keeps from the window edge (px). */
97
+ edge: 12,
98
+ /** P2: a card squeezed to fit its side is never shorter than this (px). */
99
+ cardMinHeight: 120,
100
+ /** P2 step 6: the offset of a card placed over the card in its way (px). */
101
+ overlapOffset: 24,
102
+ /** P5: notation, citation and abbreviation cards. */
103
+ cardWidth: '23rem',
104
+ /** P5: the widest a reference card grows before its content scrolls. */
105
+ referenceMaxWidth: '80ch',
106
+ /** C2: how long the pointer may stay outside the travel zone (ms). */
107
+ travelGraceMs: 250,
108
+ /** C2: resting this long on a held-back trigger switches to it (ms). */
109
+ travelRestMs: 450,
110
+ /** C2: the longest one trip may last (ms). */
111
+ travelTripMaxMs: 2500,
112
+ /** C8: how long a kept card flashes (ms). */
113
+ flashMs: 500,
114
+ /** S1: the side column's gaps to the article and to the rail (px). */
115
+ sideGap: 16,
116
+ /** S1: the side card's narrowest and widest. */
117
+ sideWidthMin: '18rem',
118
+ sideWidthMax: '23rem',
119
+ /** S2: a side card's top sits this far above its trigger's line (px). */
120
+ sideLineOffset: 14,
121
+ /** S8: a side card is at most the window height, less the header and this (px). */
122
+ sideHeightMargin: 24,
123
+ /** S4: moving into or out of the side, and the article's slide (ms). */
124
+ animationMs: 100,
125
+ /** S5: how long the arrow stays after the pointer leaves (ms). */
126
+ arrowFadeMs: 1500,
127
+ /** D1: movement before a drag starts (px). */
128
+ dragThreshold: 4,
129
+ /** D3, D4: snapping into the side column, and letting go again (px). */
130
+ snapDistance: 32,
131
+ snapRelease: 48,
132
+ /** D5: a drop this close to the anchored spot returns the card (px). */
133
+ snapHome: 32,
134
+ /** D6: one arrow-key move, and one with Shift (px). */
135
+ keyboardStep: 8,
136
+ keyboardStepLarge: 48,
137
+ } as const;
138
+
139
+ // ---------------------------------------------------------------------------
140
+ // The registry, as pure state.
141
+ // ---------------------------------------------------------------------------
142
+
143
+ /**
144
+ * Hover: follows the pointer. Pinned: kept next to its trigger. Side: kept in
145
+ * the side column, level with its trigger's line. Moved: kept where the
146
+ * reader dragged it, against the article column and its trigger's line.
147
+ */
148
+ export type CardMode = 'hover' | 'pinned' | 'side' | 'moved';
149
+
150
+ /** A kept card stays open on its own; only a hovered one depends on the pointer. */
151
+ export const isKept = (mode: CardMode): boolean => mode !== 'hover';
152
+
153
+ export interface CardState {
154
+ readonly key: string;
155
+ /** The card whose content holds this card's trigger (C9), if any. */
156
+ readonly parentKey: string | null;
157
+ readonly mode: CardMode;
158
+ /** When the card first opened. Pinning never changes it (C10). */
159
+ readonly order: number;
160
+ }
161
+
162
+ export type CardStates = ReadonlyMap<string, CardState>;
163
+
164
+ const byOrder = (a: CardState, b: CardState) => a.order - b.order;
165
+
166
+ /** The card Escape closes: the newest, whatever its layer or mode (C10). */
167
+ export function escapeTarget(cards: CardStates): string | null {
168
+ let newest: CardState | null = null;
169
+ for (const card of cards.values()) {
170
+ if (!newest || card.order > newest.order) newest = card;
171
+ }
172
+ return newest?.key ?? null;
173
+ }
174
+
175
+ /** A card and its ancestors: the hover chain a card opened from inside `key` belongs to (C7). */
176
+ export function chainOf(cards: CardStates, key: string | null): Set<string> {
177
+ const chain = new Set<string>();
178
+ for (let at = key; at && cards.has(at) && !chain.has(at); ) {
179
+ chain.add(at);
180
+ at = cards.get(at)?.parentKey ?? null;
181
+ }
182
+ return chain;
183
+ }
184
+
185
+ /** The cards opened from inside `key`, directly or further down, parents before children. */
186
+ export function descendantsOf(cards: CardStates, key: string): string[] {
187
+ const found: string[] = [];
188
+ const seen = new Set<string>([key]);
189
+ for (let index = -1; index < found.length; index++) {
190
+ const parent = index === -1 ? key : found[index];
191
+ for (const card of [...cards.values()].sort(byOrder)) {
192
+ if (card.parentKey === parent && !seen.has(card.key)) {
193
+ seen.add(card.key);
194
+ found.push(card.key);
195
+ }
196
+ }
197
+ }
198
+ return found;
199
+ }
200
+
201
+ /** A card and everything opened from inside it, in the order they close: children first (C9). */
202
+ export function closingOrder(cards: CardStates, key: string): string[] {
203
+ if (!cards.has(key)) return [];
204
+ return [...descendantsOf(cards, key).reverse(), key];
205
+ }
206
+
207
+ /** Whether a card has a child, which keeps it open (C9). */
208
+ export function hasChild(cards: CardStates, key: string): boolean {
209
+ for (const card of cards.values()) if (card.parentKey === key) return true;
210
+ return false;
211
+ }
212
+
213
+ /**
214
+ * Whether only the pointer keeps a card open: it is hovered, and no card
215
+ * opened from inside it is kept — a pinned child keeps its parent (C9).
216
+ */
217
+ export function isTransient(cards: CardStates, key: string): boolean {
218
+ const card = cards.get(key);
219
+ if (!card || isKept(card.mode)) return false;
220
+ return descendantsOf(cards, key).every((child) => {
221
+ const mode = cards.get(child)?.mode;
222
+ return !mode || !isKept(mode);
223
+ });
224
+ }
225
+
226
+ /**
227
+ * The cards that close when a card opens from inside `hostKey` (`null`: from
228
+ * the page): every transient card outside the host's chain, newest first
229
+ * (C7). A kept card never closes because another opened.
230
+ */
231
+ export function closedByOpening(cards: CardStates, hostKey: string | null): string[] {
232
+ const chain = chainOf(cards, hostKey);
233
+ return [...cards.values()]
234
+ .filter((card) => !chain.has(card.key) && isTransient(cards, card.key))
235
+ .sort((a, b) => b.order - a.order)
236
+ .map((card) => card.key);
237
+ }
238
+
239
+ // ---------------------------------------------------------------------------
240
+ // Sources: what a layer tells the manager.
241
+ // ---------------------------------------------------------------------------
242
+
243
+ export interface CardSource {
244
+ /** The first half of every card key: `notation`, `citation`, … */
245
+ readonly layer: string;
246
+ /** The trigger an event target belongs to, including triggers inside open cards. */
247
+ triggerFrom(node: EventTarget | null): HTMLElement | null;
248
+ /** The item a trigger names (`motion.velocity`, `3`), or `null` to leave the trigger alone. */
249
+ itemFor(trigger: HTMLElement): string | null;
250
+ /**
251
+ * Whether a card may open for this trigger right now (default: yes). A
252
+ * trigger the source does not claim stays a plain link: the reference
253
+ * layer's highlight mode, and cross-page references in a standalone export.
254
+ */
255
+ claims?(trigger: HTMLElement): boolean;
256
+ /**
257
+ * A fresh, unattached card for `item` (see `cloneCardShell`), or `null`
258
+ * for an unknown item. A promise opens the card once it resolves, if the
259
+ * reader still wants it.
260
+ */
261
+ render(item: string, trigger: HTMLElement): HTMLElement | null | Promise<HTMLElement | null>;
262
+ /** After any of this layer's cards opens, closes or changes mode: the open items. */
263
+ onChange?(open: ReadonlyMap<string, CardMode>): void;
264
+ }
265
+
266
+ let shellSerial = 0;
267
+
268
+ /**
269
+ * A fresh card element from a `HoverPanel.astro` template, with an id of its
270
+ * own. The header's Side, Pin and × buttons (`data-card-side`,
271
+ * `data-card-pin`, `data-card-close`) and its note (`data-card-note`) are the
272
+ * manager's; the layer fills everything else.
273
+ */
274
+ export function cloneCardShell(template: HTMLTemplateElement): HTMLElement | null {
275
+ const shell = template.content.firstElementChild;
276
+ if (!(shell instanceof HTMLElement)) return null;
277
+ const el = document.importNode(shell, true);
278
+ if (template.id) el.id = `${template.id}-${++shellSerial}`;
279
+ return el;
280
+ }
281
+
282
+ // ---------------------------------------------------------------------------
283
+ // The manager.
284
+ // ---------------------------------------------------------------------------
285
+
286
+ interface CardRecord extends CardState {
287
+ readonly key: string;
288
+ readonly item: string;
289
+ readonly source: CardSource;
290
+ readonly el: HTMLElement;
291
+ /** The occurrence the card is anchored to; a hovered card follows the pointer to another (C8). */
292
+ trigger: HTMLElement;
293
+ parentKey: string | null;
294
+ mode: CardMode;
295
+ readonly order: number;
296
+ /** Where the card goes next to its trigger (P2): its home, for Home and D5. */
297
+ anchor: CardAnchor | null;
298
+ /** D2: a Moved card's place, against the article column and its trigger's line. */
299
+ moved: MovedSpot | null;
300
+ /** D2: where a Moved card was just dropped (viewport); stored once the article has moved. */
301
+ dropAt: Point | null;
302
+ /** S2, D3: a docked card's top minus its trigger's line top; a drop sets its own. */
303
+ sideDy: number;
304
+ graceTimer: ReturnType<typeof setTimeout> | null;
305
+ flashTimer: ReturnType<typeof setTimeout> | null;
306
+ unwatch: () => void;
307
+ }
308
+
309
+ interface Hit {
310
+ readonly source: CardSource;
311
+ readonly trigger: HTMLElement;
312
+ readonly item: string;
313
+ readonly key: string;
314
+ }
315
+
316
+ /** A card waiting for its content (a fetched page). */
317
+ interface PendingOpen {
318
+ hit: Hit;
319
+ mode: CardMode;
320
+ readonly token: number;
321
+ }
322
+
323
+ const LAYER_VARIABLES = {
324
+ '--explico-card-width': CARD_CONSTANTS.cardWidth,
325
+ '--explico-card-reference-max-width': CARD_CONSTANTS.referenceMaxWidth,
326
+ '--explico-card-flash': `${CARD_CONSTANTS.flashMs}ms`,
327
+ '--explico-card-motion': `${CARD_CONSTANTS.animationMs}ms`,
328
+ };
329
+
330
+ /** S7: the Side button's answer when the window has no side column. */
331
+ const NO_ROOM_MESSAGE = 'Collapse a side panel to make room';
332
+
333
+ const sources: CardSource[] = [];
334
+ const cards = new Map<string, CardRecord>();
335
+ const pending = new Map<string, PendingOpen>();
336
+ let travel: { readonly card: CardRecord; readonly trip: Trip } | null = null;
337
+ let layout: ReturnType<typeof watchCardLayout> | null = null;
338
+ let opened = 0;
339
+ let raised = 0;
340
+ let pendingSerial = 0;
341
+ /**
342
+ * The pointer's last position. A `pointerout` reports where the pointer has
343
+ * already arrived — often on the next trigger — so the trip's apex, the
344
+ * point where it left its trigger, is the last position before that.
345
+ */
346
+ let lastPointer: Point | null = null;
347
+ /** A trigger being given focus back as its card closes: focusing it must not reopen the card. */
348
+ let refocusing: HTMLElement | null = null;
349
+ /** S3: how far the article is slid left of where it rests now (px). */
350
+ let slid = 0;
351
+ /**
352
+ * S2: the docked card the reader pointed at last — docked, or re-aligned by
353
+ * a click on another occurrence. It keeps its ideal spot in every later
354
+ * layout, so a resize does not reshuffle the column.
355
+ */
356
+ let pointedKey: string | null = null;
357
+ /** S9: "Keep the article left", as the reader last set it. */
358
+ let keepLeft = false;
359
+ let keepLeftToggle: KeepLeftToggle | null = null;
360
+ /** S5: the one arrow, from a kept card to an occurrence of its item. */
361
+ let arrow: ArrowOverlay | null = null;
362
+ /** S4: the animations moving cards and the article now. */
363
+ const motion = new Set<Animation>();
364
+ /** D1–D7: the grip's gesture, and the card being dragged with what it was before. */
365
+ let drag: CardDrag | null = null;
366
+ let dragged: {
367
+ readonly card: CardRecord;
368
+ readonly mode: CardMode;
369
+ readonly measure: SideMeasure | null;
370
+ readonly column: SideColumn | null;
371
+ } | null = null;
372
+ /** Cards this page had open when the reader last left it, not restored yet. */
373
+ let remembered: RememberedCard[] = [];
374
+ let rememberedPointed: string | null = null;
375
+ /** A layout change that arrived while things were moving; handled once they stop. */
376
+ let arrangeWhenStill = false;
377
+ let arranging = false;
378
+ let arrangeAgain = false;
379
+
380
+ const noop = () => {};
381
+
382
+ const isOpen = (card: CardRecord) => cards.get(card.key) === card;
383
+
384
+ const ordered = () => [...cards.values()].sort(byOrder);
385
+
386
+ function cardContaining(node: EventTarget | null): CardRecord | null {
387
+ const start =
388
+ node instanceof Element ? node : node instanceof Node ? node.parentElement : null;
389
+ const el = start?.closest<HTMLElement>('[data-card-key]');
390
+ const card = el?.dataset.cardKey ? cards.get(el.dataset.cardKey) : undefined;
391
+ return card && card.el === el ? card : null;
392
+ }
393
+
394
+ /** Whether `node` is inside `card` or inside a card opened from it. */
395
+ function insideSubtree(card: CardRecord, node: Node): boolean {
396
+ if (card.el.contains(node)) return true;
397
+ return descendantsOf(cards, card.key).some((key) => cards.get(key)?.el.contains(node));
398
+ }
399
+
400
+ /** The innermost trigger of any layer at `node`. */
401
+ function findTrigger(node: EventTarget | null): Hit | null {
402
+ let best: Hit | null = null;
403
+ for (const source of sources) {
404
+ const trigger = source.triggerFrom(node);
405
+ if (!trigger || (best && trigger.contains(best.trigger))) continue;
406
+ const item = source.itemFor(trigger);
407
+ if (item === null) continue;
408
+ const key = `${source.layer}:${item}`;
409
+ // A card's own item, pointed at from inside that card, is not a trigger.
410
+ if (cardContaining(trigger)?.key === key) continue;
411
+ best = { source, trigger, item, key };
412
+ }
413
+ return best;
414
+ }
415
+
416
+ /**
417
+ * Hovered, keyboard-focused, or kept open by a child (C9). Focus counts only
418
+ * as `:focus-visible`: the focus a mouse click leaves behind is not a reason
419
+ * to keep a card.
420
+ */
421
+ function wanted(card: CardRecord): boolean {
422
+ const { trigger, el } = card;
423
+ return (
424
+ (trigger.isConnected &&
425
+ (trigger.matches(':hover') || trigger.matches(':focus-visible'))) ||
426
+ el.matches(':hover') ||
427
+ el.matches(':focus-visible') ||
428
+ el.querySelector(':focus-visible') !== null ||
429
+ hasChild(cards, card.key)
430
+ );
431
+ }
432
+
433
+ function notify(source: CardSource): void {
434
+ if (!source.onChange) return;
435
+ const open = new Map<string, CardMode>();
436
+ for (const card of ordered()) if (card.source === source) open.set(card.item, card.mode);
437
+ source.onChange(open);
438
+ }
439
+
440
+ function syncMode(card: CardRecord): void {
441
+ card.el.dataset.cardMode = card.mode;
442
+ for (const pin of card.el.querySelectorAll<HTMLElement>('[data-card-pin]')) {
443
+ pin.setAttribute('aria-pressed', String(card.mode === 'pinned'));
444
+ }
445
+ for (const side of card.el.querySelectorAll<HTMLElement>('[data-card-side]')) {
446
+ side.setAttribute('aria-pressed', String(card.mode === 'side'));
447
+ }
448
+ if (card.mode === 'side') showNote(card, null);
449
+ }
450
+
451
+ /** S7: a short message in the card's header, or none. */
452
+ function showNote(card: CardRecord, text: string | null): void {
453
+ const note = card.el.querySelector<HTMLElement>('[data-card-note]');
454
+ if (!note) return;
455
+ note.textContent = text ?? '';
456
+ note.hidden = text === null;
457
+ }
458
+
459
+ function setMode(card: CardRecord, mode: CardMode): void {
460
+ if (card.mode === mode) return;
461
+ card.mode = mode;
462
+ if (mode !== 'moved') {
463
+ card.moved = null;
464
+ card.dropAt = null;
465
+ }
466
+ if (mode !== 'side') card.sideDy = -CARD_CONSTANTS.sideLineOffset;
467
+ if (isKept(mode)) {
468
+ cancelGrace(card);
469
+ if (travel?.card === card) endTravel();
470
+ } else if (arrow?.card === card.el) {
471
+ arrow.hide(); // S5: only a kept card has an arrow
472
+ }
473
+ syncMode(card);
474
+ notify(card.source);
475
+ }
476
+
477
+ /** P4: the card clicked, focused or opened last is on top. */
478
+ function raise(card: CardRecord): void {
479
+ card.el.style.zIndex = String(++raised);
480
+ }
481
+
482
+ /** C8: an accent ring for a moment, to show where a kept card is. */
483
+ function flash(card: CardRecord): void {
484
+ const { el } = card;
485
+ el.classList.remove('is-card-flashing');
486
+ void el.offsetWidth; // restart the animation
487
+ el.classList.add('is-card-flashing');
488
+ if (card.flashTimer !== null) clearTimeout(card.flashTimer);
489
+ card.flashTimer = setTimeout(() => {
490
+ card.flashTimer = null;
491
+ el.classList.remove('is-card-flashing');
492
+ }, CARD_CONSTANTS.flashMs);
493
+ }
494
+
495
+ function cancelGrace(card: CardRecord): void {
496
+ if (card.graceTimer !== null) clearTimeout(card.graceTimer);
497
+ card.graceTimer = null;
498
+ }
499
+
500
+ function closeUnlessWanted(card: CardRecord): void {
501
+ if (!isOpen(card) || card.mode !== 'hover' || travel?.card === card || wanted(card)) return;
502
+ close(card);
503
+ }
504
+
505
+ /** C2: close a hovered card after the grace, unless something wants it by then. */
506
+ function graceCheck(card: CardRecord): void {
507
+ cancelGrace(card);
508
+ card.graceTimer = setTimeout(() => {
509
+ card.graceTimer = null;
510
+ closeUnlessWanted(card);
511
+ }, CARD_CONSTANTS.travelGraceMs);
512
+ }
513
+
514
+ function endTravel(): void {
515
+ travel?.trip.end();
516
+ travel = null;
517
+ }
518
+
519
+ /** C2: the pointer left `card`'s trigger at `apex`, heading somewhere. */
520
+ function startTravel(card: CardRecord, apex: Point): void {
521
+ endTravel();
522
+ cancelGrace(card);
523
+ let handle: Trip | null = null;
524
+ const release = () => {
525
+ if (travel && travel.trip === handle) travel = null;
526
+ };
527
+ handle = startTrip(apex, {
528
+ graceMs: CARD_CONSTANTS.travelGraceMs,
529
+ restMs: CARD_CONSTANTS.travelRestMs,
530
+ tripMaxMs: CARD_CONSTANTS.travelTripMaxMs,
531
+ cardBox: () => (isOpen(card) ? card.el.getBoundingClientRect() : null),
532
+ triggerBox: () =>
533
+ card.trigger.isConnected ? card.trigger.getBoundingClientRect() : null,
534
+ onExit: () => {
535
+ release();
536
+ closeUnlessWanted(card);
537
+ },
538
+ onLand: () => {
539
+ release();
540
+ // Landing on the card's box normally means hovering it; the check
541
+ // only catches a card covered by another where it landed.
542
+ graceCheck(card);
543
+ },
544
+ onRest: (point) => restOn(card, point),
545
+ });
546
+ travel = handle.active ? { card, trip: handle } : null;
547
+ }
548
+
549
+ /** C2: the pointer rested in the zone; a held-back trigger under it takes over. */
550
+ function restOn(card: CardRecord, point: Point): void {
551
+ const under = findTrigger(document.elementFromPoint(point.x, point.y));
552
+ if (!under || under.trigger === card.trigger || card.el.contains(under.trigger)) return;
553
+ endTravel();
554
+ if (isOpen(card) && card.mode === 'hover') close(card);
555
+ hoverOpen(under);
556
+ }
557
+
558
+ /** C2: a scroll or resize moves the zone out from under the pointer. */
559
+ function interruptTravel(): void {
560
+ if (!travel) return;
561
+ const { card } = travel;
562
+ endTravel();
563
+ graceCheck(card);
564
+ }
565
+
566
+ function watch(card: CardRecord): void {
567
+ card.unwatch();
568
+ if (!layout) return;
569
+ const { schedule, observe, unobserve } = layout;
570
+ const containers = scrollContainersOf(card.trigger);
571
+ for (const container of containers) {
572
+ container.addEventListener('scroll', schedule, { passive: true });
573
+ }
574
+ observe(card.el);
575
+ card.unwatch = () => {
576
+ for (const container of containers) container.removeEventListener('scroll', schedule);
577
+ unobserve(card.el);
578
+ };
579
+ }
580
+
581
+ /** P2, P6: place `card` next to its trigger, clear of every other open card. */
582
+ function place(card: CardRecord): void {
583
+ const others = ordered()
584
+ .filter((other) => other !== card)
585
+ .map((other) => other.el);
586
+ card.anchor = placeElement(card.el, card.trigger, others, {
587
+ gap: CARD_CONSTANTS.gap,
588
+ edge: CARD_CONSTANTS.edge,
589
+ overlapOffset: CARD_CONSTANTS.overlapOffset,
590
+ minHeight: CARD_CONSTANTS.cardMinHeight,
591
+ sideColumn: (width) => columnSpot(card, width),
592
+ });
593
+ watch(card);
594
+ }
595
+
596
+ const sideOptions = (): SideOptions => ({
597
+ gap: CARD_CONSTANTS.sideGap,
598
+ minWidth: remToPx(CARD_CONSTANTS.sideWidthMin),
599
+ maxWidth: remToPx(CARD_CONSTANTS.sideWidthMax),
600
+ });
601
+
602
+ /**
603
+ * P2 step 5: the side column at the trigger's line, when the window has one
604
+ * (S1, S7) with room for `width` where the article is now. A card placed
605
+ * here never slides the article: that would move its trigger out from under
606
+ * the pointer that opened it.
607
+ */
608
+ function columnSpot(card: CardRecord, width: number): SideSpot | null {
609
+ const measure = measureSide();
610
+ if (!measure || !sideColumn(measure, sideOptions())) return null;
611
+ const line = lineTopOf(card.trigger);
612
+ const gap = CARD_CONSTANTS.sideGap;
613
+ if (line === null || roomAt(measure, slid, gap) < width - 0.5) return null;
614
+ return { left: columnLeft(measure, slid, gap), top: line - CARD_CONSTANTS.sideLineOffset };
615
+ }
616
+
617
+ /**
618
+ * D2: a Moved card at its place against the article column and its
619
+ * trigger's line, kept between the sidebars. A card just dropped is stored
620
+ * against where they are now, so a slide of the article that the drop set
621
+ * off leaves the card where the reader let go of it.
622
+ */
623
+ function placeMoved(card: CardRecord): void {
624
+ const line = lineTopOf(card.trigger);
625
+ const column = articleColumn(card.trigger);
626
+ const { dropAt } = card;
627
+ card.dropAt = null;
628
+ if (line === null || !column) {
629
+ // The trigger has no box (a disclosure closed around it): the card stays.
630
+ if (dropAt && !card.moved) moveTo(card.el, dropAt.x, dropAt.y);
631
+ return;
632
+ }
633
+ if (dropAt) card.moved = movedSpot(dropAt, column.left, line);
634
+ if (!card.moved) return;
635
+ const at = movedAt(card.moved, column.left, line);
636
+ const { width } = card.el.getBoundingClientRect();
637
+ moveTo(card.el, keepBetween(at.x, width, sidebarsBetween(CARD_CONSTANTS.edge)), at.y);
638
+ }
639
+
640
+ /** P1: a card that is not docked goes back to its place after a layout change. */
641
+ function reanchor(card: CardRecord, measure: SideMeasure | null): void {
642
+ if (card.mode === 'moved') {
643
+ placeMoved(card);
644
+ return;
645
+ }
646
+ const { anchor } = card;
647
+ if (!anchor) return;
648
+ if (anchor.column) {
649
+ // Placed in the side column (P2 step 5): it follows the column's edge and
650
+ // its trigger's line while the column has room for it.
651
+ const line = lineTopOf(card.trigger);
652
+ const gap = CARD_CONSTANTS.sideGap;
653
+ const { width } = card.el.getBoundingClientRect();
654
+ if (measure && line !== null && roomAt(measure, slid, gap) >= width - 0.5) {
655
+ moveTo(card.el, columnLeft(measure, slid, gap), line + anchor.dy);
656
+ } else if (line !== null) {
657
+ place(card);
658
+ } else if (!card.trigger.isConnected && card.mode === 'hover') {
659
+ close(card);
660
+ }
661
+ return;
662
+ }
663
+ const placed = reanchorElement(card.el, card.trigger, anchor, CARD_CONSTANTS.edge);
664
+ if (!placed && !card.trigger.isConnected && card.mode === 'hover') close(card);
665
+ }
666
+
667
+ /** S8: the tallest a docked card gets. */
668
+ const sideMaxHeight = () =>
669
+ Math.max(
670
+ CARD_CONSTANTS.cardMinHeight,
671
+ window.innerHeight - headerBottom() - CARD_CONSTANTS.sideHeightMargin,
672
+ );
673
+
674
+ /**
675
+ * S2: where each docked card goes, in page coordinates, with the article slid
676
+ * `slide` px; the card `first` gets its ideal spot first. `extra` is a card
677
+ * about to dock at a height of its own (D3's landing spot). The card being
678
+ * dragged is left out.
679
+ */
680
+ function columnTops(
681
+ column: SideColumn,
682
+ slide: number,
683
+ first: string | null,
684
+ extra: ColumnEntry | null = null,
685
+ ): { left: number; docked: CardRecord[]; tops: Map<string, number>; origin: DOMRect } {
686
+ const skip = (card: CardRecord) => card === dragged?.card || card.key === extra?.key;
687
+ const docked = ordered().filter((card) => card.mode === 'side' && !skip(card));
688
+ const left = columnLeft(column, slide, CARD_CONSTANTS.sideGap);
689
+ const right = left + column.width;
690
+ // Page coordinates, so the layout does not depend on the scroll position.
691
+ const origin = cardLayer(LAYER_VARIABLES).getBoundingClientRect();
692
+ const entries: ColumnEntry[] = docked.map((card) => {
693
+ const rect = card.el.getBoundingClientRect();
694
+ const line = lineTopOf(card.trigger);
695
+ return {
696
+ key: card.key,
697
+ ideal: line === null ? null : line - origin.top + card.sideDy,
698
+ top: rect.top - origin.top,
699
+ height: rect.height,
700
+ };
701
+ });
702
+ if (extra) entries.push(extra);
703
+ // Any other card in the column's way stays where it is.
704
+ const obstacles = ordered()
705
+ .filter((card) => card.mode !== 'side' && !skip(card))
706
+ .map((card) => card.el.getBoundingClientRect())
707
+ .filter((rect) => rect.left < right && rect.right > left)
708
+ .map((rect) => ({ top: rect.top - origin.top, bottom: rect.bottom - origin.top }));
709
+ const tops = layoutColumn(entries, {
710
+ first,
711
+ gap: CARD_CONSTANTS.gap,
712
+ // Not under the fixed header at the top of the page.
713
+ minTop: headerBottom() + CARD_CONSTANTS.edge,
714
+ obstacles,
715
+ });
716
+ return { left, docked, tops, origin };
717
+ }
718
+
719
+ /**
720
+ * S1, S2, S8: the docked cards, at the column's edge and width, each level
721
+ * with its trigger's line (or at the height it was dropped at, D3); the card
722
+ * being pointed at first.
723
+ */
724
+ function layoutDocked(column: SideColumn, first: CardRecord | null): void {
725
+ const docking = ordered().filter((card) => card.mode === 'side' && card !== dragged?.card);
726
+ if (docking.length === 0) return;
727
+ const maxHeight = sideMaxHeight();
728
+ for (const card of docking) card.el.style.maxHeight = `${maxHeight}px`;
729
+ if (first) pointedKey = first.key;
730
+ const { left, docked, tops, origin } = columnTops(column, slid, pointedKey);
731
+ for (const card of docked) {
732
+ moveTo(card.el, left, (tops.get(card.key) ?? 0) + origin.top);
733
+ }
734
+ }
735
+
736
+ interface Motion {
737
+ readonly cards: ReadonlyMap<CardRecord, DOMRect>;
738
+ readonly article: number | null;
739
+ }
740
+
741
+ /** S4: where everything is before a change that should animate; `null` under reduced motion. */
742
+ function motionFrom(): Motion | null {
743
+ if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return null;
744
+ return {
745
+ cards: new Map(ordered().map((card) => [card, card.el.getBoundingClientRect()])),
746
+ article: articleLeft(),
747
+ };
748
+ }
749
+
750
+ function stopMotion(): void {
751
+ for (const animation of [...motion]) animation.cancel();
752
+ motion.clear();
753
+ }
754
+
755
+ /**
756
+ * S4: everything that moved slides from where it was to where it is now, in
757
+ * 100 ms. The layout is already final; only the picture catches up with it.
758
+ */
759
+ function play(from: Motion): void {
760
+ const timing: KeyframeAnimationOptions = {
761
+ duration: CARD_CONSTANTS.animationMs,
762
+ easing: 'ease-out',
763
+ };
764
+ const run = (el: HTMLElement, transform: string) => {
765
+ const animation = el.animate([{ transform }, { transform: 'none' }], timing);
766
+ motion.add(animation);
767
+ const done = () => {
768
+ motion.delete(animation);
769
+ if (motion.size > 0) return;
770
+ if (arrangeWhenStill) {
771
+ arrangeWhenStill = false;
772
+ arrange();
773
+ } else {
774
+ arrow?.redraw();
775
+ }
776
+ };
777
+ animation.finished.then(done, done);
778
+ };
779
+ for (const [card, before] of from.cards) {
780
+ if (!isOpen(card)) continue;
781
+ const after = card.el.getBoundingClientRect();
782
+ const dx = before.left - after.left;
783
+ const dy = before.top - after.top;
784
+ if (Math.abs(dx) >= 0.5 || Math.abs(dy) >= 0.5) {
785
+ run(card.el, `translate(${dx}px, ${dy}px)`);
786
+ }
787
+ }
788
+ const now = articleLeft();
789
+ if (from.article !== null && now !== null && Math.abs(from.article - now) >= 0.5) {
790
+ for (const el of articleContainers()) run(el, `translateX(${from.article - now}px)`);
791
+ }
792
+ }
793
+
794
+ interface ArrangeOptions {
795
+ /** Where everything was before the change, when it should animate (S4). */
796
+ readonly from?: Motion | null;
797
+ /** The card being pointed at: in the side column it gets its ideal spot first (S2). */
798
+ readonly first?: CardRecord | null;
799
+ /** Cards to place afresh next to their triggers (P2), once the article has moved. */
800
+ readonly place?: readonly CardRecord[];
801
+ }
802
+
803
+ /**
804
+ * P1, S1–S3, S7, S9: after anything that moves cards — a card docking,
805
+ * leaving the side or closing, the reader's "Keep the article left", a
806
+ * layout change — slide the article as far as the side column needs, lay the
807
+ * docked cards out, and put every other card back beside its trigger.
808
+ */
809
+ function arrange(options: ArrangeOptions = {}): void {
810
+ if (arranging) {
811
+ arrangeAgain = true;
812
+ return;
813
+ }
814
+ arranging = true;
815
+ try {
816
+ stopMotion();
817
+ const docked = ordered().filter((card) => card.mode === 'side');
818
+ const needed =
819
+ docked.length > 0 || keepLeft || ordered().some((card) => card.anchor?.column);
820
+ const measure = needed ? measureSide() : null;
821
+ const column = measure ? sideColumn(measure, sideOptions()) : null;
822
+ const fresh = new Set(options.place ?? []);
823
+ if (!column) {
824
+ // S7: the side column is gone — the window narrowed, a panel opened.
825
+ // Its cards go back next to their triggers, still kept.
826
+ for (const card of docked) {
827
+ if (card === dragged?.card) continue;
828
+ fresh.add(card);
829
+ setMode(card, 'pinned');
830
+ }
831
+ }
832
+ const docking = column ? docked.length : 0;
833
+ // S3, S9: only as far as the side cards need, or all the way while the
834
+ // reader keeps the article left.
835
+ slid = !column ? 0 : keepLeft ? column.maxSlide : docking > 0 ? column.slide : 0;
836
+ slideArticle(slid, column?.maxSlide ?? 0);
837
+ if (column) {
838
+ cardLayer(LAYER_VARIABLES).style.setProperty(
839
+ '--explico-card-side-width',
840
+ `${column.width}px`,
841
+ );
842
+ }
843
+ // The card being dragged goes where the pointer takes it, nowhere else.
844
+ for (const card of ordered()) {
845
+ if (card.mode !== 'side' && !fresh.has(card) && card !== dragged?.card) {
846
+ reanchor(card, measure);
847
+ }
848
+ }
849
+ if (column) layoutDocked(column, options.first ?? null);
850
+ 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
+ });
856
+ arrow?.redraw();
857
+ if (options.from) play(options.from);
858
+ } finally {
859
+ arranging = false;
860
+ }
861
+ if (arrangeAgain) {
862
+ arrangeAgain = false;
863
+ arrange();
864
+ }
865
+ }
866
+
867
+ /** P1: the layout changed. While cards or the article are moving, wait until they stop. */
868
+ function reanchorAll(): void {
869
+ if (motion.size > 0) {
870
+ arrangeWhenStill = true;
871
+ return;
872
+ }
873
+ arrange();
874
+ }
875
+
876
+ /** S6, S7: the Side button — into the side column, or back next to the trigger. */
877
+ function toggleSide(card: CardRecord): void {
878
+ const from = motionFrom();
879
+ if (card.mode === 'side') {
880
+ setMode(card, 'pinned');
881
+ arrange({ from, place: [card] });
882
+ return;
883
+ }
884
+ const measure = measureSide();
885
+ if (!measure || !sideColumn(measure, sideOptions())) {
886
+ showNote(card, NO_ROOM_MESSAGE);
887
+ return;
888
+ }
889
+ setMode(card, 'side');
890
+ raise(card);
891
+ arrange({ from, first: card });
892
+ }
893
+
894
+ /** S5: a docked or moved card can sit far from its trigger; pointing at it shows where it is. */
895
+ const awayFromTrigger = (mode: CardMode | undefined): boolean =>
896
+ mode === 'side' || mode === 'moved';
897
+
898
+ /** 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 {
900
+ if (!arrow || !isKept(card.mode) || dragged?.card === card) return;
901
+ arrow.show(card.el, target);
902
+ }
903
+
904
+ /** C8: move a card to another occurrence of its item. A docked card re-aligns to its line. */
905
+ function reattach(card: CardRecord, trigger: HTMLElement): void {
906
+ card.trigger = trigger;
907
+ card.parentKey = cardContaining(trigger)?.key ?? null;
908
+ if (card.mode !== 'side') {
909
+ place(card);
910
+ return;
911
+ }
912
+ const from = motionFrom();
913
+ card.sideDy = -CARD_CONSTANTS.sideLineOffset;
914
+ watch(card);
915
+ arrange({ from, first: card });
916
+ }
917
+
918
+ /** D5, D6: back next to its trigger, Pinned, at the spot it was placed at. */
919
+ function goHome(card: CardRecord): void {
920
+ const from = motionFrom();
921
+ setMode(card, 'pinned');
922
+ arrange({ from, place: card.anchor ? [] : [card] });
923
+ }
924
+
925
+ /** D5: where a card's anchor puts it next to its trigger (its top-left corner), or `null`. */
926
+ function homeSpot(card: CardRecord, measure: SideMeasure | null): Point | null {
927
+ const { anchor } = card;
928
+ if (!anchor) return null;
929
+ if (!anchor.column) return anchoredSpot(card.el, card.trigger, anchor, CARD_CONSTANTS.edge);
930
+ const line = lineTopOf(card.trigger);
931
+ if (!measure || line === null) return null;
932
+ return { x: columnLeft(measure, slid, CARD_CONSTANTS.sideGap), y: line + anchor.dy };
933
+ }
934
+
935
+ /** D3: where a card dropped in the snap zone with its top at `top` lands, in the viewport. */
936
+ function landingOf(card: CardRecord, top: number): Box | null {
937
+ const column = dragged?.card === card ? dragged.column : null;
938
+ if (!column) return null;
939
+ // S3, S9: the article slides as the card docks, as far as it will then be.
940
+ const slide = keepLeft ? column.maxSlide : column.slide;
941
+ const origin = cardLayer(LAYER_VARIABLES).getBoundingClientRect();
942
+ const height = Math.min(card.el.getBoundingClientRect().height, sideMaxHeight());
943
+ const want = top - origin.top;
944
+ const { left, tops } = columnTops(column, slide, card.key, {
945
+ key: card.key,
946
+ ideal: want,
947
+ top: want,
948
+ height,
949
+ });
950
+ const at = (tops.get(card.key) ?? want) + origin.top;
951
+ return { left, top: at, right: left + column.width, bottom: at + height };
952
+ }
953
+
954
+ /** D1–D7: the manager's side of a drag from a card's grip (`./card-drag.ts`). */
955
+ const dragHost: DragHost = {
956
+ cardOf(grip) {
957
+ const card = cardContaining(grip);
958
+ return card && card.el.contains(grip) ? card.el : null;
959
+ },
960
+ begin(el) {
961
+ const card = cardContaining(el);
962
+ if (!card || card.el !== el) return null;
963
+ const measure = measureSide();
964
+ const column = measure ? sideColumn(measure, sideOptions()) : null;
965
+ const home = homeSpot(card, measure);
966
+ stopMotion();
967
+ dragged = { card, mode: card.mode, measure, column };
968
+ // D2: dragging pins the card.
969
+ if (card.mode === 'hover') setMode(card, 'pinned');
970
+ raise(card);
971
+ if (arrow?.card === card.el) arrow.hide();
972
+ const gap = CARD_CONSTANTS.sideGap;
973
+ // D3, S7: without a side column the zone shows nothing and never snaps.
974
+ const edge = column && measure ? columnLeft(measure, slid, 0) : null;
975
+ return {
976
+ bounds: dragBounds(CARD_CONSTANTS.edge),
977
+ edge,
978
+ area: edge !== null && measure ? { left: edge + gap, right: measure.railLeft - gap } : null,
979
+ home,
980
+ };
981
+ },
982
+ landing(el, top) {
983
+ const card = cardContaining(el);
984
+ return card ? landingOf(card, top) : null;
985
+ },
986
+ drop(el, outcome) {
987
+ const card = dragged?.card;
988
+ dragged = null;
989
+ if (!card || card.el !== el || !isOpen(card)) return;
990
+ if (outcome.kind === 'home') {
991
+ goHome(card);
992
+ return;
993
+ }
994
+ const from = motionFrom();
995
+ if (outcome.kind === 'side') {
996
+ // D3: at the drop height; S2 resolves overlaps, the article slides (S3).
997
+ setMode(card, 'side');
998
+ const line = lineTopOf(card.trigger);
999
+ card.sideDy = line === null ? -CARD_CONSTANTS.sideLineOffset : outcome.top - line;
1000
+ arrange({ from, first: card });
1001
+ return;
1002
+ }
1003
+ // D2: where it was dropped.
1004
+ setMode(card, 'moved');
1005
+ card.dropAt = { x: outcome.left, y: outcome.top };
1006
+ arrange({ from });
1007
+ },
1008
+ cancel(el) {
1009
+ const state = dragged;
1010
+ dragged = null;
1011
+ if (!state || state.card.el !== el || !isOpen(state.card)) return;
1012
+ // D7: back where it started, as it was: a hovered card closes again
1013
+ // once the pointer is elsewhere.
1014
+ setMode(state.card, state.mode);
1015
+ arrange();
1016
+ if (state.mode === 'hover') graceCheck(state.card);
1017
+ },
1018
+ nudge(el, by) {
1019
+ const card = cardContaining(el);
1020
+ if (!card || card.el !== el) return;
1021
+ const rect = el.getBoundingClientRect();
1022
+ const grip = el.querySelector<HTMLElement>('[data-card-grip]');
1023
+ const grab = grip ? Math.max(grip.getBoundingClientRect().bottom - rect.top, 1) : rect.height;
1024
+ const at = nudgeSpot(
1025
+ { x: rect.left, y: rect.top },
1026
+ by,
1027
+ rect.width,
1028
+ dragBounds(CARD_CONSTANTS.edge),
1029
+ grab,
1030
+ );
1031
+ const from = motionFrom();
1032
+ setMode(card, 'moved');
1033
+ card.dropAt = at;
1034
+ raise(card);
1035
+ arrange({ from });
1036
+ },
1037
+ home(el) {
1038
+ const card = cardContaining(el);
1039
+ if (!card || card.el !== el || card.mode === 'hover') return;
1040
+ goHome(card);
1041
+ },
1042
+ };
1043
+
1044
+ /** C10: focus goes back to the trigger when it can take focus, else to the article. */
1045
+ function returnFocus(card: CardRecord): void {
1046
+ const { trigger } = card;
1047
+ if (trigger.isConnected && trigger.tabIndex >= 0 && trigger.getClientRects().length > 0) {
1048
+ refocusing = trigger;
1049
+ try {
1050
+ trigger.focus({ preventScroll: true });
1051
+ } finally {
1052
+ refocusing = null;
1053
+ }
1054
+ return;
1055
+ }
1056
+ const article = document.querySelector<HTMLElement>('main');
1057
+ if (!article) return;
1058
+ if (!article.hasAttribute('tabindex')) {
1059
+ article.setAttribute('tabindex', '-1');
1060
+ article.dataset.cardFocusReturn = '';
1061
+ }
1062
+ article.focus({ preventScroll: true });
1063
+ }
1064
+
1065
+ function teardown(card: CardRecord): void {
1066
+ const focusInside = card.el.contains(document.activeElement);
1067
+ if (dragged?.card === card) {
1068
+ dragged = null;
1069
+ drag?.abort();
1070
+ }
1071
+ if (travel?.card === card) endTravel();
1072
+ if (arrow?.card === card.el) arrow.hide();
1073
+ cancelGrace(card);
1074
+ if (card.flashTimer !== null) clearTimeout(card.flashTimer);
1075
+ card.unwatch();
1076
+ cards.delete(card.key);
1077
+ card.el.remove();
1078
+ if (focusInside) returnFocus(card);
1079
+ notify(card.source);
1080
+ }
1081
+
1082
+ /** Close a card and every card opened from inside it (C9). */
1083
+ function close(card: CardRecord): void {
1084
+ if (!isOpen(card)) return;
1085
+ const parent = card.parentKey ? cards.get(card.parentKey) : undefined;
1086
+ const closing = closingOrder(cards, card.key);
1087
+ const undocking = closing.some((key) => cards.get(key)?.mode === 'side');
1088
+ const from = undocking ? motionFrom() : null;
1089
+ for (const key of closing) {
1090
+ const target = cards.get(key);
1091
+ if (target) teardown(target);
1092
+ }
1093
+ // S2, S3: the column closes up, and the article slides back once the last
1094
+ // side card is gone.
1095
+ if (undocking) arrange({ from });
1096
+ // C9: the parent was kept open by this child; check it again.
1097
+ if (parent && isOpen(parent) && parent.mode === 'hover') graceCheck(parent);
1098
+ }
1099
+
1100
+ /** C7: close the transient cards outside the chain of `hostKey`. */
1101
+ function closeOutside(hostKey: string | null): void {
1102
+ for (const key of closedByOpening(cards, hostKey)) {
1103
+ const card = cards.get(key);
1104
+ if (card) close(card);
1105
+ }
1106
+ }
1107
+
1108
+ function mount(hit: Hit, el: HTMLElement, mode: CardMode): void {
1109
+ el.dataset.cardKey = hit.key;
1110
+ el.hidden = false;
1111
+ el.style.visibility = 'hidden';
1112
+ cardLayer(LAYER_VARIABLES).append(el);
1113
+ const card: CardRecord = {
1114
+ key: hit.key,
1115
+ item: hit.item,
1116
+ source: hit.source,
1117
+ el,
1118
+ trigger: hit.trigger,
1119
+ parentKey: cardContaining(hit.trigger)?.key ?? null,
1120
+ mode,
1121
+ order: ++opened,
1122
+ anchor: null,
1123
+ moved: null,
1124
+ dropAt: null,
1125
+ sideDy: -CARD_CONSTANTS.sideLineOffset,
1126
+ graceTimer: null,
1127
+ flashTimer: null,
1128
+ unwatch: noop,
1129
+ };
1130
+ cards.set(card.key, card);
1131
+ syncMode(card);
1132
+ place(card);
1133
+ el.style.visibility = '';
1134
+ raise(card);
1135
+ el.addEventListener('pointerleave', (event) => {
1136
+ if (event.pointerType === 'touch' || !isOpen(card) || card.mode !== 'hover') return;
1137
+ // Straight back onto the trigger is the trigger's own business.
1138
+ if (event.relatedTarget instanceof Node && card.trigger.contains(event.relatedTarget)) {
1139
+ return;
1140
+ }
1141
+ graceCheck(card);
1142
+ });
1143
+ el.addEventListener('focusout', () => {
1144
+ if (isOpen(card) && card.mode === 'hover') graceCheck(card);
1145
+ });
1146
+ notify(card.source);
1147
+ }
1148
+
1149
+ function settle(key: string, token: number, el: HTMLElement | null): void {
1150
+ const waiting = pending.get(key);
1151
+ if (!waiting || waiting.token !== token) return;
1152
+ pending.delete(key);
1153
+ const { hit, mode } = waiting;
1154
+ if (!el || cards.has(key) || !hit.trigger.isConnected) return;
1155
+ if (
1156
+ mode === 'hover' &&
1157
+ !hit.trigger.matches(':hover') &&
1158
+ !hit.trigger.matches(':focus-visible')
1159
+ ) {
1160
+ return;
1161
+ }
1162
+ closeOutside(cardContaining(hit.trigger)?.key ?? null);
1163
+ mount(hit, el, mode);
1164
+ }
1165
+
1166
+ /** Open a card for `hit` (C7: first closing the transient cards outside its chain). */
1167
+ function request(hit: Hit, mode: CardMode): void {
1168
+ closeOutside(cardContaining(hit.trigger)?.key ?? null);
1169
+ for (const [key, waiting] of pending) {
1170
+ if (key !== hit.key && waiting.mode === 'hover') pending.delete(key);
1171
+ }
1172
+ const rendered = hit.source.render(hit.item, hit.trigger);
1173
+ if (rendered instanceof Promise) {
1174
+ const token = ++pendingSerial;
1175
+ pending.set(hit.key, { hit, mode, token });
1176
+ rendered.then(
1177
+ (el) => settle(hit.key, token, el),
1178
+ () => settle(hit.key, token, null),
1179
+ );
1180
+ return;
1181
+ }
1182
+ if (rendered) mount(hit, rendered, mode);
1183
+ }
1184
+
1185
+ /** C1, C7, C8: the pointer or keyboard focus reached a trigger. */
1186
+ function hoverOpen(hit: Hit): void {
1187
+ const card = cards.get(hit.key);
1188
+ if (card) {
1189
+ cancelGrace(card);
1190
+ if (travel?.card === card) endTravel();
1191
+ if (hit.trigger !== card.trigger) {
1192
+ // C8: a hovered card follows the pointer to another occurrence; a kept
1193
+ // one stays and shows where it is, with an arrow to this one (S5).
1194
+ const inside = insideSubtree(card, hit.trigger);
1195
+ if (card.mode === 'hover' && !inside) {
1196
+ reattach(card, hit.trigger);
1197
+ } else {
1198
+ flash(card);
1199
+ if (!inside) pointAt(card, hit.trigger);
1200
+ }
1201
+ } else if (awayFromTrigger(card.mode)) {
1202
+ pointAt(card, card.trigger); // S5: a docked or moved card's own line
1203
+ }
1204
+ closeOutside(card.key);
1205
+ return;
1206
+ }
1207
+ if (hit.source.claims && !hit.source.claims(hit.trigger)) return;
1208
+ const waiting = pending.get(hit.key);
1209
+ if (waiting) {
1210
+ waiting.hit = hit;
1211
+ return;
1212
+ }
1213
+ request(hit, 'hover');
1214
+ }
1215
+
1216
+ function onPointerOver(event: PointerEvent): void {
1217
+ if (event.pointerType === 'touch') return;
1218
+ arrow?.follow(event.target);
1219
+ // S5: pointing at a docked or moved card shows where its line is.
1220
+ const host = cardContaining(event.target);
1221
+ if (
1222
+ host &&
1223
+ awayFromTrigger(host.mode) &&
1224
+ arrow?.card !== host.el &&
1225
+ !(event.relatedTarget instanceof Node && host.el.contains(event.relatedTarget))
1226
+ ) {
1227
+ pointAt(host, host.trigger);
1228
+ }
1229
+ const hit = findTrigger(event.target);
1230
+ if (!hit) return;
1231
+ if (event.relatedTarget instanceof Node && hit.trigger.contains(event.relatedTarget)) return;
1232
+ if (travel) {
1233
+ const { card, trip } = travel;
1234
+ if (hit.trigger === card.trigger) {
1235
+ endTravel(); // back on the trigger
1236
+ } else if (
1237
+ !card.el.contains(hit.trigger) &&
1238
+ trip.holds({ x: event.clientX, y: event.clientY })
1239
+ ) {
1240
+ return; // C2: held back while the pointer travels to the card
1241
+ }
1242
+ }
1243
+ hoverOpen(hit);
1244
+ }
1245
+
1246
+ function onPointerOut(event: PointerEvent): void {
1247
+ if (event.pointerType === 'touch') return;
1248
+ if (!event.relatedTarget) arrow?.release(); // out of the window
1249
+ const hit = findTrigger(event.target);
1250
+ if (!hit) return;
1251
+ const to = event.relatedTarget instanceof Node ? event.relatedTarget : null;
1252
+ if (to && hit.trigger.contains(to)) return;
1253
+ const card = cards.get(hit.key);
1254
+ if (!card || card.trigger !== hit.trigger || card.mode !== 'hover') return;
1255
+ if (to && card.el.contains(to)) return; // straight onto its card
1256
+ startTravel(card, lastPointer ?? { x: event.clientX, y: event.clientY });
1257
+ }
1258
+
1259
+ function onPointerMove(event: PointerEvent): void {
1260
+ if (event.pointerType === 'touch') return;
1261
+ lastPointer = { x: event.clientX, y: event.clientY };
1262
+ travel?.trip.move(lastPointer);
1263
+ }
1264
+
1265
+ function onPointerDown(event: PointerEvent): void {
1266
+ const card = cardContaining(event.target);
1267
+ if (card) raise(card);
1268
+ }
1269
+
1270
+ function onFocusIn(event: FocusEvent): void {
1271
+ arrow?.follow(event.target);
1272
+ const card = cardContaining(event.target);
1273
+ if (card) raise(card);
1274
+ 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);
1277
+ }
1278
+ const hit = findTrigger(event.target);
1279
+ if (!hit || hit.trigger === refocusing) return;
1280
+ // C1: keyboard focus opens a card; the focus a click leaves behind does not.
1281
+ if (!visible) return;
1282
+ hoverOpen(hit);
1283
+ }
1284
+
1285
+ function onFocusOut(event: FocusEvent): void {
1286
+ if (!event.relatedTarget) arrow?.release();
1287
+ const hit = findTrigger(event.target);
1288
+ if (!hit) return;
1289
+ const card = cards.get(hit.key);
1290
+ if (card && card.trigger === hit.trigger && card.mode === 'hover') graceCheck(card);
1291
+ }
1292
+
1293
+ /** C3, C4, C6, C8, S6, D5: clicks on a card's controls and on triggers. */
1294
+ function onClick(event: MouseEvent): void {
1295
+ const host = cardContaining(event.target);
1296
+ const control =
1297
+ host && event.target instanceof Element
1298
+ ? event.target.closest<HTMLElement>('[data-card-pin], [data-card-close], [data-card-side]')
1299
+ : null;
1300
+ if (host && control && host.el.contains(control)) {
1301
+ if (control.hasAttribute('data-card-close')) {
1302
+ close(host);
1303
+ } else if (control.hasAttribute('data-card-side') || host.mode === 'side') {
1304
+ toggleSide(host);
1305
+ } else if (host.mode === 'moved') {
1306
+ goHome(host); // its Pin is hidden, like a docked card's (S6)
1307
+ } else if (host.mode === 'pinned') {
1308
+ setMode(host, 'hover');
1309
+ graceCheck(host);
1310
+ } else {
1311
+ setMode(host, 'pinned');
1312
+ }
1313
+ return;
1314
+ }
1315
+
1316
+ const hit = findTrigger(event.target);
1317
+ if (!hit) return;
1318
+ const link = hit.trigger.closest('a[href]');
1319
+ // A modified click on a link keeps its native meaning (a new tab, …).
1320
+ if (
1321
+ link &&
1322
+ (event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey)
1323
+ ) {
1324
+ return;
1325
+ }
1326
+ const card = cards.get(hit.key);
1327
+ const waiting = pending.get(hit.key);
1328
+ if (!card && !waiting && hit.source.claims && !hit.source.claims(hit.trigger)) return;
1329
+
1330
+ event.preventDefault();
1331
+ // C3: the click toggles a pin and is nothing else. The anchor scroller and
1332
+ // the float enhancer must not read it as a jump to the target.
1333
+ if (link) event.stopPropagation();
1334
+
1335
+ if (card) {
1336
+ if (hit.trigger === card.trigger) {
1337
+ if (card.mode === 'hover') setMode(card, 'pinned');
1338
+ else close(card); // C4: its trigger, clicked again
1339
+ } else if (insideSubtree(card, hit.trigger)) {
1340
+ if (card.mode === 'hover') setMode(card, 'pinned');
1341
+ else flash(card);
1342
+ } else if (card.mode === 'moved') {
1343
+ // C8: a moved card stays where the reader put it, and shows where it is.
1344
+ flash(card);
1345
+ pointAt(card, hit.trigger);
1346
+ closeOutside(card.key);
1347
+ } else {
1348
+ // C8: the card moves to this occurrence, and stays: a pinned card
1349
+ // beside it, a docked one level with its line.
1350
+ reattach(card, hit.trigger);
1351
+ if (card.mode === 'hover') setMode(card, 'pinned');
1352
+ closeOutside(card.key);
1353
+ }
1354
+ return;
1355
+ }
1356
+ if (waiting) {
1357
+ waiting.hit = hit;
1358
+ waiting.mode = 'pinned';
1359
+ return;
1360
+ }
1361
+ request(hit, 'pinned');
1362
+ }
1363
+
1364
+ function onKeyDown(event: KeyboardEvent): void {
1365
+ if (event.key !== 'Escape') return;
1366
+ const key = escapeTarget(cards);
1367
+ const card = key ? cards.get(key) : undefined;
1368
+ if (card) {
1369
+ close(card); // C10: one card per press, newest first
1370
+ return;
1371
+ }
1372
+ pending.clear();
1373
+ }
1374
+
1375
+ // ---------------------------------------------------------------------------
1376
+ // Remembering the open cards (`./card-memory.ts`).
1377
+ // ---------------------------------------------------------------------------
1378
+
1379
+ /**
1380
+ * Every trigger of `source` for one of `items`, in document order: on the
1381
+ * page (outside every card) when `scope` is `null`, else inside that card.
1382
+ */
1383
+ function occurrencesOf(
1384
+ source: CardSource,
1385
+ items: ReadonlySet<string>,
1386
+ scope: HTMLElement | null,
1387
+ ): Map<string, HTMLElement[]> {
1388
+ const found = new Map<string, HTMLElement[]>();
1389
+ const layer = cardLayer(LAYER_VARIABLES);
1390
+ for (const el of (scope ?? document.body).querySelectorAll<HTMLElement>('*')) {
1391
+ if (!scope && layer.contains(el)) continue;
1392
+ if (source.triggerFrom(el) !== el) continue;
1393
+ const item = source.itemFor(el);
1394
+ if (item === null || !items.has(item)) continue;
1395
+ const list = found.get(item);
1396
+ if (list) list.push(el);
1397
+ else found.set(item, [el]);
1398
+ }
1399
+ return found;
1400
+ }
1401
+
1402
+ /** Remember the cards the reader keeps, and what keeps them there, as the page is left. */
1403
+ function rememberCards(): void {
1404
+ const saved: RememberedCard[] = [];
1405
+ const keys = new Set<string>();
1406
+ // One walk of the page, or of a parent card, per layer.
1407
+ const lookups = new Map<string, Map<string, HTMLElement[]>>();
1408
+ const kept = ordered().filter((card) => !isTransient(cards, card.key));
1409
+ for (const card of kept) {
1410
+ if (!card.trigger.isConnected) continue;
1411
+ // A card opened from inside another comes back only with it.
1412
+ if (card.parentKey !== null && !keys.has(card.parentKey)) continue;
1413
+ const scope = card.parentKey ? (cards.get(card.parentKey)?.el ?? null) : null;
1414
+ const lookup = `${card.source.layer}|${card.parentKey ?? ''}`;
1415
+ let triggers = lookups.get(lookup);
1416
+ if (!triggers) {
1417
+ const items = new Set(
1418
+ kept
1419
+ .filter((other) => other.source === card.source && other.parentKey === card.parentKey)
1420
+ .map((other) => other.item),
1421
+ );
1422
+ triggers = occurrencesOf(card.source, items, scope);
1423
+ lookups.set(lookup, triggers);
1424
+ }
1425
+ const occurrence = triggers.get(card.item)?.indexOf(card.trigger) ?? -1;
1426
+ if (occurrence < 0) continue;
1427
+ keys.add(card.key);
1428
+ saved.push({
1429
+ layer: card.source.layer,
1430
+ item: card.item,
1431
+ occurrence,
1432
+ parentKey: card.parentKey,
1433
+ mode: card.mode,
1434
+ anchor: card.anchor,
1435
+ moved: card.mode === 'moved' ? card.moved : null,
1436
+ sideDy: card.mode === 'side' ? card.sideDy : null,
1437
+ });
1438
+ }
1439
+ writeRememberedCards({
1440
+ cards: saved,
1441
+ pointed: pointedKey !== null && keys.has(pointedKey) ? pointedKey : null,
1442
+ });
1443
+ }
1444
+
1445
+ /** Open a remembered card as it was: in its mode, at its place. */
1446
+ function revive(hit: Hit, el: HTMLElement, saved: RememberedCard): void {
1447
+ mount(hit, el, saved.mode === 'hover' ? 'hover' : 'pinned');
1448
+ const card = cards.get(hit.key);
1449
+ if (!card || card.el !== el) return;
1450
+ if (saved.anchor) card.anchor = saved.anchor;
1451
+ if (saved.mode === 'side') {
1452
+ setMode(card, 'side');
1453
+ if (saved.sideDy !== null) card.sideDy = saved.sideDy;
1454
+ } else if (saved.mode === 'moved' && saved.moved) {
1455
+ setMode(card, 'moved');
1456
+ card.moved = saved.moved;
1457
+ }
1458
+ if (rememberedPointed === card.key) pointedKey = card.key;
1459
+ }
1460
+
1461
+ /**
1462
+ * Restore every remembered card that can open now: its layer registered and
1463
+ * the card it was opened from open. A card whose content is fetched (a
1464
+ * cross-page reference) opens when it arrives; its children follow it.
1465
+ */
1466
+ function restoreRemembered(): void {
1467
+ let revived = false;
1468
+ for (;;) {
1469
+ const ready = restorable(
1470
+ remembered,
1471
+ new Set(sources.map((source) => source.layer)),
1472
+ new Set(cards.keys()),
1473
+ );
1474
+ if (ready.length === 0) break;
1475
+ for (const saved of ready) {
1476
+ remembered = remembered.filter((other) => other !== saved);
1477
+ const source = sources.find((candidate) => candidate.layer === saved.layer);
1478
+ if (!source) continue;
1479
+ const parent = saved.parentKey ? cards.get(saved.parentKey) : undefined;
1480
+ if (saved.parentKey && !parent) continue;
1481
+ const triggers = occurrencesOf(source, new Set([saved.item]), parent?.el ?? null).get(
1482
+ saved.item,
1483
+ );
1484
+ // The same occurrence, or the first one if the page changed since.
1485
+ const trigger = triggers?.[saved.occurrence] ?? triggers?.[0];
1486
+ if (!trigger) continue;
1487
+ const hit: Hit = { source, trigger, item: saved.item, key: keyOf(saved) };
1488
+ const rendered = source.render(saved.item, trigger);
1489
+ if (rendered instanceof Promise) {
1490
+ rendered.then(
1491
+ (el) => {
1492
+ if (!el || cards.has(hit.key) || !trigger.isConnected) return;
1493
+ revive(hit, el, saved);
1494
+ arrange();
1495
+ restoreRemembered();
1496
+ },
1497
+ noop,
1498
+ );
1499
+ } else if (rendered) {
1500
+ revive(hit, rendered, saved);
1501
+ revived = true;
1502
+ }
1503
+ }
1504
+ }
1505
+ if (!revived) return;
1506
+ arrange();
1507
+ // A hovered card came back only for a kept child (C9); without one it
1508
+ // closes like any hovered card.
1509
+ for (const card of cards.values()) {
1510
+ if (card.mode === 'hover' && !hasChild(cards, card.key)) graceCheck(card);
1511
+ }
1512
+ }
1513
+
1514
+ function install(): void {
1515
+ if (layout) return;
1516
+ layout = watchCardLayout(reanchorAll);
1517
+ const layer = () => cardLayer(LAYER_VARIABLES);
1518
+ arrow = createArrowOverlay({
1519
+ layer,
1520
+ holdMs: CARD_CONSTANTS.arrowFadeMs,
1521
+ fadeMs: CARD_CONSTANTS.animationMs,
1522
+ });
1523
+ drag = installCardDrag(dragHost, {
1524
+ threshold: CARD_CONSTANTS.dragThreshold,
1525
+ snapDistance: CARD_CONSTANTS.snapDistance,
1526
+ snapRelease: CARD_CONSTANTS.snapRelease,
1527
+ snapHome: CARD_CONSTANTS.snapHome,
1528
+ step: CARD_CONSTANTS.keyboardStep,
1529
+ stepLarge: CARD_CONSTANTS.keyboardStepLarge,
1530
+ layer,
1531
+ });
1532
+ keepLeftToggle = createKeepLeftToggle(layer, (on) => {
1533
+ keepLeft = on;
1534
+ writeKeepLeft(on);
1535
+ arrange({ from: motionFrom() });
1536
+ });
1537
+ // S9: a reader who keeps the article left finds it there on every page.
1538
+ keepLeft = readKeepLeft();
1539
+ 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);
1545
+ document.addEventListener('visibilitychange', () => {
1546
+ if (document.visibilityState === 'hidden') rememberCards();
1547
+ });
1548
+ document.addEventListener('pointerover', onPointerOver);
1549
+ document.addEventListener('pointerout', onPointerOut);
1550
+ document.addEventListener('pointermove', onPointerMove, { passive: true });
1551
+ document.addEventListener('pointerdown', onPointerDown, true);
1552
+ document.addEventListener('focusin', onFocusIn);
1553
+ document.addEventListener('focusout', onFocusOut);
1554
+ // Capture, so a trigger's click is claimed before any other listener sees it.
1555
+ document.addEventListener('click', onClick, true);
1556
+ document.addEventListener('keydown', onKeyDown);
1557
+ window.addEventListener('scroll', interruptTravel, { capture: true, passive: true });
1558
+ window.addEventListener('resize', interruptTravel, { passive: true });
1559
+ }
1560
+
1561
+ /**
1562
+ * Hand a layer's triggers to the manager. A source registered again under
1563
+ * the same `layer` replaces the old one and closes its cards.
1564
+ */
1565
+ export function registerCardSource(source: CardSource): void {
1566
+ install();
1567
+ const at = sources.findIndex((existing) => existing.layer === source.layer);
1568
+ if (at === -1) {
1569
+ sources.push(source);
1570
+ } else {
1571
+ const previous = sources[at];
1572
+ for (const card of ordered().reverse()) if (card.source === previous) close(card);
1573
+ sources.splice(at, 1, source);
1574
+ }
1575
+ restoreRemembered();
1576
+ }
1577
+
1578
+ /**
1579
+ * Close every card only the pointer keeps open, leaving kept ones. A diagram
1580
+ * or table expanding over the page calls this, so a card it would cover
1581
+ * does not linger.
1582
+ */
1583
+ export function closeTransientCards(): void {
1584
+ endTravel();
1585
+ for (const [key, waiting] of pending) {
1586
+ if (waiting.mode === 'hover') pending.delete(key);
1587
+ }
1588
+ closeOutside(null);
1589
+ }