explico 0.4.0 → 0.5.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 (143) hide show
  1. package/CHANGELOG.md +273 -1
  2. package/README.md +103 -8
  3. package/dist/compiler/astro.d.ts +9 -1
  4. package/dist/compiler/astro.js +182 -3
  5. package/dist/compiler/cli.d.ts +13 -0
  6. package/dist/compiler/cli.js +28 -4
  7. package/dist/compiler/collections.d.ts +3 -2
  8. package/dist/compiler/collections.js +28 -1
  9. package/dist/compiler/course-config.d.ts +58 -1
  10. package/dist/compiler/course-config.js +28 -1
  11. package/dist/compiler/exports/standalone-html.d.ts +34 -0
  12. package/dist/compiler/exports/standalone-html.js +803 -0
  13. package/dist/compiler/manifest.d.ts +90 -6
  14. package/dist/compiler/manifest.js +150 -17
  15. package/dist/compiler/resolution-report.d.ts +4 -0
  16. package/dist/reference/base-abbreviations.d.mts +30 -0
  17. package/dist/reference/base-abbreviations.mjs +60 -0
  18. package/dist/reference/base-operators.d.mts +15 -0
  19. package/dist/reference/base-operators.mjs +40 -0
  20. package/dist/reference/base-roman-labels.d.mts +35 -0
  21. package/dist/reference/base-roman-labels.mjs +43 -0
  22. package/dist/reference/consistency.d.ts +8 -2
  23. package/dist/reference/consistency.js +50 -3
  24. package/dist/reference/diagrams-validate.d.ts +21 -0
  25. package/dist/reference/diagrams-validate.js +13 -0
  26. package/dist/reference/equations.d.mts +10 -15
  27. package/dist/reference/equations.mjs +67 -14
  28. package/dist/reference/gate-abbreviations.d.ts +17 -0
  29. package/dist/reference/gate-abbreviations.js +64 -0
  30. package/dist/reference/gate-math.d.ts +28 -5
  31. package/dist/reference/gate-math.js +5 -2
  32. package/dist/reference/gate-prose-notation.d.ts +60 -0
  33. package/dist/reference/gate-prose-notation.js +125 -0
  34. package/dist/reference/keyed-float-validate.d.ts +5 -5
  35. package/dist/reference/keyed-jsx-floats.d.mts +7 -12
  36. package/dist/reference/keyed-jsx-floats.mjs +50 -25
  37. package/dist/reference/knowledge-checks-validate.d.ts +35 -0
  38. package/dist/reference/knowledge-checks-validate.js +78 -0
  39. package/dist/reference/knowledge-checks.d.mts +25 -0
  40. package/dist/reference/knowledge-checks.mjs +45 -0
  41. package/dist/reference/lab-math-scope.d.ts +9 -1
  42. package/dist/reference/lab-math-scope.js +32 -4
  43. package/dist/reference/math-glyphs.d.mts +30 -0
  44. package/dist/reference/math-glyphs.mjs +367 -0
  45. package/dist/reference/mdx-attributes.d.mts +13 -0
  46. package/dist/reference/mdx-attributes.mjs +38 -0
  47. package/dist/reference/mixed-content.d.ts +17 -0
  48. package/dist/reference/mixed-content.js +29 -0
  49. package/dist/reference/page-notation.d.ts +52 -0
  50. package/dist/reference/page-notation.js +171 -0
  51. package/dist/reference/raw-floats-validate.d.ts +18 -0
  52. package/dist/reference/raw-floats-validate.js +53 -0
  53. package/dist/reference/raw-floats.d.mts +18 -0
  54. package/dist/reference/raw-floats.mjs +129 -0
  55. package/dist/reference/rehype-heading-anchors.d.mts +1 -1
  56. package/dist/reference/rehype-heading-anchors.mjs +107 -1
  57. package/dist/reference/rehype-table-header-labels.d.mts +26 -0
  58. package/dist/reference/rehype-table-header-labels.mjs +152 -0
  59. package/dist/reference/remark-abbreviations.d.mts +7 -0
  60. package/dist/reference/remark-abbreviations.mjs +174 -0
  61. package/dist/reference/remark-memo.d.mts +16 -0
  62. package/dist/reference/remark-memo.mjs +339 -0
  63. package/dist/reference/remark-notation.d.mts +57 -9
  64. package/dist/reference/remark-notation.mjs +354 -42
  65. package/dist/reference/remark-short-note.d.mts +4 -0
  66. package/dist/reference/remark-short-note.mjs +237 -0
  67. package/dist/reference/types.d.ts +16 -1
  68. package/dist/reference/units-vocab.js +4 -0
  69. package/docs/VERSIONING.md +33 -4
  70. package/docs/authoring/README.md +8 -0
  71. package/docs/authoring/agents.md +17 -2
  72. package/docs/authoring/components.md +135 -0
  73. package/docs/authoring/migrating.md +92 -7
  74. package/docs/authoring/notation.md +560 -1
  75. package/docs/authoring/structure.md +120 -8
  76. package/docs/authoring/visualizations.md +36 -3
  77. package/package.json +5 -2
  78. package/src/compiler/astro.ts +208 -3
  79. package/src/compiler/cli.ts +29 -2
  80. package/src/compiler/collections.ts +45 -4
  81. package/src/compiler/course-config.ts +92 -0
  82. package/src/compiler/exports/standalone-html.ts +963 -0
  83. package/src/compiler/manifest.ts +268 -22
  84. package/src/components/HoverPanel.astro +45 -4
  85. package/src/components/abbreviation/AbbreviationGlossary.astro +70 -0
  86. package/src/components/abbreviation/AbbreviationLayer.astro +127 -0
  87. package/src/components/assessments/AssessmentRunner.tsx +52 -17
  88. package/src/components/assessments/AssessmentSet.astro +98 -14
  89. package/src/components/assessments/RenderedContent.tsx +55 -0
  90. package/src/components/assessments/assessment-randomization.ts +16 -0
  91. package/src/components/diagram/Diagram.astro +444 -83
  92. package/src/components/disclosure/hover-preview.ts +202 -0
  93. package/src/components/disclosure/persisted-disclosure.ts +66 -0
  94. package/src/components/examples/CompactExamples.astro +2 -0
  95. package/src/components/figure/Figure.astro +279 -10
  96. package/src/components/memo/Memo.astro +453 -0
  97. package/src/components/memo/ShortNote.astro +223 -0
  98. package/src/components/notation/NotationFormula.astro +18 -1
  99. package/src/components/notation/NotationLayer.astro +105 -8
  100. package/src/components/notation/render-notation-math.ts +39 -2
  101. package/src/components/notation/safe-math-to-react.ts +50 -0
  102. package/src/components/reference/FloatRefLayer.astro +233 -15
  103. package/src/components/starlight/CollapsibleSections.astro +461 -0
  104. package/src/components/starlight/DiagramEnhancer.astro +385 -0
  105. package/src/components/starlight/HeadingAnchorEnhancer.astro +5 -17
  106. package/src/components/starlight/KeyedFloatEnhancer.astro +81 -3
  107. package/src/components/starlight/LayoutHeader.astro +50 -0
  108. package/src/components/starlight/LayoutSocialIcons.astro +41 -2
  109. package/src/components/starlight/LessonExportActions.astro +436 -0
  110. package/src/components/starlight/LessonFooter.astro +27 -2
  111. package/src/components/starlight/section-parts.ts +31 -0
  112. package/src/components/table/Table.astro +293 -7
  113. package/src/components/table/TableOverflowExpander.astro +489 -0
  114. package/src/content-config.ts +20 -0
  115. package/src/reference/base-abbreviations.mjs +60 -0
  116. package/src/reference/base-operators.mjs +40 -0
  117. package/src/reference/base-roman-labels.mjs +43 -0
  118. package/src/reference/consistency.ts +73 -2
  119. package/src/reference/diagrams-validate.ts +40 -0
  120. package/src/reference/equations.mjs +67 -14
  121. package/src/reference/gate-abbreviations.ts +128 -0
  122. package/src/reference/gate-math.ts +27 -7
  123. package/src/reference/gate-prose-notation.ts +224 -0
  124. package/src/reference/keyed-float-validate.ts +5 -5
  125. package/src/reference/keyed-jsx-floats.mjs +50 -25
  126. package/src/reference/knowledge-checks-validate.ts +130 -0
  127. package/src/reference/knowledge-checks.mjs +45 -0
  128. package/src/reference/lab-math-scope.ts +45 -3
  129. package/src/reference/math-glyphs.mjs +367 -0
  130. package/src/reference/mdx-attributes.mjs +38 -0
  131. package/src/reference/mixed-content.ts +69 -0
  132. package/src/reference/page-notation.ts +252 -0
  133. package/src/reference/raw-floats-validate.ts +103 -0
  134. package/src/reference/raw-floats.mjs +129 -0
  135. package/src/reference/rehype-heading-anchors.mjs +107 -1
  136. package/src/reference/rehype-table-header-labels.mjs +152 -0
  137. package/src/reference/remark-abbreviations.mjs +174 -0
  138. package/src/reference/remark-memo.mjs +339 -0
  139. package/src/reference/remark-notation.mjs +354 -42
  140. package/src/reference/remark-short-note.mjs +237 -0
  141. package/src/reference/types.ts +16 -0
  142. package/src/reference/units-vocab.ts +4 -0
  143. package/src/styles/global.css +996 -50
