explico 0.8.0 → 0.11.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 (230) hide show
  1. package/CHANGELOG.md +1239 -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 +2 -2
  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 +1096 -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 +1471 -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
@@ -14,27 +14,53 @@ import DefaultHeader from '@astrojs/starlight/components/Header.astro';
14
14
  {
15
15
  attribute: string;
16
16
  storageKey: string;
17
+ legacyStorageKey: string;
18
+ widthStorageKey: string;
19
+ widthProperty: string;
20
+ resizedAttribute: string;
17
21
  peekSuppressedAttribute: string;
22
+ peekArmedAttribute: string;
18
23
  panelSelector: string;
24
+ /** The layout this panel's width belongs to; outside it, nothing to set. */
25
+ query: string;
26
+ /** Which way a drag widens the panel, before the page's own direction. */
27
+ grows: 1 | -1;
19
28
  shownLabel: string;
20
29
  hiddenLabel: string;
30
+ widthLabel: string;
21
31
  }
22
32
  > = {
23
33
  navigation: {
24
34
  attribute: 'data-navigation-hidden',
25
- storageKey: 'credit-playground:layout:navigation-hidden',
35
+ storageKey: 'explico:layout:navigation-hidden',
36
+ legacyStorageKey: 'credit-playground:layout:navigation-hidden',
37
+ widthStorageKey: 'explico:layout:navigation-width',
38
+ widthProperty: '--sl-sidebar-width',
39
+ resizedAttribute: 'data-navigation-resized',
26
40
  peekSuppressedAttribute: 'data-navigation-peek-suppressed',
41
+ peekArmedAttribute: 'data-navigation-peek-armed',
27
42
  panelSelector: '.sidebar-pane',
43
+ query: '(min-width: 50rem)',
44
+ grows: 1,
28
45
  shownLabel: 'Hide navigation',
29
46
  hiddenLabel: 'Show navigation',
47
+ widthLabel: 'Navigation width',
30
48
  },
31
49
  contents: {
32
50
  attribute: 'data-page-sidebar-hidden',
33
- storageKey: 'credit-playground:layout:contents-hidden',
51
+ storageKey: 'explico:layout:contents-hidden',
52
+ legacyStorageKey: 'credit-playground:layout:contents-hidden',
53
+ widthStorageKey: 'explico:layout:contents-width',
54
+ widthProperty: '--layout-contents-width',
55
+ resizedAttribute: 'data-contents-resized',
34
56
  peekSuppressedAttribute: 'data-page-sidebar-peek-suppressed',
57
+ peekArmedAttribute: 'data-page-sidebar-peek-armed',
35
58
  panelSelector: '.right-sidebar',
59
+ query: '(min-width: 72rem)',
60
+ grows: -1,
36
61
  shownLabel: 'Hide page contents',
37
62
  hiddenLabel: 'Show page contents',
63
+ widthLabel: 'Page contents width',
38
64
  },
39
65
  };
40
66
 
@@ -55,10 +81,11 @@ import DefaultHeader from '@astrojs/starlight/components/Header.astro';
55
81
  * Starlight's own formula already gave it.
56
82
  *
57
83
  * This doesn't close a loop back on itself: `.right-sidebar`'s own width
58
- * is edge-anchored and shrink-to-fit against its own content, not
59
- * against how much room `.main-pane` leaves it (that dependency is
60
- * exactly what the edge anchor removed), so measuring it here doesn't
61
- * need to react to anything this function itself changes.
84
+ * is edge-anchored and shrink-to-fit against its own content (or, once the
85
+ * reader has set one, exactly the width they set), not against how much
86
+ * room `.main-pane` leaves it — that dependency is exactly what the edge
87
+ * anchor removed — so measuring it here doesn't need to react to anything
88
+ * this function itself changes.
62
89
  */
