explico 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (279) hide show
  1. package/CHANGELOG.md +1122 -1
  2. package/README.md +91 -13
  3. package/dist/compiler/astro.d.ts +4 -3
  4. package/dist/compiler/astro.js +45 -15
  5. package/dist/compiler/cli.d.ts +26 -12
  6. package/dist/compiler/cli.js +96 -25
  7. package/dist/compiler/collections.d.ts +9 -1
  8. package/dist/compiler/collections.js +55 -16
  9. package/dist/compiler/content-schema.d.ts +31 -0
  10. package/dist/compiler/content-schema.js +82 -0
  11. package/dist/compiler/content-schemas.d.ts +422 -0
  12. package/dist/compiler/content-schemas.js +229 -0
  13. package/dist/compiler/course-config.d.ts +1 -1
  14. package/dist/compiler/course-config.js +30 -2
  15. package/dist/compiler/exports/combined-html.d.ts +97 -0
  16. package/dist/compiler/exports/combined-html.js +492 -0
  17. package/dist/compiler/exports/download-index.d.ts +9 -0
  18. package/dist/compiler/exports/download-index.js +33 -0
  19. package/dist/compiler/exports/export-chapters.d.ts +98 -0
  20. package/dist/compiler/exports/export-chapters.js +164 -0
  21. package/dist/compiler/exports/export-pages.d.ts +48 -0
  22. package/dist/compiler/exports/export-pages.js +22 -0
  23. package/dist/compiler/exports/html-tree.d.ts +22 -0
  24. package/dist/compiler/exports/html-tree.js +47 -0
  25. package/dist/compiler/exports/latex/capture.d.ts +106 -0
  26. package/dist/compiler/exports/latex/capture.js +435 -0
  27. package/dist/compiler/exports/latex/compile.d.ts +73 -0
  28. package/dist/compiler/exports/latex/compile.js +115 -0
  29. package/dist/compiler/exports/latex/document.d.ts +102 -0
  30. package/dist/compiler/exports/latex/document.js +349 -0
  31. package/dist/compiler/exports/latex/explico.cls +415 -0
  32. package/dist/compiler/exports/latex/numbering.d.ts +18 -0
  33. package/dist/compiler/exports/latex/numbering.js +57 -0
  34. package/dist/compiler/exports/latex/serialize.d.ts +112 -0
  35. package/dist/compiler/exports/latex/serialize.js +606 -0
  36. package/dist/compiler/exports/latex-export.d.ts +44 -0
  37. package/dist/compiler/exports/latex-export.js +169 -0
  38. package/dist/compiler/exports/markdown/serialize.d.ts +51 -0
  39. package/dist/compiler/exports/markdown/serialize.js +527 -0
  40. package/dist/compiler/exports/markdown-export.d.ts +39 -0
  41. package/dist/compiler/exports/markdown-export.js +240 -0
  42. package/dist/compiler/exports/standalone-html.d.ts +14 -5
  43. package/dist/compiler/exports/standalone-html.js +23 -51
  44. package/dist/compiler/exports/static-tree.d.ts +253 -0
  45. package/dist/compiler/exports/static-tree.js +625 -0
  46. package/dist/compiler/exports/zip.d.ts +16 -0
  47. package/dist/compiler/exports/zip.js +72 -0
  48. package/dist/compiler/lesson-derivation.d.ts +8 -2
  49. package/dist/compiler/lesson-derivation.js +6 -7
  50. package/dist/compiler/manifest.d.ts +87 -6
  51. package/dist/compiler/manifest.js +174 -31
  52. package/dist/compiler/render-diagnostics.d.ts +65 -0
  53. package/dist/compiler/render-diagnostics.js +56 -0
  54. package/dist/compiler/resolution-report.d.ts +2 -1
  55. package/dist/{course-config-Dsnri9Tk.d.ts → course-config-rRtME5UV.d.ts} +56 -13
  56. package/dist/curriculum/validation.d.ts +9 -3
  57. package/dist/curriculum/validation.js +35 -17
  58. package/dist/reference/base-abbreviations.d.mts +2 -2
  59. package/dist/reference/base-abbreviations.mjs +2 -2
  60. package/dist/reference/base-operators.d.mts +2 -2
  61. package/dist/reference/base-operators.mjs +2 -2
  62. package/dist/reference/base-roman-labels.d.mts +4 -3
  63. package/dist/reference/base-roman-labels.mjs +4 -3
  64. package/dist/reference/citation-format.d.mts +35 -0
  65. package/dist/reference/citation-format.mjs +74 -0
  66. package/dist/reference/consistency.d.ts +5 -9
  67. package/dist/reference/consistency.js +34 -38
  68. package/dist/reference/curriculum-alignment.js +3 -24
  69. package/dist/reference/derived-entries.d.ts +45 -0
  70. package/dist/reference/derived-entries.js +50 -0
  71. package/dist/reference/derived-notation.d.mts +125 -0
  72. package/dist/reference/derived-notation.mjs +121 -0
  73. package/dist/reference/diagram-fit.d.ts +45 -0
  74. package/dist/reference/diagram-fit.js +54 -0
  75. package/dist/reference/diagram-layout.d.ts +59 -10
  76. package/dist/reference/diagram-layout.js +85 -24
  77. package/dist/reference/diagrams-validate.d.ts +1 -1
  78. package/dist/reference/equations-validate.d.ts +1 -1
  79. package/dist/reference/equations-validate.js +26 -1
  80. package/dist/reference/equations.d.mts +27 -0
  81. package/dist/reference/equations.mjs +120 -22
  82. package/dist/reference/examples-validate.d.ts +1 -1
  83. package/dist/reference/figure-fit.d.ts +70 -0
  84. package/dist/reference/figure-fit.js +60 -0
  85. package/dist/reference/figure-pictures.d.ts +27 -0
  86. package/dist/reference/figure-pictures.js +49 -0
  87. package/dist/reference/figures-validate.d.ts +1 -1
  88. package/dist/reference/gate-abbreviations.d.ts +1 -1
  89. package/dist/reference/gate-abbreviations.js +1 -1
  90. package/dist/reference/gate-course-rules.d.ts +4 -3
  91. package/dist/reference/gate-course-rules.js +9 -4
  92. package/dist/reference/gate-heading-length.d.ts +2 -0
  93. package/dist/reference/gate-heading-length.js +7 -5
  94. package/dist/reference/gate-heading-structure.js +26 -0
  95. package/dist/reference/gate-indices.d.ts +35 -0
  96. package/dist/reference/gate-indices.js +299 -0
  97. package/dist/reference/gate-math.d.ts +32 -2
  98. package/dist/reference/gate-math.js +68 -12
  99. package/dist/reference/gate-mdx-expression.d.ts +17 -0
  100. package/dist/reference/gate-mdx-expression.js +29 -0
  101. package/dist/reference/gate-part-pages.d.ts +1 -1
  102. package/dist/reference/gate-prop-text.d.ts +28 -0
  103. package/dist/reference/gate-prop-text.js +106 -0
  104. package/dist/reference/gate-prose-notation.d.ts +3 -1
  105. package/dist/reference/gate-prose-notation.js +4 -2
  106. package/dist/reference/gate-retired-keys.d.ts +35 -0
  107. package/dist/reference/gate-retired-keys.js +58 -0
  108. package/dist/reference/gloss.d.ts +2 -0
  109. package/dist/reference/katex-measure.d.ts +17 -0
  110. package/dist/reference/katex-measure.js +223 -0
  111. package/dist/reference/keyed-float-validate.js +12 -0
  112. package/dist/reference/keyed-jsx-floats.d.mts +2 -2
  113. package/dist/reference/keyed-jsx-floats.mjs +11 -14
  114. package/dist/reference/lab-math-scope.d.ts +1 -0
  115. package/dist/reference/lab-math-scope.js +13 -2
  116. package/dist/reference/math-glyphs.d.mts +94 -2
  117. package/dist/reference/math-glyphs.mjs +851 -46
  118. package/dist/reference/mixed-content.d.ts +8 -3
  119. package/dist/reference/mixed-content.js +4 -2
  120. package/dist/reference/page-notation.d.ts +34 -4
  121. package/dist/reference/page-notation.js +54 -2
  122. package/dist/reference/references.d.ts +11 -1
  123. package/dist/reference/references.js +6 -0
  124. package/dist/reference/registry.js +104 -10
  125. package/dist/reference/rehype-fail-katex-errors.d.mts +9 -0
  126. package/dist/reference/rehype-fail-katex-errors.mjs +19 -0
  127. package/dist/reference/rehype-heading-anchors.mjs +11 -1
  128. package/dist/reference/remark-citation.mjs +0 -0
  129. package/dist/reference/remark-notation.mjs +84 -20
  130. package/dist/reference/render-lab-math.d.ts +2 -0
  131. package/dist/reference/render-lab-math.js +17 -5
  132. package/dist/reference/sections-validate.d.ts +28 -3
  133. package/dist/reference/sections-validate.js +31 -4
  134. package/dist/reference/sections.d.mts +24 -6
  135. package/dist/reference/sections.mjs +92 -17
  136. package/dist/reference/tables-validate.d.ts +1 -1
  137. package/dist/reference/tables-validate.js +24 -1
  138. package/dist/reference/text-measure.d.ts +10 -0
  139. package/dist/reference/text-measure.js +41 -0
  140. package/dist/reference/text-widths.d.ts +10 -0
  141. package/dist/reference/text-widths.js +1238 -0
  142. package/dist/reference/types.d.ts +64 -8
  143. package/dist/rules/catalog.js +191 -30
  144. package/dist/rules/in-effect.d.ts +1 -1
  145. package/dist/rules/rules.schema.json +79 -24
  146. package/dist/rules/settings.d.ts +1 -1
  147. package/dist/rules/settings.js +7 -0
  148. package/dist/session/PreferenceStore.d.ts +18 -1
  149. package/dist/session/PreferenceStore.js +29 -1
  150. package/docs/authoring/README.md +32 -21
  151. package/docs/authoring/agents.md +38 -7
  152. package/docs/authoring/components.md +111 -6
  153. package/docs/authoring/migrating.md +663 -0
  154. package/docs/authoring/notation.md +281 -29
  155. package/docs/authoring/retired.md +1 -0
  156. package/docs/authoring/structure-rules.md +10 -41
  157. package/docs/authoring/structure.md +53 -2
  158. package/docs/authoring/visualizations.md +144 -18
  159. package/package.json +12 -7
  160. package/src/compiler/astro.ts +57 -17
  161. package/src/compiler/cli.ts +143 -35
  162. package/src/compiler/collections.ts +92 -19
  163. package/src/compiler/content-schema.ts +108 -0
  164. package/src/compiler/content-schemas.ts +391 -0
  165. package/src/compiler/course-config.ts +86 -15
  166. package/src/compiler/exports/combined-html.ts +669 -0
  167. package/src/compiler/exports/download-index.ts +36 -0
  168. package/src/compiler/exports/export-chapters.ts +233 -0
  169. package/src/compiler/exports/export-pages.ts +44 -0
  170. package/src/compiler/exports/html-tree.ts +62 -0
  171. package/src/compiler/exports/latex/capture.ts +651 -0
  172. package/src/compiler/exports/latex/compile.ts +168 -0
  173. package/src/compiler/exports/latex/document.ts +479 -0
  174. package/src/compiler/exports/latex/explico.cls +415 -0
  175. package/src/compiler/exports/latex/numbering.ts +74 -0
  176. package/src/compiler/exports/latex/serialize.ts +787 -0
  177. package/src/compiler/exports/latex/texlive-packages.txt +76 -0
  178. package/src/compiler/exports/latex-export.ts +255 -0
  179. package/src/compiler/exports/markdown/serialize.ts +632 -0
  180. package/src/compiler/exports/markdown-export.ts +329 -0
  181. package/src/compiler/exports/standalone-html.ts +39 -70
  182. package/src/compiler/exports/static-tree.ts +944 -0
  183. package/src/compiler/exports/zip.ts +88 -0
  184. package/src/compiler/lesson-derivation.ts +13 -8
  185. package/src/compiler/manifest.ts +299 -28
  186. package/src/compiler/render-diagnostics.ts +111 -0
  187. package/src/components/HoverPanel.astro +204 -6
  188. package/src/components/PartOverview.astro +12 -9
  189. package/src/components/abbreviation/AbbreviationLayer.astro +21 -12
  190. package/src/components/assessments/AssessmentRunner.tsx +7 -1
  191. package/src/components/assessments/AssessmentSet.astro +14 -1
  192. package/src/components/block-heading.ts +38 -0
  193. package/src/components/card-arrow.ts +18 -4
  194. package/src/components/card-carry.ts +406 -0
  195. package/src/components/card-drag.ts +1 -30
  196. package/src/components/card-fetch.ts +87 -0
  197. package/src/components/card-manager.ts +1222 -38
  198. package/src/components/card-memory.ts +5 -1
  199. package/src/components/card-placement.ts +211 -13
  200. package/src/components/card-side.ts +281 -31
  201. package/src/components/citation/CitationLayer.astro +45 -40
  202. package/src/components/diagram/Diagram.astro +123 -44
  203. package/src/components/figure/Figure.astro +11 -0
  204. package/src/components/figure/FloatCaption.astro +4 -17
  205. package/src/components/float-toolbar.ts +274 -0
  206. package/src/components/memo/Memo.astro +21 -10
  207. package/src/components/memo/ShortNote.astro +4 -0
  208. package/src/components/notation/NotationGlossary.astro +22 -17
  209. package/src/components/notation/NotationLayer.astro +300 -79
  210. package/src/components/notation/NotationMath.astro +8 -1
  211. package/src/components/notation/render-notation-math.ts +51 -5
  212. package/src/components/readable-text.ts +25 -0
  213. package/src/components/reference/FloatRefLayer.astro +162 -89
  214. package/src/components/starlight/HeadingAnchorEnhancer.astro +13 -8
  215. package/src/components/starlight/KeyedFloatEnhancer.astro +28 -9
  216. package/src/components/starlight/LayoutPageSidebar.astro +17 -3
  217. package/src/components/starlight/LayoutSocialIcons.astro +79 -29
  218. package/src/components/starlight/LessonExportActions.astro +384 -310
  219. package/src/components/starlight/LessonFooter.astro +47 -9
  220. package/src/components/starlight/PrintController.astro +28 -0
  221. package/src/components/starlight/TextSizeControl.astro +137 -0
  222. package/src/components/starlight/print-controller.ts +380 -0
  223. package/src/components/starlight/section-summary.ts +13 -5
  224. package/src/components/table/Table.astro +13 -21
  225. package/src/components/table/table-truncation.ts +15 -0
  226. package/src/content-config.ts +20 -312
  227. package/src/curriculum/validation.ts +72 -24
  228. package/src/reference/base-abbreviations.mjs +2 -2
  229. package/src/reference/base-operators.mjs +2 -2
  230. package/src/reference/base-roman-labels.mjs +4 -3
  231. package/src/reference/citation-format.mjs +74 -0
  232. package/src/reference/consistency.ts +47 -61
  233. package/src/reference/curriculum-alignment.ts +4 -41
  234. package/src/reference/derived-entries.ts +68 -0
  235. package/src/reference/derived-notation.mjs +121 -0
  236. package/src/reference/diagram-fit.ts +104 -0
  237. package/src/reference/diagram-layout.ts +182 -51
  238. package/src/reference/diagrams-validate.ts +2 -1
  239. package/src/reference/equations-validate.ts +41 -2
  240. package/src/reference/equations.mjs +120 -22
  241. package/src/reference/examples-validate.ts +1 -0
  242. package/src/reference/figure-fit.ts +139 -0
  243. package/src/reference/figure-pictures.ts +96 -0
  244. package/src/reference/figures-validate.ts +2 -1
  245. package/src/reference/gate-abbreviations.ts +1 -1
  246. package/src/reference/gate-course-rules.ts +22 -7
  247. package/src/reference/gate-heading-length.ts +25 -7
  248. package/src/reference/gate-heading-structure.ts +40 -1
  249. package/src/reference/gate-indices.ts +470 -0
  250. package/src/reference/gate-math.ts +102 -13
  251. package/src/reference/gate-mdx-expression.ts +45 -0
  252. package/src/reference/gate-prop-text.ts +145 -0
  253. package/src/reference/gate-prose-notation.ts +19 -5
  254. package/src/reference/gate-retired-keys.ts +101 -0
  255. package/src/reference/gloss.ts +2 -0
  256. package/src/reference/katex-measure.ts +384 -0
  257. package/src/reference/keyed-float-validate.ts +22 -0
  258. package/src/reference/keyed-jsx-floats.mjs +11 -14
  259. package/src/reference/lab-math-scope.ts +15 -1
  260. package/src/reference/math-glyphs.mjs +851 -46
  261. package/src/reference/mixed-content.ts +11 -2
  262. package/src/reference/page-notation.ts +100 -3
  263. package/src/reference/references.ts +14 -0
  264. package/src/reference/registry.ts +154 -8
  265. package/src/reference/rehype-fail-katex-errors.mjs +19 -0
  266. package/src/reference/rehype-heading-anchors.mjs +11 -1
  267. package/src/reference/remark-citation.mjs +0 -0
  268. package/src/reference/remark-notation.mjs +84 -20
  269. package/src/reference/render-lab-math.ts +29 -5
  270. package/src/reference/sections-validate.ts +67 -3
  271. package/src/reference/sections.mjs +92 -17
  272. package/src/reference/tables-validate.ts +40 -2
  273. package/src/reference/text-measure.ts +63 -0
  274. package/src/reference/text-widths.ts +133 -0
  275. package/src/reference/types.ts +75 -8
  276. package/src/rules/catalog.ts +205 -29
  277. package/src/rules/settings.ts +10 -0
  278. package/src/session/PreferenceStore.ts +53 -0
  279. package/src/styles/global.css +291 -75