@@ -0,0 +1,385 @@
1
+ ---
2
+ /**
3
+ * Progressive behavior for `<Diagram>`:
4
+ *
5
+ * - a persistent, animated title disclosure;
6
+ * - a compact horizontal expand control shown only when the canvas overflows;
7
+ * - an in-page expanded surface that moves the real figure (never a clone) to
8
+ * `<body>`, overlays its placeholder, and follows that placeholder while the
9
+ * page scrolls — the same spatial model as `TableOverflowExpander.astro`.
10
+ *
11
+ * Expansion is deliberately non-modal: there is no backdrop, page scrolling
12
+ * remains available, and the diagram closes only through its own shrink button
13
+ * or Escape. Tall expanded content scrolls vertically inside its viewport-
14
+ * capped surface.
15
+ */
16
+ ---
17
+
18
+ <script>
19
+ import {
20
+ savePersistedOpenState,
21
+ wirePersistedDetails,
22
+ } from '../disclosure/persisted-disclosure';
23
+ import { claimActiveSurface, releaseActiveSurface } from '../hover-panel';
24
+
25
+ const GROUP = 'diagram-overflow';
26
+ const EDGE_PX = 16;
27
+ const ANIMATION_MS = 220;
28
+
29
+ interface Expansion {
30
+ readonly figure: HTMLElement;
31
+ readonly placeholder: HTMLElement;
32
+ readonly spoofWrapper: HTMLElement;
33
+ readonly button: HTMLButtonElement;
34
+ readonly scroll: HTMLElement;
35
+ readonly reposition: () => void;
36
+ readonly evaluate: () => void;
37
+ }
38
+
39
+ let active: Expansion | null = null;
40
+
41
+ function sidebarLeftBound(): number {
42
+ const sidebar = document.querySelector<HTMLElement>('.sidebar-pane');
43
+ const visible = !!sidebar && getComputedStyle(sidebar).visibility !== 'hidden';
44
+ return visible ? sidebar!.getBoundingClientRect().right + EDGE_PX : EDGE_PX;
45
+ }
46
+
47
+ function tocLeftBound(): number | null {
48
+ const toc = document.querySelector<HTMLElement>('.right-sidebar');
49
+ if (!toc) return null;
50
+ if (document.documentElement.hasAttribute('data-page-sidebar-hidden')) {
51
+ return null;
52
+ }
53
+ if (!window.matchMedia('(min-width: 72rem)').matches) return null;
54
+ return toc.getBoundingClientRect().left;
55
+ }
56
+
57
+ function captionFor(figure: HTMLElement): string {
58
+ return (
59
+ figure.querySelector<HTMLElement>('.diagram__summary')?.textContent?.trim() ??
60
+ 'this diagram'
61
+ );
62
+ }
63
+
64
+ function updateExpandButton(
65
+ figure: HTMLElement,
66
+ button: HTMLButtonElement,
67
+ expanded: boolean,
68
+ ): void {
69
+ button.setAttribute('aria-expanded', String(expanded));
70
+ button.setAttribute(
71
+ 'aria-label',
72
+ expanded
73
+ ? `Shrink ${captionFor(figure)}`
74
+ : `Expand ${captionFor(figure)} horizontally`,
75
+ );
76
+ button.title = expanded ? 'Shrink diagram' : 'Expand diagram horizontally';
77
+ const srText = button.querySelector<HTMLElement>('.sr-only');
78
+ if (srText) srText.textContent = expanded ? 'Shrink diagram' : 'Expand diagram';
79
+ }
80
+
81
+ function updateScrollFocusability(scroll: HTMLElement, figure: HTMLElement): void {
82
+ if (scroll.scrollWidth > scroll.clientWidth + 1) {
83
+ scroll.tabIndex = 0;
84
+ scroll.setAttribute('aria-label', `Scrollable ${captionFor(figure)}`);
85
+ } else {
86
+ scroll.removeAttribute('tabindex');
87
+ scroll.removeAttribute('aria-label');
88
+ }
89
+ }
90
+
91
+ function evaluateOverflow(
92
+ figure: HTMLElement,
93
+ details: HTMLDetailsElement,
94
+ toolbar: HTMLElement,
95
+ button: HTMLButtonElement,
96
+ scroll: HTMLElement,
97
+ ): void {
98
+ if (active?.figure === figure) return;
99
+ const overflows = details.open && scroll.scrollWidth > scroll.clientWidth + 1;
100
+ figure.dataset.diagramOverflow = String(overflows);
101
+ toolbar.hidden = !overflows;
102
+ button.hidden = !overflows;
103
+ updateScrollFocusability(scroll, figure);
104
+ }
105
+
106
+ function collapseExpansion({ restoreFocus = true } = {}): void {
107
+ if (!active) return;
108
+ const state = active;
109
+ active = null;
110
+ window.removeEventListener('scroll', state.reposition, true);
111
+ window.removeEventListener('resize', state.reposition);
112
+
113
+ state.figure.classList.remove('diagram-is-expanded');
114
+ state.figure.style.cssText = '';
115
+ state.placeholder.replaceWith(state.figure);
116
+ state.spoofWrapper.remove();
117
+ updateExpandButton(state.figure, state.button, false);
118
+ state.evaluate();
119
+ if (restoreFocus) state.button.focus({ preventScroll: true });
120
+ }
121
+
122
+ function expand(
123
+ figure: HTMLElement,
124
+ button: HTMLButtonElement,
125
+ scroll: HTMLElement,
126
+ evaluate: () => void,
127
+ ): void {
128
+ // An expanded diagram is dismissed only by its own shrink control or
129
+ // Escape. Ignore another diagram's expand control until the current one is
130
+ // restored.
131
+ if (active && active.figure !== figure) return;
132
+
133
+ claimActiveSurface(GROUP, () => {});
134
+ releaseActiveSurface(GROUP);
135
+
136
+ const neededWidth = scroll.scrollWidth;
137
+ const rect = figure.getBoundingClientRect();
138
+ const placeholder = document.createElement('div');
139
+ placeholder.className = 'diagram-expand-placeholder';
140
+ placeholder.style.height = `${rect.height}px`;
141
+ figure.replaceWith(placeholder);
142
+
143
+ // Retain every `.sl-markdown-content …` descendant rule after moving the
144
+ // original figure to `<body>`, without creating a layout box of its own.
145
+ const spoofWrapper = document.createElement('div');
146
+ spoofWrapper.className = 'sl-markdown-content';
147
+ spoofWrapper.style.display = 'contents';
148
+ document.body.append(spoofWrapper);
149
+ spoofWrapper.append(figure);
150
+ figure.classList.add('diagram-is-expanded');
151
+
152
+ const reposition = () => {
153
+ const headerBottom =
154
+ document.querySelector<HTMLElement>('.header')?.getBoundingClientRect()
155
+ .bottom ?? 0;
156
+ const verticalStart = Math.max(EDGE_PX, headerBottom + EDGE_PX);
157
+ const placeholderRect = placeholder.getBoundingClientRect();
158
+ const sidebarEdge = sidebarLeftBound();
159
+ const viewportEdge = window.innerWidth - EDGE_PX;
160
+ const tocBoundary = tocLeftBound();
161
+ const rightBound = Math.min(
162
+ viewportEdge,
163
+ Math.max(
164
+ tocBoundary !== null ? tocBoundary - EDGE_PX : viewportEdge,
165
+ sidebarEdge + neededWidth,
166
+ ),
167
+ );
168
+ const availableWidth = Math.max(rightBound - sidebarEdge, 80);
169
+ const width = Math.min(neededWidth, availableWidth);
170
+ const left = sidebarEdge + (availableWidth - width) / 2;
171
+
172
+ // Exact placeholder tracking makes this an overlay at the diagram's
173
+ // document position, not a sticky/modal surface. It naturally moves
174
+ // above or below the viewport as the reader scrolls past it.
175
+ const top = placeholderRect.top;
176
+ figure.style.position = 'fixed';
177
+ figure.style.top = `${top}px`;
178
+ figure.style.left = `${left}px`;
179
+ figure.style.width = `${width}px`;
180
+ const visibleTop = Math.max(verticalStart, top);
181
+ const maxHeight = Math.max(
182
+ window.innerHeight - visibleTop - EDGE_PX,
183
+ 80,
184
+ );
185
+ figure.style.maxHeight = `${maxHeight}px`;
186
+ const titleHeight =
187
+ figure
188
+ .querySelector<HTMLElement>('.diagram__summary')
189
+ ?.getBoundingClientRect().height ?? 0;
190
+ figure.style.setProperty(
191
+ '--diagram-expanded-body-height',
192
+ `${Math.max(maxHeight - titleHeight, 40)}px`,
193
+ );
194
+ updateScrollFocusability(scroll, figure);
195
+ };
196
+
197
+ active = {
198
+ figure,
199
+ placeholder,
200
+ spoofWrapper,
201
+ button,
202
+ scroll,
203
+ reposition,
204
+ evaluate,
205
+ };
206
+ updateExpandButton(figure, button, true);
207
+ reposition();
208
+ window.addEventListener('scroll', reposition, {
209
+ passive: true,
210
+ capture: true,
211
+ });
212
+ window.addEventListener('resize', reposition, { passive: true });
213
+ }
214
+
215
+ function wireDisclosure(
216
+ figure: HTMLElement,
217
+ details: HTMLDetailsElement,
218
+ body: HTMLElement,
219
+ evaluate: () => void,
220
+ ): void {
221
+ const summary = details.querySelector<HTMLElement>(':scope > summary');
222
+ if (!summary) return;
223
+ const defaultOpen = details.open;
224
+ const remember = details.dataset.rememberState === 'true';
225
+ if (remember) {
226
+ wirePersistedDetails(details, `diagram:${figure.id}`, defaultOpen);
227
+ }
228
+ details.dataset.state = details.open ? 'open' : 'closed';
229
+
230
+ // An external/bookmarked deep link reveals the complete diagram. Later
231
+ // in-page jumps retain the reader's persisted minimized state.
232
+ if (location.hash === `#${figure.id}`) {
233
+ details.open = true;
234
+ details.dataset.state = 'open';
235
+ }
236
+
237
+ const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
238
+ let finishTimer: number | null = null;
239
+ let transitionEnd: ((event: TransitionEvent) => void) | null = null;
240
+
241
+ const cancelAnimation = () => {
242
+ if (finishTimer !== null) window.clearTimeout(finishTimer);
243
+ finishTimer = null;
244
+ if (transitionEnd) body.removeEventListener('transitionend', transitionEnd);
245
+ transitionEnd = null;
246
+ body.style.removeProperty('transition');
247
+ body.style.removeProperty('overflow');
248
+ };
249
+
250
+ const setOpen = (open: boolean) => {
251
+ cancelAnimation();
252
+ const current = body.getBoundingClientRect().height;
253
+ if (open) details.open = true;
254
+ details.dataset.state = open ? 'open' : 'closed';
255
+ if (remember) savePersistedOpenState(`diagram:${figure.id}`, open);
256
+
257
+ if (reduceMotion.matches) {
258
+ details.open = open;
259
+ body.style.maxHeight = open ? '' : '0px';
260
+ if (active?.figure === figure) {
261
+ active.placeholder.style.height = `${figure.getBoundingClientRect().height}px`;
262
+ }
263
+ evaluate();
264
+ active?.reposition();
265
+ return;
266
+ }
267
+
268
+ body.style.transition = 'none';
269
+ body.style.maxHeight = `${current}px`;
270
+ body.style.overflow = 'hidden';
271
+ void body.offsetHeight;
272
+ body.style.removeProperty('transition');
273
+
274
+ const expandedLimit = Number.parseFloat(
275
+ getComputedStyle(figure).getPropertyValue(
276
+ '--diagram-expanded-body-height',
277
+ ),
278
+ );
279
+ const target = open
280
+ ? Math.min(
281
+ body.scrollHeight,
282
+ Number.isFinite(expandedLimit) ? expandedLimit : Number.POSITIVE_INFINITY,
283
+ )
284
+ : 0;
285
+ const finish = () => {
286
+ cancelAnimation();
287
+ details.open = open;
288
+ body.style.maxHeight = open ? '' : '0px';
289
+ if (active?.figure === figure) {
290
+ active.placeholder.style.height = `${figure.getBoundingClientRect().height}px`;
291
+ }
292
+ evaluate();
293
+ active?.reposition();
294
+ };
295
+ transitionEnd = (event) => {
296
+ if (event.target === body && event.propertyName === 'max-height') finish();
297
+ };
298
+ body.addEventListener('transitionend', transitionEnd);
299
+ finishTimer = window.setTimeout(finish, ANIMATION_MS + 60);
300
+ requestAnimationFrame(() => {
301
+ body.style.maxHeight = `${target}px`;
302
+ });
303
+ };
304
+
305
+ summary.addEventListener('click', (event) => {
306
+ event.preventDefault();
307
+ setOpen(details.dataset.state !== 'open');
308
+ });
309
+ }
310
+
311
+ function setup(): void {
312
+ for (const figure of document.querySelectorAll<HTMLElement>(
313
+ '.diagram[data-diagram-expandable]',
314
+ )) {
315
+ if (figure.dataset.diagramExpandReady === 'true') continue;
316
+ const details = figure.querySelector<HTMLDetailsElement>(
317
+ '[data-diagram-details]',
318
+ );
319
+ const body = figure.querySelector<HTMLElement>('[data-diagram-body]');
320
+ const toolbar = figure.querySelector<HTMLElement>('[data-diagram-toolbar]');
321
+ const button = figure.querySelector<HTMLButtonElement>('[data-diagram-expand]');
322
+ const scroll = figure.querySelector<HTMLElement>('.diagram-scroll');
323
+ if (!details || !body || !toolbar || !button || !scroll) continue;
324
+ figure.dataset.diagramExpandReady = 'true';
325
+
326
+ const evaluate = () =>
327
+ evaluateOverflow(figure, details, toolbar, button, scroll);
328
+ button.addEventListener('click', () => {
329
+ if (active?.figure === figure) collapseExpansion();
330
+ else expand(figure, button, scroll, evaluate);
331
+ });
332
+ new ResizeObserver(evaluate).observe(scroll);
333
+ details.addEventListener('toggle', evaluate);
334
+ wireDisclosure(figure, details, body, evaluate);
335
+ updateExpandButton(figure, button, false);
336
+ evaluate();
337
+ }
338
+ }
339
+
340
+ setup();
341
+ window.addEventListener('explico:prepare-static-export', () => {
342
+ collapseExpansion({ restoreFocus: false });
343
+ });
344
+ document.addEventListener('astro:page-load', setup);
345
+ document.addEventListener('keydown', (event) => {
346
+ if (event.key === 'Escape' && active) collapseExpansion();
347
+ });
348
+ </script>
349
+
350
+ <style is:global>
351
+ .diagram-is-expanded {
352
+ /* Above the side rails, but below Starlight's sticky site header. */
353
+ z-index: calc(var(--sl-z-index-navbar) - 1);
354
+ margin: 0 !important;
355
+ max-width: none !important;
356
+ contain: none !important;
357
+ overflow-y: hidden;
358
+ background: var(--sl-color-bg);
359
+ box-shadow: var(--sl-shadow-lg);
360
+ }
361
+
362
+ .diagram-is-expanded .diagram__titlebar,
363
+ .diagram-is-expanded .diagram__details {
364
+ max-height: inherit;
365
+ }
366
+
367
+ .diagram-is-expanded .diagram__details {
368
+ display: flex;
369
+ flex-direction: column;
370
+ }
371
+
372
+ .diagram-is-expanded .diagram__body {
373
+ min-height: 0;
374
+ max-height: var(--diagram-expanded-body-height);
375
+ flex: 1 1 auto;
376
+ overflow-x: hidden;
377
+ overflow-y: auto;
378
+ }
379
+
380
+ @media print {
381
+ .diagram-expand-placeholder {
382
+ display: none;
383
+ }
384
+ }
385
+ </style>
@@ -19,6 +19,9 @@
19
19
  * won't navigate to a fragment that's already current;