63
90
  function updateTocReservation(): void {
64
91
  const panel = document.querySelector<HTMLElement>('.right-sidebar');
@@ -78,14 +105,22 @@ import DefaultHeader from '@astrojs/starlight/components/Header.astro';
78
105
  // PreferenceStore and nothing here changes.
79
106
  const preferences = createBrowserPreferenceStore();
80
107
 
81
- function readPreference(storageKey: string): boolean {
82
- return preferences.get(storageKey) === 'true';
108
+ function readPreference(region: LayoutRegion): boolean {
109
+ const settings = regions[region];
110
+ const stored =
111
+ preferences.get(settings.storageKey) ??
112
+ // Written under a name this engine has outgrown; honour it once so a
113
+ // returning reader keeps the layout they left.
114
+ preferences.get(settings.legacyStorageKey);
115
+ return stored === 'true';
83
116
  }
84
117
 
85
118
  function writePreference(storageKey: string, hidden: boolean): void {
86
119
  preferences.set(storageKey, String(hidden));
87
120
  }
88
121
 
122
+ const toggles = new Map<LayoutRegion, HTMLButtonElement>();
123
+
89
124
  function applyState(
90
125
  button: HTMLButtonElement,
91
126
  region: LayoutRegion,
@@ -100,14 +135,40 @@ import DefaultHeader from '@astrojs/starlight/components/Header.astro';
100
135
  button.title = label;
101
136
  }
102
137
 
138
+ function setHidden(region: LayoutRegion, hidden: boolean): void {
139
+ const settings = regions[region];
140
+ if (root.hasAttribute(settings.attribute) === hidden) return;
141
+ const button = toggles.get(region);
142
+ if (button) applyState(button, region, hidden);
143
+ else root.toggleAttribute(settings.attribute, hidden);
144
+ writePreference(settings.storageKey, hidden);
145
+ }
146
+
147
+ /**
148
+ * A panel collapsed by the pointer would otherwise peek straight back open,
149
+ * because the pointer that collapsed it is still inside the rail it left
150
+ * behind. Hold the peek off until the pointer actually leaves.
151
+ */
152
+ function suppressPeek(region: LayoutRegion): void {
153
+ const settings = regions[region];
154
+ root.setAttribute(settings.peekSuppressedAttribute, '');
155
+ const panel = document.querySelector(settings.panelSelector);
156
+ const clear = () => root.removeAttribute(settings.peekSuppressedAttribute);
157
+ panel?.addEventListener('pointerleave', clear, { once: true });
158
+ window.setTimeout(() => {
159
+ if (!panel?.matches(':hover')) clear();
160
+ }, 180);
161
+ }
162
+
103
163
  document
104
164
  .querySelectorAll<HTMLButtonElement>('[data-layout-toggle]')
105
165
  .forEach((button) => {
106
166
  const region = button.dataset.layoutToggle as LayoutRegion;
107
167
  const settings = regions[region];
108
168
  if (!settings) return;
169
+ toggles.set(region, button);
109
170
 
110
- applyState(button, region, readPreference(settings.storageKey));
171
+ applyState(button, region, readPreference(region));
111
172
  button.removeAttribute('hidden');
112
173
  button.addEventListener('click', (event) => {
113
174
  const hidden = !root.hasAttribute(settings.attribute);
@@ -121,20 +182,238 @@ import DefaultHeader from '@astrojs/starlight/components/Header.astro';
121
182
  // A pointer click should leave a newly collapsed rail collapsed even
122
183
  // though the pointer was inside the full-width panel when clicked.
123
184
  if (hidden && event.detail > 0) {
124
- root.setAttribute(settings.peekSuppressedAttribute, '');
125
185
  button.blur();
186
+ suppressPeek(region);
187
+ }
188
+ });
189
+ });
190
+
191
+ /**
192
+ * Hover intent for the peek.
193
+ *
194
+ * A collapsed panel peeks open when the pointer rests on its rail, and the
195
+ * rails sit on the two edges of the window — a pointer on its way to the
196
+ * scrollbar, the tab strip, or the other side of the article crosses one
197
+ * without meaning anything by it, and the panel would swing open behind it
198
+ * every time. Waiting out `--layout-peek-delay` before arming the peek
199
+ * makes crossing the rail cost nothing while resting on it still opens the
200
+ * panel about as fast as the eye registers that it moved.
201
+ *
202
+ * The CSS keeps `:hover` as the trigger and reads this attribute as the
203
+ * gate in front of it, so there is one description of what peeking looks
204
+ * like, not two. Nothing is lost without JavaScript: a panel is only ever
205
+ * collapsed because this same script collapsed it.
206
+ */
207
+ const PEEK_DELAY_FALLBACK = 40;
208
+
209
+ function peekDelay(): number {
210
+ const declared = getComputedStyle(root)
211
+ .getPropertyValue('--layout-peek-delay')
212
+ .trim();
213
+ const value = Number.parseFloat(declared);
214
+ if (!Number.isFinite(value) || value < 0) return PEEK_DELAY_FALLBACK;
215
+ // A bare number is read as milliseconds; `s` is the only other unit CSS
216
+ // gives a <time>.
217
+ return declared.endsWith('ms') || !declared.endsWith('s')
218
+ ? value
219
+ : value * 1000;
220
+ }
221
+
222
+ (Object.keys(regions) as LayoutRegion[]).forEach((region) => {
223
+ const settings = regions[region];
224
+ const panel = document.querySelector<HTMLElement>(settings.panelSelector);
225
+ if (!panel) return;
226
+ let timer: number | null = null;
227
+
228
+ panel.addEventListener('pointerenter', () => {
229
+ if (timer !== null) window.clearTimeout(timer);
230
+ timer = window.setTimeout(() => {
231
+ timer = null;
232
+ root.setAttribute(settings.peekArmedAttribute, '');
233
+ }, peekDelay());
234
+ });
235
+
236
+ panel.addEventListener('pointerleave', () => {
237
+ if (timer !== null) window.clearTimeout(timer);
238
+ timer = null;
239
+ root.removeAttribute(settings.peekArmedAttribute);
240
+ });
241
+ });
242
+
243
+ /**
244
+ * Reader-set panel widths.
245
+ *
246
+ * A panel's width is the reader's, not the theme's: `--sl-sidebar-width` is
247
+ * Starlight's own token, so setting it moves the header's title column, the
248
+ * article's inline start and the `.main-pane` formula together, with no
249
+ * second source of truth to keep in step. The "On this page" rail has no
250
+ * such token — it shrink-wraps its own content — so a reader-set width goes
251
+ * to `--layout-contents-width`, which `global.css` only honours once
252
+ * `data-contents-resized` says the reader has set one.
253
+ *
254
+ * Dragging a panel below three characters of width is how a reader collapses
255
+ * it by hand: there is no useful panel narrower than that, and treating it
256
+ * as "closed" beats leaving a sliver that can only be reopened by dragging
257
+ * a handle that has nowhere left to sit. `3ch` is measured in the panel's
258
+ * own font rather than assumed, so a course that changes `--sl-font` gets
259
+ * the threshold its own text implies.
260
+ */
261
+ const MAX_PANEL_WIDTH = 640;
262
+ const pageIsRtl = getComputedStyle(root).direction === 'rtl';
263
+
264
+ function threeCharacters(panel: HTMLElement): number {
265
+ const probe = document.createElement('div');
266
+ probe.style.cssText = 'position:absolute;visibility:hidden;width:3ch';
267
+ panel.appendChild(probe);
268
+ const width = probe.getBoundingClientRect().width;
269
+ probe.remove();
270
+ return width || 24;
271
+ }
272
+
273
+ function maxPanelWidth(): number {
274
+ return Math.min(window.innerWidth / 2, MAX_PANEL_WIDTH);
275
+ }
276
+
277
+ function restoreWidth(region: LayoutRegion, matches: boolean): void {
278
+ const settings = regions[region];
279
+ const stored = Number(preferences.get(settings.widthStorageKey));
280
+ if (matches && Number.isFinite(stored) && stored > 0) {
281
+ root.style.setProperty(settings.widthProperty, `${Math.round(stored)}px`);
282
+ root.setAttribute(settings.resizedAttribute, '');
283
+ return;
284
+ }
285
+ // Outside this panel's layout the token belongs to the theme again: a
286
+ // desktop width left on `:root` would follow the page down to a phone,
287
+ // where Starlight expects its own value.
288
+ root.style.removeProperty(settings.widthProperty);
289
+ root.removeAttribute(settings.resizedAttribute);
290
+ }
291
+
292
+ document
293
+ .querySelectorAll<HTMLElement>('[data-layout-resizer]')
294
+ .forEach((handle) => {
295
+ const region = handle.dataset.layoutResizer as LayoutRegion;
296
+ const settings = regions[region];
297
+ if (!settings) return;
298
+ const media = window.matchMedia(settings.query);
299
+ const grows = settings.grows * (pageIsRtl ? -1 : 1);
300
+ let floor = 24;
301
+
302
+ const panel = () =>
303
+ document.querySelector<HTMLElement>(settings.panelSelector);
304
+ const currentWidth = () => panel()?.getBoundingClientRect().width ?? 0;
305
+
306
+ const describe = (width: number) => {
307
+ handle.setAttribute('aria-valuemin', '0');
308
+ handle.setAttribute('aria-valuemax', String(Math.round(maxPanelWidth())));
309
+ handle.setAttribute('aria-valuenow', String(Math.round(width)));
310
+ handle.setAttribute(
311
+ 'aria-valuetext',
312
+ width > 0 ? `${Math.round(width)} pixels` : 'collapsed',
313
+ );
314
+ };
315
+
316
+ const applyWidth = (width: number) => {
317
+ root.style.setProperty(settings.widthProperty, `${Math.round(width)}px`);
318
+ root.setAttribute(settings.resizedAttribute, '');
319
+ if (region === 'contents') updateTocReservation();
320
+ };
321
+
322
+ /**
323
+ * A width the reader passed through on the way somewhere else is not a
324
+ * width they chose, so nothing is written until the gesture ends. That
325
+ * also keeps the width a collapsing drag sweeps through — 40px, 30px,
326
+ * whatever the last frame above the threshold happened to be — from
327
+ * becoming the width the panel reopens at.
328
+ */
329
+ const persist = () => {
330
+ const current = Number.parseFloat(
331
+ root.style.getPropertyValue(settings.widthProperty),
332
+ );
333
+ if (Number.isFinite(current) && current > 0) {
334
+ preferences.set(settings.widthStorageKey, String(Math.round(current)));
335
+ }
336
+ };
126
337
 
127
- const panel = document.querySelector(settings.panelSelector);
128
- const clearSuppression = () =>
129
- root.removeAttribute(settings.peekSuppressedAttribute);
130
- panel?.addEventListener('pointerleave', clearSuppression, {
131
- once: true,
132
- });
133
- window.setTimeout(() => {
134
- if (!panel?.matches(':hover')) clearSuppression();
135
- }, 180);
338
+ const resize = (width: number, reopenAt: number) => {
339
+ if (width < floor) {
340
+ setHidden(region, true);
341
+ // Collapsed, the panel's own width is the rail's; this is the width
342
+ // its control will bring back.
343
+ applyWidth(reopenAt);
344
+ describe(0);
345
+ return;
136
346
  }
347
+ setHidden(region, false);
348
+ const next = Math.round(
349
+ Math.min(Math.max(width, floor), maxPanelWidth()),
350
+ );
351
+ applyWidth(next);
352
+ describe(next);
353
+ };
354
+
355
+ const measureFloor = () => {
356
+ const element = panel();
357
+ floor = element ? threeCharacters(element) : 24;
358
+ };
359
+
360
+ handle.addEventListener('pointerdown', (event) => {
361
+ if (!media.matches) return;
362
+ const startX = event.clientX;
363
+ const startWidth = currentWidth();
364
+ measureFloor();
365
+ handle.setAttribute('data-resizing', '');
366
+ handle.setPointerCapture(event.pointerId);
367
+
368
+ const move = (moveEvent: PointerEvent) => {
369
+ resize(startWidth + (moveEvent.clientX - startX) * grows, startWidth);
370
+ };
371
+ const end = () => {
372
+ handle.removeAttribute('data-resizing');
373
+ persist();
374
+ // The pointer that dragged the panel away is still inside the rail
375
+ // it left behind, which would peek it straight back open.
376
+ if (root.hasAttribute(settings.attribute)) suppressPeek(region);
377
+ window.removeEventListener('pointermove', move);
378
+ window.removeEventListener('pointerup', end);
379
+ window.removeEventListener('pointercancel', end);
380
+ };
381
+ // Captured events still bubble on through the handle's ancestors, and
382
+ // listening up here keeps the drag alive across the moment a panel
383
+ // dragged past the threshold collapses and takes its handle off the
384
+ // layout — the reader can drag straight back out of it.
385
+ window.addEventListener('pointermove', move);
386
+ window.addEventListener('pointerup', end);
387
+ window.addEventListener('pointercancel', end);
388
+ event.preventDefault();
137
389
  });
390
+
391
+ handle.addEventListener('keydown', (event) => {
392
+ if (!media.matches) return;
393
+ const step = event.shiftKey ? 64 : 16;
394
+ measureFloor();
395
+ const from = currentWidth();
396
+ if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
397
+ const direction = event.key === 'ArrowRight' ? 1 : -1;
398
+ resize(from + direction * grows * step, from);
399
+ } else if (event.key === 'Home') {
400
+ resize(0, from);
401
+ } else if (event.key === 'End') {
402
+ resize(maxPanelWidth(), from);
403
+ } else {
404
+ return;
405
+ }
406
+ persist();
407
+ event.preventDefault();
408
+ });
409
+
410
+ const applyMedia = () => {
411
+ restoreWidth(region, media.matches);
412
+ handle.toggleAttribute('hidden', !media.matches);
413
+ if (media.matches) describe(currentWidth());
414
+ };
415
+ applyMedia();
416
+ media.addEventListener('change', applyMedia);
138
417
  });
139
418
 
140
419
  updateTocReservation();
@@ -1,6 +1,29 @@
1
1
  ---
2
- import { Icon } from '@astrojs/starlight/components';
3
2
  import DefaultPageSidebar from '@astrojs/starlight/components/PageSidebar.astro';
3
+ import { isLessonSlug, lessonIdFromSlug } from '../../compiler/lesson-derivation';
4
+ import { titledBlocksOf, tocWithTitledBlocks } from '../block-heading';
5
+
6
+ // "On this page" lists the `<h2>` a knowledge check and a worked-example set
7
+ // draw (docs/plans/titles-and-containers.md Part 6): a component renders
8
+ // them, so Astro's heading list — Starlight's source for the list — never
9
+ // had them. The manifest's `titledBlocks` says where each one stands; the
10
+ // list is rebuilt with them in before Starlight's own component (desktop and
11
+ // mobile alike) renders it. Each is listed by its title, as a section is;
12
+ // `data-titled-blocks` tells the script below which entries they are, so
13
+ // `global.css` draws the badge's icon before them.
14
+ const route = Astro.locals.starlightRoute;
15
+ const routeSlug = route.entry.id;
16
+ let blockKinds: Record<string, string> | undefined;
17
+ if (route.toc && isLessonSlug(routeSlug)) {
18
+ const blocks = await titledBlocksOf(lessonIdFromSlug(routeSlug));
19
+ if (blocks.length > 0) {
20
+ route.toc = {
21
+ ...route.toc,
22
+ items: tocWithTitledBlocks(route.toc, route.headings, blocks),
23
+ };
24
+ blockKinds = Object.fromEntries(blocks.map((block) => [block.anchor, block.kind]));
25
+ }
26
+ }
4
27
  ---
5
28
 
6
29
  <div
@@ -16,18 +39,82 @@ import DefaultPageSidebar from '@astrojs/starlight/components/PageSidebar.astro'
16
39
  data-layout-toggle="contents"
17
40
  hidden
18
41
  >
19
- <Icon
20
- name="right-caret"
21
- class="layout-edge-icon layout-edge-icon-hide"
22
- size="1rem"
23
- />
24
- <Icon
25
- name="left-caret"
26
- class="layout-edge-icon layout-edge-icon-restore"
27
- size="1rem"
28
- />
42
+ <span class="layout-edge-glyph layout-edge-glyph-hide" aria-hidden="true"
43
+ >&gt;&gt;&gt;</span
44
+ >
45
+ <span class="layout-edge-glyph layout-edge-glyph-restore" aria-hidden="true"
46
+ >&lt;&lt;&lt;</span
47
+ >
29
48
  </button>
30
- <div id="starlight__page-sidebar-content" class="layout-page-sidebar-content">
49
+ <div
50
+ class="layout-edge-resizer layout-contents-resizer"
51
+ role="separator"
52
+ aria-orientation="vertical"
53
+ aria-controls="starlight__page-sidebar-content"
54
+ aria-label="Page contents width"
55
+ tabindex="0"
56
+ data-layout-resizer="contents"
57
+ hidden
58
+ >
59
+ </div>
60
+ <div
61
+ id="starlight__page-sidebar-content"
62
+ class="layout-page-sidebar-content"
63
+ data-titled-blocks={blockKinds && JSON.stringify(blockKinds)}
64
+ >
31
65
  <DefaultPageSidebar />
66
+ <script>
67
+ // A knowledge check's or an example set's entry: `data-block-kind` on
68
+ // its link, which `global.css` gives the badge's icon.
69
+ (() => {
70
+ const rail = document.querySelector<HTMLElement>('#starlight__page-sidebar-content');
71
+ const kinds = JSON.parse(rail?.dataset.titledBlocks ?? '{}') as Record<string, string>;
72
+ for (const link of rail?.querySelectorAll<HTMLAnchorElement>('a[href^="#"]') ?? []) {
73
+ const kind = kinds[decodeURIComponent(link.hash.slice(1))];
74
+ if (kind) link.dataset.blockKind = kind;
75
+ }
76
+ })();
77
+ </script>
78
+ <script>
79
+ /**
80
+ * The same progress the navigation rail shows across the course, shown
81
+ * here across the page: the headings above the reader's position are
82
+ * marked, the one they are in is marked as current, and the rest stay
83
+ * plain.
84
+ *
85
+ * Starlight's own scroll-spy moves `aria-current="true"` from link to
86
+ * link as the reader scrolls, so this watches for that attribute
87
+ * instead of re-implementing the intersection logic, and re-marks from
88
+ * whichever link holds it.
89
+ */
90
+ (() => {
91
+ const rail = document.querySelector('#starlight__page-sidebar-content');
92
+ const toc = rail?.querySelector('starlight-toc');
93
+ if (!toc) return;
94
+
95
+ const mark = () => {
96
+ const links = [...toc.querySelectorAll('a')];
97
+ const current = links.findIndex(
98
+ (link) => link.getAttribute('aria-current') === 'true',
99
+ );
100
+ links.forEach((link, index) => {
101
+ const item = link.parentElement;
102
+ if (!item) return;
103
+ item.toggleAttribute(
104
+ 'data-covered',
105
+ current > 0 && index < current,
106
+ );
107
+ item.toggleAttribute('data-current', index === current);
108
+ });
109
+ };
110
+
111
+ mark();
112
+ new MutationObserver(mark).observe(toc, {
113
+ attributes: true,
114
+ attributeFilter: ['aria-current'],
115
+ subtree: true,
116
+ });
117
+ })();
118
+ </script>
32
119
  </div>
33
120
  </div>
@@ -0,0 +1,120 @@
1
+ ---
2
+ /**
3
+ * The page title, preceded by the part it belongs to.
4
+ *
5
+ * A reader who arrives from a search result, a shared link, or a print export
6
+ * has no sidebar state to read their position from, and the sidebar itself
7
+ * names a part without linking it. One line above the title answers both: it
8
+ * says which part this chapter sits in, and it is the route to that part's own
9
+ * page — the surface that does not depend on a hover, a caret, or a rail being
10
+ * open at all.
11
+ *
12
+ * The trail stops at the part on purpose: the `<h1>` immediately below is the
13
+ * current page, and repeating it costs a screen reader the same words twice.
14
+ */
15
+ import DefaultPageTitle from '@astrojs/starlight/components/PageTitle.astro';
16
+ import { loadManifest } from '../../compiler/manifest';
17
+ import { isLessonSlug } from '../../compiler/lesson-derivation';
18
+
19
+ const route = Astro.locals.starlightRoute;
20
+ const routeSlug = route.entry.id;
21
+ const manifest = await loadManifest();
22
+
23
+ // `<dir>/<name>` is a chapter. A part's own page is `<dir>/index.mdx`, whose
24
+ // entry id the glob loader shortens to the directory itself — so a slug with
25
+ // no `/` is a part page when it names a configured part, and an ordinary
26
+ // top-level page (`glossary`, `curriculum-map`) when it does not.
27
+ const partDir = isLessonSlug(routeSlug)
28
+ ? routeSlug.slice(0, routeSlug.indexOf('/'))
29
+ : routeSlug.replace(/\/index$/, '');
30
+ const part = manifest.parts.find((entry) => entry.dir === partDir);
31
+ const isPartPage = part !== undefined && !isLessonSlug(routeSlug);
32
+
33
+ const base = import.meta.env.BASE_URL.replace(/\/$/, '');
34
+ // A part is only a link when there is a page behind its directory URL.
35
+ const partHref =
36
+ part?.overview && !isPartPage ? `${base}${part.link}` : undefined;
37
+ ---
38
+
39
+ {
40
+ part && (
41
+ <nav class="lesson-breadcrumb not-content" aria-label="Breadcrumb">
42
+ <ol>
43
+ <li>
44
+ <a href={route.siteTitleHref}>{route.siteTitle}</a>
45
+ </li>
46
+ <li>
47
+ {partHref ? (
48
+ <a href={partHref}>{part.label}</a>
49
+ ) : (
50
+ <span aria-current={isPartPage ? 'page' : undefined}>
51
+ {part.label}
52
+ </span>
53
+ )}
54
+ </li>
55
+ </ol>
56
+ </nav>
57
+ )
58
+ }
59
+ <DefaultPageTitle />
60
+
61
+ <style>
62
+ .lesson-breadcrumb {
63
+ margin-block-start: 1rem;
64
+ /* The title's own `margin-top: 1rem` would otherwise open a second gap of
65
+ the same size between the trail and the heading it introduces. */
66
+ margin-block-end: -0.75rem;
67
+ font-size: var(--sl-text-xs);
68
+ line-height: 1.4;
69
+ }
70
+
71
+ .lesson-breadcrumb ol {
72
+ display: flex;
73
+ flex-wrap: wrap;
74
+ align-items: center;
75
+ gap: 0.25rem 0.4rem;
76
+ margin: 0;
77
+ padding: 0;
78
+ list-style: none;
79
+ }
80
+
81
+ .lesson-breadcrumb li {
82
+ display: flex;
83
+ align-items: center;
84
+ gap: 0.4rem;
85
+ margin: 0;
86
+ padding: 0;
87
+ color: var(--sl-color-gray-3);
88
+ }
89
+
90
+ .lesson-breadcrumb li + li::before {
91
+ content: '\203A';
92
+ color: var(--sl-color-gray-4);
93
+ }
94
+
95
+ .lesson-breadcrumb a {
96
+ color: var(--sl-color-gray-3);
97
+ text-decoration: none;
98
+ }
99
+
100
+ .lesson-breadcrumb a:hover,
101
+ .lesson-breadcrumb a:focus-visible {
102
+ color: var(--sl-color-text-accent);
103
+ text-decoration: underline;
104
+ text-underline-offset: 0.2em;
105
+ }
106
+
107
+ [dir='rtl'] .lesson-breadcrumb li + li::before {
108
+ content: '\2039';
109
+ }
110
+
111
+ @media print {
112
+ .lesson-breadcrumb {
113
+ margin-block-end: 0;
114
+ }
115
+
116
+ .lesson-breadcrumb a {
117
+ text-decoration: none;
118
+ }
119
+ }
120
+ </style>