@@ -39,6 +39,8 @@ export interface RememberedCard {
39
39
  readonly moved: MovedSpot | null;
40
40
  /** S2, D3: a docked card's top against its trigger's line. */
41
41
  readonly sideDy: number | null;
42
+ /** S11: a card's own width, from its edge; absent or `null` takes the column's. */
43
+ readonly width?: number | null;
42
44
  }
43
45
 
44
46
  export interface RememberedPage {
@@ -80,7 +82,8 @@ function decodeMoved(value: unknown): MovedSpot | null {
80
82
  return { x: value.x, y: value.y };
81
83
  }
82
84
 
83
- function decodeCard(value: unknown): RememberedCard | null {
85
+ /** One remembered card, checked field by field; `null` for anything malformed. */
86
+ export function decodeCard(value: unknown): RememberedCard | null {
84
87
  if (!isRecord(value)) return null;
85
88
  const { layer, item, occurrence, parentKey, mode } = value;
86
89
  if (typeof layer !== 'string' || layer === '' || typeof item !== 'string' || item === '') {
@@ -101,6 +104,7 @@ function decodeCard(value: unknown): RememberedCard | null {
101
104
  anchor: decodeAnchor(value.anchor),
102
105
  moved: mode === 'moved' ? moved : null,
103
106
  sideDy: mode === 'side' && isNumber(value.sideDy) ? value.sideDy : null,
107
+ ...(isNumber(value.width) && value.width > 0 ? { width: value.width } : {}),
104
108
  };
105
109
  }
106
110
 
@@ -10,11 +10,13 @@
10
10
  * trigger scrolls (`scrollContainersOf`).
11
11
  *
12
12
  * P2. A card takes the first free spot in a fixed order, judged against the
13
- * window below the sticky header at open time and against every other open
14
- * card (P6). `placeCard` is that decision as a pure function over plain
15
- * boxes, so `tests/unit/card-placement.test.ts` runs it in Node; the rest of
16
- * this module measures the page and applies the answer.
13
+ * window below the sticky header and between the side panels at open time,
14
+ * and against every other open card (P6). `placeCard` is that decision as a
15
+ * pure function over plain boxes, so `tests/unit/card-placement.test.ts` runs
16
+ * it in Node; the rest of this module measures the page and applies the
17
+ * answer.
17
18
  */
19
+ import { sidebarsBetween } from './card-side';
18
20
  import type { Box } from './card-travel';
19
21
 
20
22
  export type CardSide = 'below' | 'above';
@@ -27,7 +29,10 @@ export interface SideSpot {
27
29
  }
28
30
 
29
31
  export interface PlacementInput {
30
- /** The part of the window a card must fit: below the sticky header, inset by the window edge. */
32
+ /**
33
+ * The part of the window a card must fit: below the sticky header and
34
+ * between the side panels, inset by the window edge.
35
+ */
31
36
  readonly band: Box;
32
37
  /** The article column's horizontal extent, or `null` when the page has none. */
33
38
  readonly column: { readonly left: number; readonly right: number } | null;
@@ -233,6 +238,175 @@ export function placeCard(input: PlacementInput): Placement {
233
238
  return result(cover(first), preferred, 6);
234
239
  }
235
240
 
241
+ // ---------------------------------------------------------------------------
242
+ // K2–K4: kept cards held at the window's edges while the reader carries them.
243
+ // ---------------------------------------------------------------------------
244
+
245
+ /** K2: the window edge a carried card is held at. */
246
+ export type HoldEdge = 'top' | 'bottom';
247
+
248
+ export interface HoldCard {
249
+ readonly key: string;
250
+ /**
251
+ * Where the card is with the page (P1), in the viewport, at its full
252
+ * height: next to its trigger, in the side column, or where it was moved.
253
+ */
254
+ readonly box: Box;
255
+ /** K3: all a collapsed card keeps, its header row. */
256
+ readonly headerHeight: number;
257
+ /** K3: when it opened; the older card sits nearer the edge. */
258
+ readonly order: number;
259
+ /** K3: the lowest collapses first: the card's order, or newer once the reader expanded it. */
260
+ readonly rank: number;
261
+ /** K5: a card from another page has no place on this one; it is held at this edge whatever its box. */
262
+ readonly edge?: HoldEdge | null;
263
+ }
264
+
265
+ export interface HoldBand {
266
+ /** The window under the header, inset by the window edge. */
267
+ readonly top: number;
268
+ readonly bottom: number;
269
+ /** Things fixed at the top edge, which a held card over them stacks under: the side controls row. */
270
+ readonly blocks?: readonly Box[];
271
+ }
272
+
273
+ export interface Held {
274
+ readonly edge: HoldEdge;
275
+ /** The card's top, in the viewport. */
276
+ readonly top: number;
277
+ /** K3: shown as its header row only. */
278
+ readonly collapsed: boolean;
279
+ /** K3: the height a card is cut to when its stack has nothing left to collapse; `null` keeps its own. */
280
+ readonly maxHeight: number | null;
281
+ }
282
+
283
+ /** Whether two boxes are over each other: they share some width, wherever they are vertically. */
284
+ const overX = (a: Box, b: Box): boolean => a.left < b.right - EPSILON && a.right > b.left + EPSILON;
285
+
286
+ const ascending = (a: number, b: number): number => (a < b ? -1 : a > b ? 1 : 0);
287
+
288
+ /**
289
+ * K2–K4: which kept cards are held at an edge of the window, and where.
290
+ *
291
+ * K2. A card scrolls with the page until its top passes the top edge (under
292
+ * the header, or under a block fixed there, `band.blocks`), or its bottom
293
+ * the bottom edge; then it stays at that edge, until its place with the page
294
+ * comes back inside the window and it follows the page again. A card from
295
+ * another page (`edge`) is always held.
296
+ *
297
+ * K3. Cards at one edge stack outward from it in the order they were opened,
298
+ * only cards that are over each other: a docked card and one in the article
299
+ * each keep their own column. A card reaches the edge once its place passes
300
+ * the stack already there, with every card in it at its full height, so
301
+ * collapsing never changes which cards are held. When the cards held over
302
+ * each other at both edges are taller than the window, the lowest-ranked
303
+ * collapse to their header row, one by one, until they fit or one is left
304
+ * open; that one is cut to the room left.
305
+ *
306
+ * Returns the held cards only; a card not in the map stays with the page.
307
+ */
308
+ export function heldAt(
309
+ cards: readonly HoldCard[],
310
+ band: HoldBand,
311
+ gap: number,
312
+ ): Map<string, Held> {
313
+ const blocks = band.blocks ?? [];
314
+ const ceiling = (card: HoldCard) =>
315
+ blocks
316
+ .filter((block) => overX(block, card.box))
317
+ .reduce((top, block) => Math.max(top, block.bottom + gap), band.top);
318
+ const full = (card: HoldCard) => card.box.bottom - card.box.top;
319
+ /** The room the stack already at an edge takes over `card`, every card at its full height. */
320
+ const taken = (stack: readonly HoldCard[], card: HoldCard) =>
321
+ stack
322
+ .filter((other) => overX(other.box, card.box))
323
+ .reduce((sum, other) => sum + full(other) + gap, 0);
324
+
325
+ // K2: the top edge first, the card nearest it first, so each joins the
326
+ // stack its predecessors made.
327
+ const topKey = (card: HoldCard) =>
328
+ card.edge === 'top' ? Number.NEGATIVE_INFINITY : card.box.top;
329
+ const top: HoldCard[] = [];
330
+ for (const card of cards
331
+ .filter((candidate) => candidate.edge !== 'bottom')
332
+ .sort((a, b) => ascending(topKey(a), topKey(b)) || a.order - b.order)) {
333
+ if (card.edge === 'top' || card.box.top < ceiling(card) + taken(top, card) - EPSILON) {
334
+ top.push(card);
335
+ }
336
+ }
337
+ const atTop = new Set(top);
338
+ const bottomKey = (card: HoldCard) =>
339
+ card.edge === 'bottom' ? Number.POSITIVE_INFINITY : card.box.bottom;
340
+ const bottom: HoldCard[] = [];
341
+ for (const card of cards
342
+ .filter((candidate) => !atTop.has(candidate) && candidate.edge !== 'top')
343
+ .sort((a, b) => ascending(bottomKey(b), bottomKey(a)) || a.order - b.order)) {
344
+ if (card.edge === 'bottom' || card.box.bottom > band.bottom - taken(bottom, card) + EPSILON) {
345
+ bottom.push(card);
346
+ }
347
+ }
348
+
349
+ // K3: collapse the lowest-ranked card over a crowded one, until none is
350
+ // crowded or each crowded stack has one card left open.
351
+ const held = [...top, ...bottom];
352
+ const collapsed = new Set<string>();
353
+ const shown = (card: HoldCard) => (collapsed.has(card.key) ? card.headerHeight : full(card));
354
+ const load = (card: HoldCard) =>
355
+ held
356
+ .filter((other) => overX(other.box, card.box))
357
+ .reduce((sum, other) => sum + shown(other) + gap, -gap);
358
+ const room = (card: HoldCard) => band.bottom - ceiling(card);
359
+ for (let progress = true; progress; ) {
360
+ progress = false;
361
+ for (const card of held) {
362
+ if (load(card) <= room(card) + EPSILON) continue;
363
+ const open = held.filter((other) => overX(other.box, card.box) && !collapsed.has(other.key));
364
+ if (open.length < 2) continue;
365
+ const newest = open.reduce((a, b) => (b.rank > a.rank ? b : a));
366
+ const oldest = open
367
+ .filter((other) => other !== newest)
368
+ .reduce((a, b) => (b.rank < a.rank ? b : a));
369
+ collapsed.add(oldest.key);
370
+ progress = true;
371
+ break;
372
+ }
373
+ }
374
+ // The one card left open in a stack that is still too tall is cut to fit.
375
+ const caps = new Map<string, number>();
376
+ for (const card of held) {
377
+ const over = load(card) - room(card);
378
+ if (over <= EPSILON) continue;
379
+ for (const open of held.filter((other) => overX(other.box, card.box) && !collapsed.has(other.key))) {
380
+ const cap = Math.max(open.headerHeight, full(open) - over);
381
+ caps.set(open.key, Math.min(caps.get(open.key) ?? Number.POSITIVE_INFINITY, cap));
382
+ }
383
+ }
384
+ const height = (card: HoldCard) => caps.get(card.key) ?? shown(card);
385
+
386
+ const result = new Map<string, Held>();
387
+ const stack = (members: readonly HoldCard[], edge: HoldEdge) => {
388
+ const placed: { readonly box: Box; readonly top: number; readonly bottom: number }[] = [];
389
+ for (const card of [...members].sort((a, b) => a.order - b.order)) {
390
+ const h = height(card);
391
+ const under = placed.filter((other) => overX(other.box, card.box));
392
+ const at =
393
+ edge === 'top'
394
+ ? Math.max(ceiling(card), ...under.map((other) => other.bottom + gap))
395
+ : Math.min(band.bottom, ...under.map((other) => other.top - gap)) - h;
396
+ placed.push({ box: card.box, top: at, bottom: at + h });
397
+ result.set(card.key, {
398
+ edge,
399
+ top: at,
400
+ collapsed: collapsed.has(card.key),
401
+ maxHeight: caps.get(card.key) ?? null,
402
+ });
403
+ }
404
+ };
405
+ stack(top, 'top');
406
+ stack(bottom, 'bottom');
407
+ return result;
408
+ }
409
+
236
410
  // ---------------------------------------------------------------------------
237
411
  // The DOM layer.
238
412
  // ---------------------------------------------------------------------------
@@ -268,19 +442,33 @@ export interface PlaceOptions {
268
442
  readonly sideColumn?: PlacementInput['sideColumn'];
269
443
  }
270
444
 
271
- /** The safe region R a card must not cover: the enclosing display equation, else the trigger. */
445
+ /**
446
+ * The safe region R a card must not cover: the enclosing display equation,
447
+ * the whole float when the card hangs from the float's own number (its
448
+ * toolbar's Pin, `float-toolbar.ts`), else the trigger.
449
+ */
272
450
  export const regionOf = (trigger: HTMLElement): HTMLElement =>
273
- trigger.closest<HTMLElement>('.katex-display') ?? trigger;
451
+ trigger.closest<HTMLElement>('.katex-display') ??
452
+ (trigger.matches('a.keyed-equation__number, a.float-caption__number')
453
+ ? trigger.closest<HTMLElement>('.keyed-equation, figure')
454
+ : null) ??
455
+ trigger;
274
456
 
275
- /** The window below the sticky header, inset by `edge`, in viewport coordinates. */
457
+ /**
458
+ * The window below the sticky header and between the side panels, inset by
459
+ * `edge`, in viewport coordinates. A card wider than the article column
460
+ * centres in this room, not in the whole window: the navigation and the rail
461
+ * paint over the card layer, so a card reaching under one lost its edge.
462
+ */
276
463
  export function cardBand(edge: number): Box {
277
464
  const header = document.querySelector<HTMLElement>('.header');
278
465
  const headerBottom = header ? header.getBoundingClientRect().bottom : 0;
279
466
  const root = document.documentElement;
467
+ const { left, right } = sidebarsBetween(edge);
280
468
  return {
281
- left: edge,
469
+ left,
282
470
  top: Math.max(edge, headerBottom + edge),
283
- right: root.clientWidth - edge,
471
+ right: Math.max(left, right),
284
472
  bottom: root.clientHeight - edge,
285
473
  };
286
474
  }
@@ -354,11 +542,20 @@ export function placeElement(
354
542
  ): CardAnchor {
355
543
  const band = cardBand(options.edge);
356
544
  const bandHeight = Math.max(band.bottom - band.top, 0);
545
+ const bandWidth = Math.max(band.right - band.left, 0);
357
546
  // Measure with all the room the window offers, then keep whatever part of
358
- // it the chosen spot allows.
547
+ // it the chosen spot allows. Only a card wider than the room between the
548
+ // side panels is narrowed to it, and measured again as it wraps: an inline
549
+ // cap on every card would replace the stylesheet's own (a reference card's
550
+ // `80ch`) and let a card grow past it.
359
551
  el.style.maxHeight = `${bandHeight}px`;
552
+ el.style.maxWidth = '';
360
553
  moveTo(el, band.left, band.top);
361
- const size = el.getBoundingClientRect();
554
+ let size = el.getBoundingClientRect();
555
+ if (size.width > bandWidth + EPSILON) {
556
+ el.style.maxWidth = `${bandWidth}px`;
557
+ size = el.getBoundingClientRect();
558
+ }
362
559
  const region = regionOf(trigger).getBoundingClientRect();
363
560
  const triggerBox = trigger.getBoundingClientRect();
364
561
 
@@ -426,7 +623,8 @@ export function anchoredSpot(
426
623
  /**
427
624
  * Put `el` back at its anchor after the layout moved its trigger (P1). A
428
625
  * trigger with no box (inside a closed disclosure) leaves the card where it
429
- * is. Returns whether the trigger could be measured.
626
+ * is — `card-manager.ts`'s `foldAway` hides a kept one meanwhile. Returns
627
+ * whether the trigger could be measured.
430
628
  */
431
629
  export function reanchorElement(
432
630
  el: HTMLElement,
@@ -176,6 +176,53 @@ export function layoutColumn(
176
176
  return tops;
177
177
  }
178
178
 
179
+ /** S11: the reader's width for docked cards, from the width menu. */
180
+ export type SideWidthPreset = 'default' | 'wide' | 'very-wide';
181
+
182
+ export interface SideWidthOption {
183
+ readonly id: SideWidthPreset;
184
+ readonly label: string;
185
+ /** Characters a line of the card's text holds; `null` for the default, the column's own width. */
186
+ readonly characters: number | null;
187
+ }
188
+
189
+ /** S11: Default is today's width (S1); the others count characters, so they follow the text size. */
190
+ export const SIDE_WIDTH_OPTIONS: readonly SideWidthOption[] = [
191
+ { id: 'default', label: 'Default', characters: null },
192
+ { id: 'wide', label: 'Wide', characters: 65 },
193
+ { id: 'very-wide', label: 'Very wide', characters: 90 },
194
+ ];
195
+
196
+ export interface ClosedRoomInput {
197
+ readonly windowWidth: number;
198
+ /** A collapsed side panel's strip. */
199
+ readonly strip: number;
200
+ /** The article's own start padding, kept when it is slid fully left. */
201
+ readonly contentPad: number;
202
+ readonly articleWidth: number;
203
+ /** S1: from the article to a side card, and from a side card to the rail. */
204
+ readonly gap: number;
205
+ }
206
+
207
+ /** S11: the room beside the article with both side panels collapsed to their strips and the article fully left. */
208
+ export const roomWithPanelsClosed = (input: ClosedRoomInput): number =>
209
+ input.windowWidth - input.strip - input.contentPad - input.articleWidth - 2 * input.gap - input.strip;
210
+
211
+ /**
212
+ * S11: the sizes worth offering: Default always, a wider one only when it
213
+ * fits that room, so a reader who collapses both panels gets the whole
214
+ * card. With only Default left, the menu is not shown at all.
215
+ */
216
+ export function offeredWidths(
217
+ widths: ReadonlyMap<SideWidthPreset, number>,
218
+ roomClosed: number,
219
+ ): SideWidthPreset[] {
220
+ return SIDE_WIDTH_OPTIONS.filter(
221
+ (option) =>
222
+ option.id === 'default' || (widths.get(option.id) ?? Infinity) <= roomClosed + EPSILON,
223
+ ).map((option) => option.id);
224
+ }
225
+
179
226
  // ---------------------------------------------------------------------------
180
227
  // The DOM layer.
181
228
  // ---------------------------------------------------------------------------
@@ -186,6 +233,9 @@ const SLID_ATTRIBUTE = 'data-article-slid';
186
233
  const START_PROPERTY = '--explico-article-start';
187
234
  /** S9, remembered across pages and visits. */
188
235
  const KEEP_LEFT_KEY = 'explico:layout:keep-article-left';
236
+ /** S11: the width menu's choice, and whether the reader has resized a card, which offers the menu. */
237
+ const SIDE_WIDTH_KEY = 'explico:layout:side-card-width';
238
+ const SIDE_WIDTH_OFFERED_KEY = 'explico:layout:side-card-width-offered';
189
239
  /** S7: below this there is never a side column, the same breakpoint Starlight drops the rail at. */
190
240
  const SIDE_QUERY = '(min-width: 72rem)';
191
241
 
@@ -241,6 +291,38 @@ export function railLeftEdge(): number {
241
291
  return rect.width > 0 ? rect.left : windowRight;
242
292
  }
243
293
 
294
+ /**
295
+ * The navigation's right edge: collapsed, its edge strip's, since the panel
296
+ * itself widens over the article while it peeks. `null` without a visible
297
+ * navigation (a phone, a page without one).
298
+ */
299
+ function navigationRightEdge(): number | null {
300
+ const pane = document.querySelector<HTMLElement>('.sidebar-pane');
301
+ if (!pane || pane.getClientRects().length === 0) return null;
302
+ if (getComputedStyle(pane).visibility === 'hidden') return null;
303
+ const rect = pane.getBoundingClientRect();
304
+ if (rect.width === 0) return null;
305
+ if (root().hasAttribute('data-navigation-hidden')) {
306
+ const strip = getComputedStyle(root()).getPropertyValue('--layout-edge-rail-width').trim();
307
+ const width = strip ? remToPx(strip) : Number.NaN;
308
+ if (Number.isFinite(width) && width > 0) return rect.left + width;
309
+ }
310
+ return rect.right;
311
+ }
312
+
313
+ /**
314
+ * D2, P2: the room between the navigation and the rail, each `edge` px away,
315
+ * in viewport x. A placed card stays in it as a moved one does, so neither
316
+ * side panel paints over part of a card.
317
+ */
318
+ export function sidebarsBetween(edge: number): { readonly left: number; readonly right: number } {
319
+ const nav = navigationRightEdge();
320
+ return {
321
+ left: (nav ?? 0) + edge,
322
+ right: railLeftEdge() - edge,
323
+ };
324
+ }
325
+
244
326
  /**
245
327
  * S1, S3, S7: the article and the rail, measured with the article at rest.
246
328
  * `null` when the layout can never have a side column: below 72rem, a
@@ -288,49 +370,217 @@ export const readKeepLeft = (): boolean => preferences.get(KEEP_LEFT_KEY) === 't
288
370
 
289
371
  export const writeKeepLeft = (on: boolean): void => preferences.set(KEEP_LEFT_KEY, String(on));
290
372
 
291
- export interface KeepLeftToggle {
292
- /** Show the toggle at the top of the side column (`left`, in viewport x), or hide it. */
293
- update(state: { readonly visible: boolean; readonly on: boolean; readonly left: number }): void;
373
+ /** S11: the width menu's choice; Default until the reader picks another. */
374
+ export function readSideWidth(): SideWidthPreset {
375
+ const stored = preferences.get(SIDE_WIDTH_KEY);
376
+ return SIDE_WIDTH_OPTIONS.some((option) => option.id === stored)
377
+ ? (stored as SideWidthPreset)
378
+ : 'default';
379
+ }
380
+
381
+ export const writeSideWidth = (preset: SideWidthPreset): void =>
382
+ preferences.set(SIDE_WIDTH_KEY, preset);
383
+
384
+ /** S11: the menu is offered once the reader has resized a card by its edge. */
385
+ export const readSideWidthOffered = (): boolean =>
386
+ preferences.get(SIDE_WIDTH_OFFERED_KEY) === 'true';
387
+
388
+ export const writeSideWidthOffered = (): void => preferences.set(SIDE_WIDTH_OFFERED_KEY, 'true');
389
+
390
+ /**
391
+ * S11: a size in pixels for each option, measured in the card's own text:
392
+ * Default is `defaultWidth`, the others their characters plus the card's
393
+ * padding and border.
394
+ */
395
+ export function sideWidthPixels(layer: HTMLElement, defaultWidth: number): Map<SideWidthPreset, number> {
396
+ const probe = document.createElement('span');
397
+ probe.style.cssText =
398
+ 'position:absolute;visibility:hidden;width:100ch;font-size:max(12px,var(--sl-text-sm))';
399
+ layer.append(probe);
400
+ const ch = probe.getBoundingClientRect().width / 100;
401
+ probe.remove();
402
+ const chrome = remToPx('2rem') + 2;
403
+ const widths = new Map<SideWidthPreset, number>();
404
+ for (const option of SIDE_WIDTH_OPTIONS) {
405
+ widths.set(
406
+ option.id,
407
+ option.characters === null ? defaultWidth : Math.round(option.characters * ch + chrome),
408
+ );
409
+ }
410
+ return widths;
411
+ }
412
+
413
+ /** S11: the room the width menu offers sizes for, with this page's panels and article. */
414
+ export function closedRoom(gap: number): number {
415
+ const article = articleOf();
416
+ if (!article) return 0;
417
+ const style = getComputedStyle(root());
418
+ const strip = remToPx(style.getPropertyValue('--layout-edge-rail-width').trim() || '2.5rem');
419
+ const contentPad = remToPx(style.getPropertyValue('--sl-content-pad-x').trim() || '1.5rem');
420
+ return roomWithPanelsClosed({
421
+ windowWidth: root().clientWidth,
422
+ strip,
423
+ contentPad,
424
+ articleWidth: article.getBoundingClientRect().width,
425
+ gap,
426
+ });
427
+ }
428
+
429
+ export interface SideControlsState {
430
+ /** Show the row, or hide it. */
431
+ readonly visible: boolean;
432
+ /**
433
+ * Where the row sits, in viewport x: at the side column's left edge while
434
+ * the column is in use, else with its right edge `right` px from the
435
+ * window's, at the article's top right (K1, D7).
436
+ */
437
+ readonly at: { readonly left: number } | { readonly right: number };
438
+ /** S9: "Article left". */
439
+ readonly keepLeft: { readonly visible: boolean; readonly on: boolean };
440
+ /** K1: "Carry cards", while a card is kept. */
441
+ readonly carry: { readonly visible: boolean; readonly on: boolean };
442
+ /** S11: the width menu, with the sizes it offers; `null` hides it. */
443
+ readonly width: {
444
+ readonly offered: readonly SideWidthPreset[];
445
+ readonly value: SideWidthPreset | 'custom';
446
+ /** Each size's pixels, for its tooltip. */
447
+ readonly characters: ReadonlyMap<SideWidthPreset, number | null>;
448
+ } | null;
449
+ }
450
+
451
+ export interface SideControls {
452
+ update(state: SideControlsState): void;
453
+ /** K2: the row's box while it shows; a card held at the top edge under it stacks below it. */
454
+ box(): DOMRect | null;
455
+ }
456
+
457
+ export interface SideControlsHandlers {
458
+ onKeepLeft(on: boolean): void;
459
+ onCarry(on: boolean): void;
460
+ onWidth(preset: SideWidthPreset): void;
461
+ }
462
+
463
+ /** A switch: a knob and a short label, its full sentence in the tooltip (D6). */
464
+ function createSwitch(label: string, title: string, onToggle: (on: boolean) => void): HTMLButtonElement {
465
+ const button = document.createElement('button');
466
+ button.type = 'button';
467
+ button.className = 'explico-side-switch';
468
+ button.title = title;
469
+ button.setAttribute('aria-pressed', 'false');
470
+ const knob = document.createElement('span');
471
+ knob.className = 'explico-side-switch-knob';
472
+ knob.setAttribute('aria-hidden', 'true');
473
+ const text = document.createElement('span');
474
+ text.textContent = label;
475
+ button.append(knob, text);
476
+ button.addEventListener('click', () => onToggle(button.getAttribute('aria-pressed') !== 'true'));
477
+ return button;
294
478
  }
295
479
 
296
480
  /**
297
- * S9: "Keep the article left", a small toggle at the top of the side column,
298
- * fixed just under the site header. It mounts right after the card layer,
481
+ * The row of card controls, fixed just under the site header: at the top of
482
+ * the side column, "Article left" (S9), "Carry cards" (K1) and, once the
483
+ * reader has resized a card, the width menu (S11); with no side column in
484
+ * use, "Carry cards" alone at the article's top right, while a card is kept
485
+ * (D7). Short labels, so the row fits a 1920 px window's column; each
486
+ * control's tooltip says it in full. It mounts right after the card layer,
299
487
  * so it sits over the cards scrolling past and under the header.
300
488
  */
301
- export function createKeepLeftToggle(
489
+ export function createSideControls(
302
490
  after: () => HTMLElement,
303
- onToggle: (on: boolean) => void,
304
- ): KeepLeftToggle {
305
- let button: HTMLButtonElement | null = null;
491
+ handlers: SideControlsHandlers,
492
+ ): SideControls {
493
+ let row: HTMLElement | null = null;
494
+ let keepLeft: HTMLButtonElement | null = null;
495
+ let carry: HTMLButtonElement | null = null;
496
+ let width: HTMLLabelElement | null = null;
497
+ let select: HTMLSelectElement | null = null;
306
498
 
307
499
  const build = () => {
308
- const el = document.createElement('button');
309
- el.type = 'button';
310
- el.className = 'explico-keep-left';
311
- el.dataset.keepArticleLeft = '';
312
- el.title = 'Keep the article on the left after the last side card closes';
313
- el.setAttribute('aria-pressed', 'false');
314
- const knob = document.createElement('span');
315
- knob.className = 'explico-keep-left-switch';
316
- knob.setAttribute('aria-hidden', 'true');
317
- const label = document.createElement('span');
318
- label.textContent = 'Keep the article left';
319
- el.append(knob, label);
320
- el.addEventListener('click', () => onToggle(el.getAttribute('aria-pressed') !== 'true'));
321
- return el;
500
+ row = document.createElement('div');
501
+ row.className = 'explico-side-controls';
502
+ row.hidden = true;
503
+
504
+ keepLeft = createSwitch(
505
+ 'Article left',
506
+ 'Keep the article on the left after the last side card closes',
507
+ handlers.onKeepLeft,
508
+ );
509
+ keepLeft.dataset.keepArticleLeft = '';
510
+ carry = createSwitch(
511
+ 'Carry cards',
512
+ 'Keep the cards you pin on screen as you scroll, and bring them along to every page of the course',
513
+ handlers.onCarry,
514
+ );
515
+ carry.dataset.carryCards = '';
516
+
517
+ width = document.createElement('label');
518
+ width.className = 'explico-side-width';
519
+ width.hidden = true;
520
+ const caption = document.createElement('span');
521
+ caption.textContent = 'Width';
522
+ select = document.createElement('select');
523
+ select.dataset.sideCardWidth = '';
524
+ select.setAttribute('aria-label', 'Width of the cards beside the article');
525
+ select.addEventListener('change', () => {
526
+ const value = select?.value;
527
+ if (SIDE_WIDTH_OPTIONS.some((option) => option.id === value)) {
528
+ handlers.onWidth(value as SideWidthPreset);
529
+ }
530
+ });
531
+ width.append(caption, select);
532
+
533
+ row.append(keepLeft, carry, width);
534
+ after().after(row);
322
535
  };
323
536
 
324
537
  return {
325
- update({ visible, on, left }) {
326
- if (!visible && !button) return;
327
- if (!button?.isConnected) {
328
- button = build();
329
- after().after(button);
538
+ box() {
539
+ return row?.isConnected && !row.hidden ? row.getBoundingClientRect() : null;
540
+ },
541
+ update(state) {
542
+ if (!state.visible && !row) return;
543
+ if (!row?.isConnected) build();
544
+ if (!row || !keepLeft || !carry || !width || !select) return;
545
+ row.hidden = !state.visible;
546
+ if (!state.visible) return;
547
+ const { at } = state;
548
+ row.style.left = 'left' in at ? `${Math.round(at.left)}px` : '';
549
+ row.style.right = 'right' in at ? `${Math.round(at.right)}px` : '';
550
+ row.dataset.at = 'left' in at ? 'column' : 'article';
551
+ keepLeft.hidden = !state.keepLeft.visible;
552
+ keepLeft.setAttribute('aria-pressed', String(state.keepLeft.on));
553
+ carry.hidden = !state.carry.visible;
554
+ carry.setAttribute('aria-pressed', String(state.carry.on));
555
+ const menu = state.width;
556
+ width.hidden = !menu;
557
+ if (!menu) return;
558
+ const options = SIDE_WIDTH_OPTIONS.filter((option) => menu.offered.includes(option.id));
559
+ const wanted = [
560
+ ...options.map((option) => [option.id, option.label] as const),
561
+ ...(menu.value === 'custom' ? [['custom', 'Custom'] as const] : []),
562
+ ];
563
+ const current = Array.from(select.options, (option) => `${option.value}=${option.text}`).join('|');
564
+ const next = wanted.map(([value, text]) => `${value}=${text}`).join('|');
565
+ if (current !== next) {
566
+ select.replaceChildren(
567
+ ...wanted.map(([value, text]) => {
568
+ const option = document.createElement('option');
569
+ option.value = value;
570
+ option.textContent = text;
571
+ if (value === 'custom') option.disabled = true;
572
+ return option;
573
+ }),
574
+ );
330
575
  }
331
- button.hidden = !visible;
332
- button.setAttribute('aria-pressed', String(on));
333
- if (visible) button.style.left = `${Math.round(left)}px`;
576
+ select.value = menu.value;
577
+ const characters = menu.value === 'custom' ? null : menu.characters.get(menu.value);
578
+ width.title =
579
+ menu.value === 'custom'
580
+ ? 'A card here has a width of its own, from its edge; pick a size for every card'
581
+ : characters
582
+ ? `Cards beside the article hold ${characters} characters a line`
583
+ : 'Cards beside the article at their usual width';
334
584
  },
335
585
  };
336
586
  }