20
20
  * - moving keyboard focus onto the heading so screen-reader users land on it.
21
21
  *
22
+ * `sectionParts()` (the section-boundary walk) lives in `./section-parts.ts`,
23
+ * shared with `CollapsibleSections.astro`.
24
+ *
22
25
  * It deliberately does *not* intercept an ordinary click that changes the
23
26
  * hash. `:target` is set only by a genuine same-document fragment
24
27
  * navigation — never by `history.pushState` (a common trap: pushState
@@ -33,11 +36,12 @@
33
36
  ---
34
37
 
35
38
  <script>
39
+ import { HEADINGS, sectionParts } from './section-parts';
40
+
36
41
  (() => {
37
42
  const root = document.querySelector('.sl-markdown-content');
38
43
  if (!root) return;
39
44
 
40
- const HEADINGS = 'h1, h2, h3, h4, h5, h6';
41
45
  const reduce = window.matchMedia('(prefers-reduced-motion: reduce)');
42
46
 
43
47
  const isContentHeading = (el: Element | null): el is HTMLElement =>
@@ -70,22 +74,6 @@
70
74
  );
71
75
  };
72
76
 
73
- const sectionParts = (heading: HTMLElement): Element[] => {
74
- const wrapper = heading.closest('.sl-heading-wrapper') ?? heading;
75
- const rank = Number(heading.tagName[1]);
76
- const parts: Element[] = [wrapper];
77
- let node = wrapper.nextElementSibling;
78
- while (node) {
79
- const inner = node.matches(HEADINGS)
80
- ? node
81
- : node.querySelector(HEADINGS);
82
- if (inner && Number(inner.tagName[1]) <= rank) break;
83
- parts.push(node);
84
- node = node.nextElementSibling;
85
- }
86
- return parts;
87
- };
88
-
89
77
  const activate = (heading: HTMLElement | null, doScroll: boolean) => {
90
78
  if (!heading) return;
91
79
  const parts = sectionParts(heading);
@@ -1,13 +1,14 @@
1
1
  ---
2
2
  /**
3
3
  * Progressive enhancement for keyed floats — equations (Step D7), extended
4
- * to `<Table>`/`<Figure>` (F-tables-figures). Generalizes the original
4
+ * to `<Table>`/`<Figure>`/`<Diagram>`. Generalizes the original
5
5
  * equation-only `EquationEnhancer`: the scroll/focus/flash mechanics are
6
6
  * unchanged, parameterized over `KINDS` below so a deep-link to `#eq-`,
7
- * `#tab-`, or `#fig-` is handled the same way.
7
+ * `#tab-`, `#fig-`, or `#dia-` is handled the same way.
8
8
  *
9
9
  * The no-JS baseline is complete: `remark-notation` emits a real
10
- * `id="eq-<key>"` / `id="tab-<key>"` / `id="fig-<key>"` container, a
10
+ * `id="eq-<key>"` / `id="tab-<key>"` / `id="fig-<key>"` /
11
+ * `id="dia-<key>"` container, a
11
12
  * focusable number `<a>`, and a `[[…]]` reference renders a plain anchor;
12
13
  * `global.css` does the `:target` deep-link highlight (with a
13
14
  * `prefers-reduced-motion` fallback) and the sticky-header scroll offset via
@@ -69,6 +70,12 @@
69
70
  numberSelector: '.keyed-figure__number',
70
71
  reveal: false,
71
72
  },
73
+ {
74
+ prefix: '#dia-',
75
+ triggerSelector: 'a.diagram-ref, a.diagram__title-anchor',
76
+ numberSelector: '.diagram__number',
77
+ reveal: false,
78
+ },
72
79
  ];
73
80
  const TRIGGER_SELECTOR = KINDS.map((kind) => kind.triggerSelector).join(', ');
74
81
  const kindForHash = (hash: string) =>
@@ -123,6 +130,77 @@
123
130
  }
124
131
  });
125
132
 
133
+ // "Copy LaTeX" for an equation, two ways: its own button (only a numbered
134
+ // display equation has one — `data-eq-copy`, `remark-notation.mjs`'s
135
+ // `equationNode`), or a click anywhere else in the equation that isn't a
136
+ // symbol with its own notation card (an operator, a bare number, a
137
+ // parenthesis, …) — the same gesture works on an inline equation too
138
+ // (`inlineEquationNode`), which has no room for a persistent button of
139
+ // its own without breaking the reading line. A notation-registry symbol
140
+ // keeps its existing click-to-pin behavior either way
141
+ // (`NotationLayer.astro`), so this skips anything under `[data-notation-key]`.
142
+ root.addEventListener('click', (event) => {
143
+ const origin = event.target instanceof Element ? event.target : null;
144
+ if (!origin) return;
145
+ if (origin.closest('a.keyed-equation__number, [data-notation-key]')) {
146
+ return;
147
+ }
148
+
149
+ const ownButton = origin.closest<HTMLButtonElement>('[data-eq-copy]');
150
+ const container = ownButton
151
+ ? null
152
+ : origin.closest<HTMLElement>('.keyed-equation, .inline-equation');
153
+ if (!ownButton && (!container || !origin.closest('.katex-html'))) {
154
+ return;
155
+ }
156
+
157
+ const feedbackTarget =
158
+ ownButton ??
159
+ container?.querySelector<HTMLElement>('.keyed-equation__copy') ??
160
+ container;
161
+ const latex =
162
+ ownButton?.dataset.latex ?? feedbackTarget?.dataset.latex ?? '';
163
+ if (!feedbackTarget || latex.length === 0 || !navigator.clipboard) {
164
+ return;
165
+ }
166
+
167
+ navigator.clipboard.writeText(latex).then(
168
+ () => {
169
+ feedbackTarget.dataset.copied = 'true';
170
+ window.setTimeout(() => {
171
+ delete feedbackTarget.dataset.copied;
172
+ }, 1200);
173
+ },
174
+ () => {
175
+ /* Clipboard blocked: nothing more we can do here. */
176
+ },
177
+ );
178
+ });
179
+
180
+ // A tooltip for the gesture above, and only for the part of the equation
181
+ // it actually applies to — an explained symbol has its own hover card
182
+ // (`NotationLayer.astro`) and needs no tooltip layered on top of it.
183
+ // `mouseover`/`mouseout` (not `mouseenter`/`mouseleave`) because they
184
+ // bubble, which delegation on `root` needs.
185
+ root.addEventListener('mouseover', (event) => {
186
+ const origin = event.target instanceof Element ? event.target : null;
187
+ if (!origin || origin.getAttribute('title') === 'Copy LaTeX') return;
188
+ if (origin.closest('[data-notation-key], [data-eq-copy]')) return;
189
+ if (
190
+ !origin.closest('.keyed-equation .katex-html, .inline-equation .katex-html')
191
+ ) {
192
+ return;
193
+ }
194
+ origin.setAttribute('title', 'Copy LaTeX');
195
+ });
196
+
197
+ root.addEventListener('mouseout', (event) => {
198
+ const origin = event.target instanceof Element ? event.target : null;
199
+ if (origin?.getAttribute('title') === 'Copy LaTeX') {
200
+ origin.removeAttribute('title');
201
+ }
202
+ });
203
+
126
204
  window.addEventListener('hashchange', () => {
127
205
  const kind = kindForHash(location.hash);
128
206
  if (!kind) return;
@@ -40,6 +40,39 @@ import DefaultHeader from '@astrojs/starlight/components/Header.astro';
40
40
 
41
41
  const root = document.documentElement;
42
42
 
43
+ /**
44
+ * `.main-pane`'s width formula (`global.css`) caps itself against
45
+ * `var(--toc-actual-width, ...)` so it never grows into the "On this
46
+ * page" rail — `.right-sidebar` is edge-anchored (`inset-inline-end: 0`)
47
+ * rather than positioned relative to `.main-pane`, so nothing else
48
+ * guarantees that any more. A worst-case CSS estimate alone would have
49
+ * to assume the rail's own 80ch cap at all times, which is far wider
50
+ * than any actual page's headings need and would cap `.main-pane`
51
+ * well short of where it could safely go. Measuring the rail's real
52
+ * rendered width here and writing it to `--toc-actual-width` lets the
53
+ * cap match reality instead — on a typical page of short headings this
54
+ * ends up nowhere near binding, so `.main-pane` keeps the same width
55
+ * Starlight's own formula already gave it.
56
+ *
57
+ * 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.
62
+ */
63
+ function updateTocReservation(): void {
64
+ const panel = document.querySelector<HTMLElement>('.right-sidebar');
65
+ if (!panel) return;
66
+ // Only meaningful for the open, two-column desktop layout — collapsed
67
+ // (`data-page-sidebar-hidden`) uses its own edge-rail width formula
68
+ // that never reads this variable, and below the breakpoint there's no
69
+ // `.right-sidebar` column to cap `.main-pane` against at all.
70
+ if (root.hasAttribute('data-page-sidebar-hidden')) return;
71
+ if (!window.matchMedia('(min-width: 72rem)').matches) return;
72
+ const width = panel.getBoundingClientRect().width;
73
+ if (width > 0) root.style.setProperty('--toc-actual-width', `${width}px`);
74
+ }
75
+
43
76
  // The one seam for per-viewer UI preferences (Phase H1). This component never
44
77
  // touches browser storage directly; a future SSR host swaps in a different
45
78
  // PreferenceStore and nothing here changes.
@@ -80,6 +113,10 @@ import DefaultHeader from '@astrojs/starlight/components/Header.astro';
80
113
  const hidden = !root.hasAttribute(settings.attribute);
81
114
  applyState(button, region, hidden);
82
115
  writePreference(settings.storageKey, hidden);
116
+ // The initial measurement below skips a rail that starts collapsed
117
+ // (there's nothing on screen yet to measure); catch it here the
118
+ // first time it actually opens.
119
+ if (region === 'contents' && !hidden) updateTocReservation();
83
120
 
84
121
  // A pointer click should leave a newly collapsed rail collapsed even
85
122
  // though the pointer was inside the full-width panel when clicked.
@@ -99,4 +136,17 @@ import DefaultHeader from '@astrojs/starlight/components/Header.astro';
99
136
  }
100
137
  });
