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,631 @@
1
+ /**
2
+ * Dragging a card by its grip, and snapping it into the side column (rules
3
+ * D1–D7 of `docs/plans/hover-card-consistency.md`).
4
+ *
5
+ * The grip at the left of a card's header is the only handle, so the card's
6
+ * text stays selectable. A drag starts after a few pixels of movement and
7
+ * pins the card. Dropped in the snap zone — its left edge near the article's
8
+ * right edge — the card docks in the side column at the drop height;
9
+ * dropped near its anchored spot, it goes back next to its trigger; dropped
10
+ * anywhere else, it stays there as a Moved card, which keeps its place
11
+ * relative to the article column and its trigger's line and never covers a
12
+ * sidebar. The grip is also a button: arrow keys move the card, Home brings
13
+ * it back. Escape during a drag cancels it.
14
+ *
15
+ * The decisions are pure functions over plain numbers and boxes (`snaps`,
16
+ * `dropOutcome`, `keepBetween`, `movedSpot`, `movedAt`, `nudgeSpot`), so
17
+ * `tests/unit/card-drag.test.ts` checks them in Node. The DOM half follows
18
+ * the pointer (`installCardDrag`) and draws the docking area, the article's
19
+ * edge and the landing outline (`createDockPreview`). The card manager
20
+ * (`./card-manager.ts`) is the `DragHost`: it owns the cards' modes and
21
+ * where they go once dropped.
22
+ */
23
+ import { moveTo } from './card-placement';
24
+ import { headerBottom, railLeftEdge, remToPx } from './card-side';
25
+ import type { Box, Point } from './card-travel';
26
+
27
+ const clamp = (value: number, low: number, high: number) =>
28
+ Math.max(low, Math.min(value, high));
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // The decisions, pure.
32
+ // ---------------------------------------------------------------------------
33
+
34
+ /** D1: whether the pointer has moved far enough from where it went down to start a drag. */
35
+ export const pastThreshold = (from: Point, to: Point, threshold: number): boolean =>
36
+ Math.hypot(to.x - from.x, to.y - from.y) >= threshold;
37
+
38
+ export interface SnapInput {
39
+ /**
40
+ * The card's left edge where the pointer takes it, before it is kept
41
+ * between the sidebars: a card held against the rail can still reach the
42
+ * zone when the article has to slide to make room.
43
+ */
44
+ readonly left: number;
45
+ /** The article's right text edge now, or `null` when the window has no side column (S7). */
46
+ readonly edge: number | null;
47
+ /** Whether the card is snapped already, during this drag (D4). */
48
+ readonly snapped: boolean;
49
+ /** Alt or Option held: no snapping (D4). */
50
+ readonly altKey: boolean;
51
+ /** D3: how near the article's edge the card's left edge snaps. */
52
+ readonly distance: number;
53
+ /** D4: how far it must go again to let go once snapped. */
54
+ readonly release: number;
55
+ }
56
+
57
+ /** D3, D4: whether the card is in the snap zone, with hysteresis. */
58
+ export function snaps(input: SnapInput): boolean {
59
+ if (input.altKey || input.edge === null) return false;
60
+ const off = Math.abs(input.left - input.edge);
61
+ return off <= (input.snapped ? input.release : input.distance);
62
+ }
63
+
64
+ export type DropOutcome =
65
+ | { readonly kind: 'side'; readonly top: number }
66
+ | { readonly kind: 'home' }
67
+ | { readonly kind: 'moved'; readonly left: number; readonly top: number };
68
+
69
+ export interface DropInput {
70
+ /** Where the card is dropped: its top-left corner. */
71
+ readonly at: Point;
72
+ /** Whether it is snapped into the side column (`snaps`). */
73
+ readonly snapped: boolean;
74
+ /** Its anchored spot next to its trigger, or `null` when it has none. */
75
+ readonly home: Point | null;
76
+ /** Alt or Option held: no snapping of either kind. */
77
+ readonly altKey: boolean;
78
+ /** D5: how near its anchored spot a drop goes back there. */
79
+ readonly homeDistance: number;
80
+ }
81
+
82
+ /**
83
+ * D3, D5, D2: a snapped card docks at the drop height; one dropped within
84
+ * reach of its anchored spot goes back next to its trigger; any other drop
85
+ * leaves it where it is, Moved.
86
+ */
87
+ export function dropOutcome(input: DropInput): DropOutcome {
88
+ const { at, home } = input;
89
+ if (input.snapped) return { kind: 'side', top: at.y };
90
+ if (
91
+ !input.altKey &&
92
+ home &&
93
+ Math.hypot(at.x - home.x, at.y - home.y) <= input.homeDistance
94
+ ) {
95
+ return { kind: 'home' };
96
+ }
97
+ return { kind: 'moved', left: at.x, top: at.y };
98
+ }
99
+
100
+ /** The room between the sidebars a card is kept in, in viewport x. */
101
+ export interface Between {
102
+ readonly left: number;
103
+ readonly right: number;
104
+ }
105
+
106
+ /** D2: a card's left edge, kept between the sidebars; a card wider than the room sits at its left. */
107
+ export const keepBetween = (left: number, width: number, between: Between): number =>
108
+ clamp(left, between.left, Math.max(between.left, between.right - width));
109
+
110
+ /** Where a card may go during a drag, in the viewport. */
111
+ export interface DragBounds extends Between {
112
+ /** Below the sticky header. */
113
+ readonly top: number;
114
+ /** The window's bottom edge, less the window edge. */
115
+ readonly bottom: number;
116
+ }
117
+
118
+ /**
119
+ * D1, D2: the card's spot while the pointer drags it, from where the card and
120
+ * the pointer started. It is kept between the sidebars, and its grip row
121
+ * (`grab` px tall) stays below the header and inside the window, so the card
122
+ * can always be picked up again.
123
+ */
124
+ export function dragSpot(
125
+ card: Point,
126
+ from: Point,
127
+ to: Point,
128
+ width: number,
129
+ bounds: DragBounds,
130
+ grab: number,
131
+ ): { readonly free: Point; readonly kept: Point } {
132
+ const free = { x: card.x + to.x - from.x, y: card.y + to.y - from.y };
133
+ return {
134
+ free,
135
+ kept: {
136
+ x: keepBetween(free.x, width, bounds),
137
+ y: clamp(free.y, bounds.top, Math.max(bounds.top, bounds.bottom - grab)),
138
+ },
139
+ };
140
+ }
141
+
142
+ /**
143
+ * D6: the card's spot after one arrow-key step. Like a drag, a step never
144
+ * takes it over a sidebar, nor its grip under the header or out of the
145
+ * window; a card already past an edge (scrolled under the header) is never
146
+ * pushed further out, nor pulled in by a step away from that edge.
147
+ */
148
+ export function nudgeSpot(
149
+ card: Point,
150
+ by: Point,
151
+ width: number,
152
+ bounds: DragBounds,
153
+ grab: number,
154
+ ): Point {
155
+ const low = Math.min(card.y, bounds.top);
156
+ const high = Math.max(card.y, bounds.bottom - grab);
157
+ return {
158
+ x: keepBetween(card.x + by.x, width, bounds),
159
+ y: clamp(card.y + by.y, low, high),
160
+ };
161
+ }
162
+
163
+ /** D6: the step an arrow key moves a card, or `null` for any other key. */
164
+ export function keyboardStep(
165
+ key: string,
166
+ large: boolean,
167
+ steps: { readonly small: number; readonly large: number },
168
+ ): Point | null {
169
+ const size = large ? steps.large : steps.small;
170
+ switch (key) {
171
+ case 'ArrowLeft':
172
+ return { x: -size, y: 0 };
173
+ case 'ArrowRight':
174
+ return { x: size, y: 0 };
175
+ case 'ArrowUp':
176
+ return { x: 0, y: -size };
177
+ case 'ArrowDown':
178
+ return { x: 0, y: size };
179
+ default:
180
+ return null;
181
+ }
182
+ }
183
+
184
+ /**
185
+ * D2: a Moved card's place — its left edge from the article column's left
186
+ * edge, its top from its trigger's line — so it scrolls with the page and
187
+ * follows its trigger when the text reflows.
188
+ */
189
+ export interface MovedSpot {
190
+ readonly x: number;
191
+ readonly y: number;
192
+ }
193
+
194
+ /** D2: a card at `at` (its top-left corner), stored against the article column and its trigger's line. */
195
+ export const movedSpot = (at: Point, columnLeft: number, lineTop: number): MovedSpot => ({
196
+ x: at.x - columnLeft,
197
+ y: at.y - lineTop,
198
+ });
199
+
200
+ /** D2: where a Moved card goes, for the article column and its trigger's line where they are now. */
201
+ export const movedAt = (spot: MovedSpot, columnLeft: number, lineTop: number): Point => ({
202
+ x: columnLeft + spot.x,
203
+ y: lineTop + spot.y,
204
+ });
205
+
206
+ // ---------------------------------------------------------------------------
207
+ // The DOM layer.
208
+ // ---------------------------------------------------------------------------
209
+
210
+ /**
211
+ * The navigation's right edge: collapsed, its edge strip's, since the panel
212
+ * itself widens over the article while it peeks. `null` without a visible
213
+ * navigation (a phone, a page without one).
214
+ */
215
+ function navigationRightEdge(): number | null {
216
+ const pane = document.querySelector<HTMLElement>('.sidebar-pane');
217
+ if (!pane || pane.getClientRects().length === 0) return null;
218
+ if (getComputedStyle(pane).visibility === 'hidden') return null;
219
+ const rect = pane.getBoundingClientRect();
220
+ if (rect.width === 0) return null;
221
+ const root = document.documentElement;
222
+ if (root.hasAttribute('data-navigation-hidden')) {
223
+ const strip = getComputedStyle(root).getPropertyValue('--layout-edge-rail-width').trim();
224
+ const width = strip ? remToPx(strip) : Number.NaN;
225
+ if (Number.isFinite(width) && width > 0) return rect.left + width;
226
+ }
227
+ return rect.right;
228
+ }
229
+
230
+ /** D2: the room between the navigation and the rail, each `edge` px away, in viewport x. */
231
+ export function sidebarsBetween(edge: number): Between {
232
+ const nav = navigationRightEdge();
233
+ return {
234
+ left: (nav ?? 0) + edge,
235
+ right: railLeftEdge() - edge,
236
+ };
237
+ }
238
+
239
+ /** D1, D2: where a dragged card may go: between the sidebars, below the header, inside the window. */
240
+ export function dragBounds(edge: number): DragBounds {
241
+ return {
242
+ ...sidebarsBetween(edge),
243
+ top: Math.max(edge, headerBottom() + edge),
244
+ bottom: document.documentElement.clientHeight - edge,
245
+ };
246
+ }
247
+
248
+ export interface DockPreviewState {
249
+ /** D3: the docking area, tinted from a drag's first movement; `null` without a side column. */
250
+ readonly area: Between | null;
251
+ /** D3: the article's edge, dashed while the card is snapped. */
252
+ readonly edge: number | null;
253
+ /** D3: the dashed outline of where a snapped card lands. */
254
+ readonly landing: Box | null;
255
+ }
256
+
257
+ export interface DockPreview {
258
+ show(state: DockPreviewState): void;
259
+ hide(): void;
260
+ }
261
+
262
+ /** "Release to pin to the side" (D3). */
263
+ const RELEASE_LABEL = 'Release to pin to the side';
264
+
265
+ /**
266
+ * D3: the docking area's tint, the dashed line on the article's edge, the
267
+ * landing outline and its label. They are fixed to the window at the bottom
268
+ * of the card layer: over the article, under every card, and never in the
269
+ * way of a click.
270
+ */
271
+ export function createDockPreview(layer: () => HTMLElement): DockPreview {
272
+ let root: HTMLElement | null = null;
273
+ let area: HTMLElement | null = null;
274
+ let edge: HTMLElement | null = null;
275
+ let outline: HTMLElement | null = null;
276
+ let label: HTMLElement | null = null;
277
+
278
+ const part = (name: string) => {
279
+ const el = document.createElement('div');
280
+ el.className = `explico-card-dock-${name}`;
281
+ el.hidden = true;
282
+ return el;
283
+ };
284
+
285
+ const mount = () => {
286
+ if (!root) {
287
+ root = document.createElement('div');
288
+ root.className = 'explico-card-dock';
289
+ root.dataset.cardDock = '';
290
+ root.setAttribute('aria-hidden', 'true');
291
+ area = part('area');
292
+ edge = part('edge');
293
+ outline = part('outline');
294
+ label = part('label');
295
+ label.textContent = RELEASE_LABEL;
296
+ root.append(area, edge, outline, label);
297
+ }
298
+ const host = layer();
299
+ if (root.parentElement !== host) host.prepend(root);
300
+ return root;
301
+ };
302
+
303
+ const top = () => Math.max(0, headerBottom());
304
+
305
+ return {
306
+ show(state) {
307
+ mount();
308
+ if (!area || !edge || !outline || !label) return;
309
+ area.hidden = !state.area;
310
+ if (state.area) {
311
+ area.style.left = `${Math.round(state.area.left)}px`;
312
+ area.style.width = `${Math.max(0, Math.round(state.area.right - state.area.left))}px`;
313
+ area.style.top = `${Math.round(top())}px`;
314
+ }
315
+ edge.hidden = state.edge === null;
316
+ if (state.edge !== null) {
317
+ edge.style.left = `${Math.round(state.edge)}px`;
318
+ edge.style.top = `${Math.round(top())}px`;
319
+ }
320
+ const landing = state.landing;
321
+ outline.hidden = !landing;
322
+ label.hidden = !landing;
323
+ root?.toggleAttribute('data-snapped', !!landing);
324
+ if (landing) {
325
+ outline.style.left = `${Math.round(landing.left)}px`;
326
+ outline.style.top = `${Math.round(landing.top)}px`;
327
+ outline.style.width = `${Math.round(landing.right - landing.left)}px`;
328
+ outline.style.height = `${Math.round(landing.bottom - landing.top)}px`;
329
+ // Under the outline, or over its top when it reaches the window's bottom.
330
+ const height = label.offsetHeight || 24;
331
+ const below = landing.bottom + 8;
332
+ const room = document.documentElement.clientHeight - 8;
333
+ label.style.left = `${Math.round((landing.left + landing.right) / 2)}px`;
334
+ label.style.top = `${Math.round(below + height <= room ? below : landing.top + 8)}px`;
335
+ }
336
+ },
337
+ hide() {
338
+ root?.remove();
339
+ root?.removeAttribute('data-snapped');
340
+ for (const el of [area, edge, outline, label]) if (el) el.hidden = true;
341
+ },
342
+ };
343
+ }
344
+
345
+ /** What a drag needs from the page, measured once when it starts. */
346
+ export interface DragFrame {
347
+ /** Where the card may go (D1, D2). */
348
+ readonly bounds: DragBounds;
349
+ /** The article's right text edge now, or `null` when the window has no side column (D3, S7). */
350
+ readonly edge: number | null;
351
+ /** The docking area: 16 px right of the article to 16 px left of the rail (D3). */
352
+ readonly area: Between | null;
353
+ /** The card's anchored spot next to its trigger, its top-left corner (D5). */
354
+ readonly home: Point | null;
355
+ }
356
+
357
+ /** The card manager's side of a drag. Every card is the card element a grip sits in. */
358
+ export interface DragHost {
359
+ /** The open card a grip belongs to, or `null`. */
360
+ cardOf(grip: HTMLElement): HTMLElement | null;
361
+ /** D1, D2: the drag starts — pin the card — and say where it may go; `null` refuses it. */
362
+ begin(card: HTMLElement): DragFrame | null;
363
+ /** D3: where a snapped card with its top at `top` lands, in the viewport. */
364
+ landing(card: HTMLElement, top: number): Box | null;
365
+ /** The card is dropped: dock it, send it home, or leave it where it is. */
366
+ drop(card: HTMLElement, outcome: DropOutcome): void;
367
+ /** D7: the drag was cancelled; the card is back where it started. */
368
+ cancel(card: HTMLElement): void;
369
+ /** D6: an arrow key on the grip. */
370
+ nudge(card: HTMLElement, by: Point): void;
371
+ /** D6: Home on the grip. */
372
+ home(card: HTMLElement): void;
373
+ }
374
+
375
+ export interface CardDragOptions {
376
+ /** D1: movement before a drag starts (px). */
377
+ readonly threshold: number;
378
+ /** D3, D4 (px). */
379
+ readonly snapDistance: number;
380
+ readonly snapRelease: number;
381
+ /** D5 (px). */
382
+ readonly snapHome: number;
383
+ /** D6 (px). */
384
+ readonly step: number;
385
+ readonly stepLarge: number;
386
+ /** The card layer, where the preview mounts. */
387
+ layer(): HTMLElement;
388
+ }
389
+
390
+ export interface CardDrag {
391
+ /** The card being dragged now, if any. */
392
+ readonly active: HTMLElement | null;
393
+ /** End a drag without a drop or a cancel: the card it moved is closing. */
394
+ abort(): void;
395
+ }
396
+
397
+ /** Set on `<html>` for the length of a drag: no text selection, a grabbing cursor. */
398
+ const DRAGGING_ATTRIBUTE = 'data-card-dragging';
399
+
400
+ interface Session {
401
+ readonly pointerId: number;
402
+ readonly grip: HTMLElement;
403
+ readonly card: HTMLElement;
404
+ /** The pointer where it went down. */
405
+ readonly from: Point;
406
+ /** The card's top-left corner then, in the viewport. */
407
+ readonly start: Point;
408
+ /** The card's inline position then, restored on cancel. */
409
+ readonly style: { readonly left: string; readonly top: string };
410
+ readonly width: number;
411
+ /** The grip row's height: what must stay inside the window. */
412
+ readonly grab: number;
413
+ frame: DragFrame | null;
414
+ pointer: Point;
415
+ altKey: boolean;
416
+ snapped: boolean;
417
+ at: Point;
418
+ }
419
+
420
+ /**
421
+ * D1–D7: follow the pointer from a card's grip (`[data-card-grip]`), and the
422
+ * grip's arrow keys and Home. One set of document listeners serves every
423
+ * card.
424
+ */
425
+ export function installCardDrag(host: DragHost, options: CardDragOptions): CardDrag {
426
+ const preview = createDockPreview(options.layer);
427
+ let session: Session | null = null;
428
+
429
+ const gripOf = (node: EventTarget | null): HTMLElement | null =>
430
+ node instanceof Element ? node.closest<HTMLElement>('[data-card-grip]') : null;
431
+
432
+ const finish = () => {
433
+ const ending = session;
434
+ session = null;
435
+ preview.hide();
436
+ document.documentElement.removeAttribute(DRAGGING_ATTRIBUTE);
437
+ window.removeEventListener('scroll', onScroll, true);
438
+ if (!ending) return;
439
+ ending.card.removeAttribute('data-card-dragged');
440
+ if (ending.grip.hasPointerCapture?.(ending.pointerId)) {
441
+ ending.grip.releasePointerCapture(ending.pointerId);
442
+ }
443
+ };
444
+
445
+ /** Put the card where the pointer takes it, and show what a drop there would do. */
446
+ const follow = (current: Session) => {
447
+ const { frame } = current;
448
+ if (!frame) return;
449
+ const { free, kept } = dragSpot(
450
+ current.start,
451
+ current.from,
452
+ current.pointer,
453
+ current.width,
454
+ frame.bounds,
455
+ current.grab,
456
+ );
457
+ current.snapped = snaps({
458
+ left: free.x,
459
+ edge: frame.edge,
460
+ snapped: current.snapped,
461
+ altKey: current.altKey,
462
+ distance: options.snapDistance,
463
+ release: options.snapRelease,
464
+ });
465
+ current.at = kept;
466
+ moveTo(current.card, kept.x, kept.y);
467
+ preview.show({
468
+ area: frame.area,
469
+ edge: current.snapped ? frame.edge : null,
470
+ landing: current.snapped ? host.landing(current.card, kept.y) : null,
471
+ });
472
+ };
473
+
474
+ /** D1: past the threshold, the drag starts. */
475
+ const begin = (current: Session): boolean => {
476
+ const frame = host.begin(current.card);
477
+ if (!frame) {
478
+ finish();
479
+ return false;
480
+ }
481
+ current.frame = frame;
482
+ document.documentElement.setAttribute(DRAGGING_ATTRIBUTE, '');
483
+ current.card.setAttribute('data-card-dragged', '');
484
+ // A wheel turned mid-drag scrolls the page under the card: keep it at the pointer.
485
+ window.addEventListener('scroll', onScroll, { capture: true, passive: true });
486
+ return true;
487
+ };
488
+
489
+ /** D7: back where the drag started. */
490
+ const cancel = (current: Session) => {
491
+ const started = current.frame !== null;
492
+ current.card.style.left = current.style.left;
493
+ current.card.style.top = current.style.top;
494
+ finish();
495
+ if (started) host.cancel(current.card);
496
+ };
497
+
498
+ function onScroll() {
499
+ if (session?.frame) follow(session);
500
+ }
501
+
502
+ const onPointerDown = (event: PointerEvent) => {
503
+ const grip = gripOf(event.target);
504
+ if (!grip || event.button !== 0 || !event.isPrimary) return;
505
+ const card = host.cardOf(grip);
506
+ if (!card) return;
507
+ // No text selection, no focus ring from the mouse: the grip is a handle.
508
+ event.preventDefault();
509
+ if (session) cancel(session);
510
+ const rect = card.getBoundingClientRect();
511
+ const gripRect = grip.getBoundingClientRect();
512
+ const point = { x: event.clientX, y: event.clientY };
513
+ session = {
514
+ pointerId: event.pointerId,
515
+ grip,
516
+ card,
517
+ from: point,
518
+ start: { x: rect.left, y: rect.top },
519
+ style: { left: card.style.left, top: card.style.top },
520
+ width: rect.width,
521
+ grab: Math.max(gripRect.bottom - rect.top, 1),
522
+ frame: null,
523
+ pointer: point,
524
+ altKey: event.altKey,
525
+ snapped: false,
526
+ at: { x: rect.left, y: rect.top },
527
+ };
528
+ try {
529
+ grip.setPointerCapture(event.pointerId);
530
+ } catch {
531
+ // A pointer that is already gone: the drag simply never starts.
532
+ }
533
+ };
534
+
535
+ const onPointerMove = (event: PointerEvent) => {
536
+ const current = session;
537
+ if (!current || event.pointerId !== current.pointerId) return;
538
+ current.pointer = { x: event.clientX, y: event.clientY };
539
+ current.altKey = event.altKey;
540
+ if (!current.frame) {
541
+ if (!pastThreshold(current.from, current.pointer, options.threshold)) return;
542
+ if (!begin(current)) return;
543
+ }
544
+ event.preventDefault();
545
+ follow(current);
546
+ };
547
+
548
+ const onPointerUp = (event: PointerEvent) => {
549
+ const current = session;
550
+ if (!current || event.pointerId !== current.pointerId) return;
551
+ if (!current.frame) {
552
+ finish(); // a click on the grip moves nothing
553
+ return;
554
+ }
555
+ current.pointer = { x: event.clientX, y: event.clientY };
556
+ current.altKey = event.altKey;
557
+ follow(current);
558
+ const outcome = dropOutcome({
559
+ at: current.at,
560
+ snapped: current.snapped,
561
+ home: current.frame.home,
562
+ altKey: current.altKey,
563
+ homeDistance: options.snapHome,
564
+ });
565
+ finish();
566
+ host.drop(current.card, outcome);
567
+ };
568
+
569
+ const onPointerCancel = (event: PointerEvent) => {
570
+ if (session && event.pointerId === session.pointerId) cancel(session);
571
+ };
572
+
573
+ const onLostCapture = (event: PointerEvent) => {
574
+ // After a drop the session is already gone; any other loss ends the drag.
575
+ if (session && event.pointerId === session.pointerId) cancel(session);
576
+ };
577
+
578
+ const onKeyDown = (event: KeyboardEvent) => {
579
+ const current = session;
580
+ if (current) {
581
+ if (event.key === 'Escape') {
582
+ // D7: this press cancels the drag and closes nothing (C10 waits for the next).
583
+ event.preventDefault();
584
+ event.stopPropagation();
585
+ cancel(current);
586
+ } else if (event.key === 'Alt' && current.frame) {
587
+ current.altKey = true;
588
+ follow(current);
589
+ }
590
+ return;
591
+ }
592
+ const grip = gripOf(event.target);
593
+ if (!grip || event.ctrlKey || event.metaKey || event.altKey) return;
594
+ const card = host.cardOf(grip);
595
+ if (!card) return;
596
+ if (event.key === 'Home') {
597
+ event.preventDefault();
598
+ host.home(card);
599
+ return;
600
+ }
601
+ const by = keyboardStep(event.key, event.shiftKey, {
602
+ small: options.step,
603
+ large: options.stepLarge,
604
+ });
605
+ if (!by) return;
606
+ event.preventDefault();
607
+ host.nudge(card, by);
608
+ };
609
+
610
+ const onKeyUp = (event: KeyboardEvent) => {
611
+ if (event.key !== 'Alt' || !session?.frame) return;
612
+ session.altKey = false;
613
+ follow(session);
614
+ };
615
+
616
+ document.addEventListener('pointerdown', onPointerDown, true);
617
+ document.addEventListener('pointermove', onPointerMove, true);
618
+ document.addEventListener('pointerup', onPointerUp, true);
619
+ document.addEventListener('pointercancel', onPointerCancel, true);
620
+ document.addEventListener('lostpointercapture', onLostCapture, true);
621
+ // The window's capture phase runs before the card manager's Escape (C10).
622
+ window.addEventListener('keydown', onKeyDown, true);
623
+ window.addEventListener('keyup', onKeyUp, true);
624
+
625
+ return {
626
+ get active() {
627
+ return session?.frame ? session.card : null;
628
+ },
629
+ abort: finish,
630
+ };
631
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * The arrow that ends an explanation card's "Go to …" link, from where the
3
+ * link lands: ↑ or ↓ for a place on this page, above or below the middle of
4
+ * the reader's view; → for another page of the course; ↗ only for a link
5
+ * that leaves the site. ↗ used to end every one of them, although only a
6
+ * citation's source ever leaves the site.
7
+ *
8
+ * The float, notation, citation and abbreviation cards mark the arrow with
9
+ * `[data-jump-arrow]` inside the link and call `wireJumpArrow` once its
10
+ * `href` is set. The arrow is worked out again whenever the reader points at
11
+ * or focuses the link, since a kept card outlives the scroll position it was
12
+ * opened at.
13
+ */
14
+
15
+ const trimPath = (path: string) => path.replace(/\/index\.html$/, '/').replace(/\/+$/, '');
16
+
17
+ export function jumpArrow(href: string): '↑' | '↓' | '→' | '↗' {
18
+ let url: URL;
19
+ try {
20
+ url = new URL(href, window.location.href);
21
+ } catch {
22
+ return '→';
23
+ }
24
+ if (url.origin !== window.location.origin) return '↗';
25
+ if (trimPath(url.pathname) !== trimPath(window.location.pathname)) return '→';
26
+ const id = decodeURIComponent(url.hash.slice(1));
27
+ const target = id ? document.getElementById(id) : null;
28
+ if (!target) return '↓';
29
+ const rect = target.getBoundingClientRect();
30
+ return rect.top + rect.height / 2 < window.innerHeight / 2 ? '↑' : '↓';
31
+ }
32
+
33
+ /** Set `link`'s `[data-jump-arrow]` from its `href`, and keep it current. */
34
+ export function wireJumpArrow(link: HTMLAnchorElement): void {
35
+ const arrow = link.querySelector<HTMLElement>('[data-jump-arrow]');
36
+ if (!arrow) return;
37
+ const update = () => {
38
+ arrow.textContent = ` ${jumpArrow(link.getAttribute('href') ?? '')}`;
39
+ };
40
+ update();
41
+ if (link.dataset.jumpArrowWired === 'true') return;
42
+ link.dataset.jumpArrowWired = 'true';
43
+ link.addEventListener('pointerenter', update);
44
+ link.addEventListener('focus', update);
45
+ }