101
138
  });
139
+
140
+ updateTocReservation();
141
+ let tocReservationResizeTimer: number | null = null;
142
+ window.addEventListener(
143
+ 'resize',
144
+ () => {
145
+ if (tocReservationResizeTimer !== null) {
146
+ window.clearTimeout(tocReservationResizeTimer);
147
+ }
148
+ tocReservationResizeTimer = window.setTimeout(updateTocReservation, 150);
149
+ },
150
+ { passive: true },
151
+ );
102
152
  </script>
@@ -1,12 +1,51 @@
1
1
  ---
2
2
  /**
3
3
  * Starlight allows one `SocialIcons` override, so this composes the stock
4
- * social-link row with the `[?]` About control (`AboutPanel.astro`). Both sit in
5
- * the header's right cluster; the About panel is present on every route.
4
+ * social-link row with lesson export and `[?]` About controls. They sit in the
5
+ * header's right cluster; the About panel is present on every route.
6
6
  */
7
7
  import DefaultSocialIcons from '@astrojs/starlight/components/SocialIcons.astro';
8
+ import { loadManifest } from '../../compiler/manifest';
9
+ import {
10
+ isLessonSlug,
11
+ lessonIdFromSlug,
12
+ } from '../../compiler/lesson-derivation';
8
13
  import AboutPanel from './AboutPanel.astro';
14
+ import LessonExportActions from './LessonExportActions.astro';
15
+
16
+ const routeSlug = Astro.locals.starlightRoute.entry.id;
17
+ const lessonId = isLessonSlug(routeSlug)
18
+ ? lessonIdFromSlug(routeSlug)
19
+ : undefined;
20
+ let standaloneDownload:
21
+ | { readonly href: string; readonly filename: string }
22
+ | undefined;
23
+ let printEnabled = false;
24
+ let canonicalBaseUrl: string | undefined;
25
+ if (lessonId) {
26
+ const manifest = await loadManifest();
27
+ const lesson = manifest.lessons.find((entry) => entry.id === lessonId);
28
+ printEnabled = lesson !== undefined && manifest.lessonExports.print;
29
+ canonicalBaseUrl = manifest.lessonExports.canonicalBaseUrl;
30
+ if (lesson && manifest.lessonExports.standaloneHtml) {
31
+ const base = import.meta.env.BASE_URL.replace(/\/$/, '');
32
+ standaloneDownload = {
33
+ href: `${base}/downloads/${lesson.slug}.html`,
34
+ filename: `${lesson.slug.split('/').at(-1) ?? 'lesson'}.html`,
35
+ };
36
+ }
37
+ }
9
38
  ---
10
39
 
11
40
  <DefaultSocialIcons />
12
41
  <AboutPanel />
42
+ {
43
+ (standaloneDownload || printEnabled) && (
44
+ <LessonExportActions
45
+ standaloneDownload={standaloneDownload}
46
+ print={printEnabled}
47
+ canonicalBaseUrl={canonicalBaseUrl}
48
+ siteBaseUrl={import.meta.env.BASE_URL}
49
+ />
50
+ )
51
+ }