explico 0.3.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 (151) hide show
  1. package/CHANGELOG.md +344 -1
  2. package/README.md +117 -3
  3. package/dist/compiler/astro.d.ts +38 -0
  4. package/dist/compiler/astro.js +258 -0
  5. package/dist/compiler/cli.d.ts +13 -0
  6. package/dist/compiler/cli.js +32 -12
  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 +86 -4
  10. package/dist/compiler/course-config.js +39 -0
  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/lesson-derivation.d.ts +1 -1
  14. package/dist/compiler/lesson-derivation.js +2 -2
  15. package/dist/compiler/manifest.d.ts +112 -7
  16. package/dist/compiler/manifest.js +205 -40
  17. package/dist/compiler/resolution-report.d.ts +4 -0
  18. package/dist/reference/base-abbreviations.d.mts +30 -0
  19. package/dist/reference/base-abbreviations.mjs +60 -0
  20. package/dist/reference/base-operators.d.mts +15 -0
  21. package/dist/reference/base-operators.mjs +40 -0
  22. package/dist/reference/base-roman-labels.d.mts +35 -0
  23. package/dist/reference/base-roman-labels.mjs +43 -0
  24. package/dist/reference/consistency.d.ts +8 -2
  25. package/dist/reference/consistency.js +50 -3
  26. package/dist/reference/diagrams-validate.d.ts +21 -0
  27. package/dist/reference/diagrams-validate.js +13 -0
  28. package/dist/reference/equations-validate.d.ts +8 -3
  29. package/dist/reference/equations-validate.js +1 -1
  30. package/dist/reference/equations.d.mts +66 -35
  31. package/dist/reference/equations.mjs +142 -40
  32. package/dist/reference/gate-abbreviations.d.ts +17 -0
  33. package/dist/reference/gate-abbreviations.js +64 -0
  34. package/dist/reference/gate-math.d.ts +28 -5
  35. package/dist/reference/gate-math.js +5 -2
  36. package/dist/reference/gate-prose-notation.d.ts +60 -0
  37. package/dist/reference/gate-prose-notation.js +125 -0
  38. package/dist/reference/keyed-float-validate.d.ts +14 -8
  39. package/dist/reference/keyed-float-validate.js +1 -1
  40. package/dist/reference/keyed-jsx-floats.d.mts +24 -21
  41. package/dist/reference/keyed-jsx-floats.mjs +90 -41
  42. package/dist/reference/knowledge-checks-validate.d.ts +35 -0
  43. package/dist/reference/knowledge-checks-validate.js +78 -0
  44. package/dist/reference/knowledge-checks.d.mts +25 -0
  45. package/dist/reference/knowledge-checks.mjs +45 -0
  46. package/dist/reference/lab-math-scope.d.ts +9 -1
  47. package/dist/reference/lab-math-scope.js +32 -4
  48. package/dist/reference/math-glyphs.d.mts +30 -0
  49. package/dist/reference/math-glyphs.mjs +367 -0
  50. package/dist/reference/mdx-attributes.d.mts +13 -0
  51. package/dist/reference/mdx-attributes.mjs +38 -0
  52. package/dist/reference/mixed-content.d.ts +17 -0
  53. package/dist/reference/mixed-content.js +29 -0
  54. package/dist/reference/page-notation.d.ts +52 -0
  55. package/dist/reference/page-notation.js +171 -0
  56. package/dist/reference/raw-floats-validate.d.ts +18 -0
  57. package/dist/reference/raw-floats-validate.js +53 -0
  58. package/dist/reference/raw-floats.d.mts +18 -0
  59. package/dist/reference/raw-floats.mjs +129 -0
  60. package/dist/reference/rehype-heading-anchors.d.mts +1 -1
  61. package/dist/reference/rehype-heading-anchors.mjs +107 -1
  62. package/dist/reference/rehype-table-header-labels.d.mts +26 -0
  63. package/dist/reference/rehype-table-header-labels.mjs +152 -0
  64. package/dist/reference/remark-abbreviations.d.mts +7 -0
  65. package/dist/reference/remark-abbreviations.mjs +174 -0
  66. package/dist/reference/remark-memo.d.mts +16 -0
  67. package/dist/reference/remark-memo.mjs +339 -0
  68. package/dist/reference/remark-notation.d.mts +102 -5
  69. package/dist/reference/remark-notation.mjs +506 -50
  70. package/dist/reference/remark-short-note.d.mts +4 -0
  71. package/dist/reference/remark-short-note.mjs +237 -0
  72. package/dist/reference/types.d.ts +16 -1
  73. package/dist/reference/units-vocab.js +4 -0
  74. package/docs/VERSIONING.md +33 -4
  75. package/docs/authoring/README.md +12 -0
  76. package/docs/authoring/agents.md +17 -2
  77. package/docs/authoring/components.md +135 -0
  78. package/docs/authoring/migrating.md +154 -7
  79. package/docs/authoring/notation.md +568 -8
  80. package/docs/authoring/structure.md +251 -0
  81. package/docs/authoring/visualizations.md +36 -3
  82. package/package.json +5 -2
  83. package/src/compiler/astro.ts +332 -0
  84. package/src/compiler/cli.ts +33 -10
  85. package/src/compiler/collections.ts +45 -4
  86. package/src/compiler/course-config.ts +131 -3
  87. package/src/compiler/exports/standalone-html.ts +963 -0
  88. package/src/compiler/lesson-derivation.ts +2 -2
  89. package/src/compiler/manifest.ts +367 -49
  90. package/src/components/HoverPanel.astro +45 -4
  91. package/src/components/abbreviation/AbbreviationGlossary.astro +70 -0
  92. package/src/components/abbreviation/AbbreviationLayer.astro +127 -0
  93. package/src/components/assessments/AssessmentRunner.tsx +52 -17
  94. package/src/components/assessments/AssessmentSet.astro +98 -14
  95. package/src/components/assessments/RenderedContent.tsx +55 -0
  96. package/src/components/assessments/assessment-randomization.ts +16 -0
  97. package/src/components/diagram/Diagram.astro +444 -83
  98. package/src/components/disclosure/hover-preview.ts +202 -0
  99. package/src/components/disclosure/persisted-disclosure.ts +66 -0
  100. package/src/components/examples/CompactExamples.astro +2 -0
  101. package/src/components/figure/Figure.astro +279 -10
  102. package/src/components/memo/Memo.astro +453 -0
  103. package/src/components/memo/ShortNote.astro +223 -0
  104. package/src/components/notation/NotationFormula.astro +18 -1
  105. package/src/components/notation/NotationLayer.astro +116 -8
  106. package/src/components/notation/render-notation-math.ts +39 -2
  107. package/src/components/notation/safe-math-to-react.ts +50 -0
  108. package/src/components/reference/FloatRefLayer.astro +233 -15
  109. package/src/components/starlight/CollapsibleSections.astro +461 -0
  110. package/src/components/starlight/DiagramEnhancer.astro +385 -0
  111. package/src/components/starlight/HeadingAnchorEnhancer.astro +5 -17
  112. package/src/components/starlight/KeyedFloatEnhancer.astro +81 -3
  113. package/src/components/starlight/LayoutHeader.astro +50 -0
  114. package/src/components/starlight/LayoutSidebar.astro +62 -1
  115. package/src/components/starlight/LayoutSocialIcons.astro +41 -2
  116. package/src/components/starlight/LessonExportActions.astro +436 -0
  117. package/src/components/starlight/LessonFooter.astro +38 -2
  118. package/src/components/starlight/section-parts.ts +31 -0
  119. package/src/components/table/Table.astro +296 -9
  120. package/src/components/table/TableOverflowExpander.astro +489 -0
  121. package/src/content-config.ts +20 -0
  122. package/src/reference/base-abbreviations.mjs +60 -0
  123. package/src/reference/base-operators.mjs +40 -0
  124. package/src/reference/base-roman-labels.mjs +43 -0
  125. package/src/reference/consistency.ts +73 -2
  126. package/src/reference/diagrams-validate.ts +40 -0
  127. package/src/reference/equations-validate.ts +6 -4
  128. package/src/reference/equations.mjs +142 -40
  129. package/src/reference/gate-abbreviations.ts +128 -0
  130. package/src/reference/gate-math.ts +27 -7
  131. package/src/reference/gate-prose-notation.ts +224 -0
  132. package/src/reference/keyed-float-validate.ts +12 -9
  133. package/src/reference/keyed-jsx-floats.mjs +90 -41
  134. package/src/reference/knowledge-checks-validate.ts +130 -0
  135. package/src/reference/knowledge-checks.mjs +45 -0
  136. package/src/reference/lab-math-scope.ts +45 -3
  137. package/src/reference/math-glyphs.mjs +367 -0
  138. package/src/reference/mdx-attributes.mjs +38 -0
  139. package/src/reference/mixed-content.ts +69 -0
  140. package/src/reference/page-notation.ts +252 -0
  141. package/src/reference/raw-floats-validate.ts +103 -0
  142. package/src/reference/raw-floats.mjs +129 -0
  143. package/src/reference/rehype-heading-anchors.mjs +107 -1
  144. package/src/reference/rehype-table-header-labels.mjs +152 -0
  145. package/src/reference/remark-abbreviations.mjs +174 -0
  146. package/src/reference/remark-memo.mjs +339 -0
  147. package/src/reference/remark-notation.mjs +506 -50
  148. package/src/reference/remark-short-note.mjs +237 -0
  149. package/src/reference/types.ts +16 -0
  150. package/src/reference/units-vocab.ts +4 -0
  151. package/src/styles/global.css +1125 -50
@@ -0,0 +1,489 @@
1
+ ---
2
+ /**
3
+ * Table column narrowing + expand-on-overflow
4
+ * (docs/plans/collapsible-disclosure.md Part 4) — a runtime enhancer, wired
5
+ * from `LessonFooter.astro` alongside the other lesson-page enhancers, the
6
+ * same progressive-enhancement approach `CollapsibleSections.astro` and
7
+ * `HeadingAnchorEnhancer.astro` already use.
8
+ *
9
+ * **Structure**: each candidate container (`.keyed-table__body` from
10
+ * `Table.astro`, or `.table-overflow-body` — created at runtime around a
11
+ * bare markdown table that has none) is turned into a flex row. Normally
12
+ * that row is `[.table-overflow-scroll, .table-overflow-expand-btn]` — the
13
+ * button, present only once the table actually overflows
14
+ * (`evaluateOverflow` toggles `hidden`), is a plain flex sibling rather than
15
+ * an overlay, so `align-items: stretch` (the flex default) makes it exactly
16
+ * as tall as the table for free, and it can never sit on top of table
17
+ * content. While expanded, a second button is prepended — the row becomes
18
+ * `[collapse, .table-overflow-scroll, collapse]`, the "0th and n+1th
19
+ * column" — so *both* collapse affordances are real layout columns the
20
+ * table's own content is pushed clear of, never floating overlays that
21
+ * could cover a cell. `.table-overflow-expand-btn:first-child`/`:last-child`
22
+ * in `global.css` flip which side gets the divider border; the two buttons
23
+ * otherwise share one class. Row striping/hover-highlight
24
+ * (`tbody tr:nth-child(even)`/`:hover`) is a separate, unconditional rule in
25
+ * `global.css` — plain CSS, so it applies with or without this script.
26
+ *
27
+ * **Why a dedicated `.table-overflow-scroll` wrapper**: Starlight's own
28
+ * `markdown.css` gives *every* `<table>` under `.sl-markdown-content` its
29
+ * own `display: block; overflow: auto` — its own independent scroll
30
+ * container. Left in place inside `.table-overflow-scroll`, that inner rule
31
+ * would win first and clip/scroll the table before this wrapper ever saw
32
+ * overflow, leaving `scrollWidth > clientWidth` on it permanently false.
33
+ * `global.css` undoes that per-table rule specifically *inside*
34
+ * `.table-overflow-scroll`, so there is exactly one real scroll surface —
35
+ * the one this component measures and, on click, enlarges. A table with no
36
+ * JS at all never gets this wrapper and keeps Starlight's default per-table
37
+ * scroll as its fallback, unmodified. Column *sizing* was never the bug —
38
+ * `table-layout` is `auto` either way, so columns already size to content —
39
+ * the bug was only about which element ends up owning the scrollbar.
40
+ *
41
+ * A bare markdown table gets the same `.table-overflow-scroll` treatment
42
+ * inside a runtime-created `.table-overflow-body`, skipping anything
43
+ * already inside a `<Table>` or a lab island's own `.lab-data-table`
44
+ * (`ProjectileRangeExplorer.tsx` and friends already give that a dedicated,
45
+ * deliberately-always-scrolling container; wrapping it again would nest two
46
+ * independent scroll containers around the same rows).
47
+ *
48
+ * **Expand**: on click, the actual node that moves to `<body>` and becomes
49
+ * `position: fixed` is `target` — the whole `<figure class="keyed-table">`
50
+ * when there is one (so its `<figcaption>` travels with the expanded view
51
+ * instead of being left behind, orphaned, in the narrow column), or the
52
+ * flex-row container itself for a bare table with no figure. `target`'s
53
+ * left edge lands at the live `.sidebar-pane` edge — not the
54
+ * `--sl-sidebar-width`/`--__toc-width` CSS vars, which don't reflect the
55
+ * sidebar's real, responsive visibility, per `FloatRefLayer.astro`'s
56
+ * `horizontalRegion()`. Its width is only as wide as the table and its two
57
+ * button columns actually need (`.table-overflow-scroll`'s `scrollWidth`,
58
+ * measured before anything moves — that reports the full unclipped content
59
+ * width regardless of how narrow the wrapper currently renders it, plus
60
+ * both `SIDE_COLUMN_PX` button widths). Unlike `FloatRefLayer.astro`'s own
61
+ * card, which deliberately ignores the "On this page" rail's column
62
+ * entirely, this prefers *not* claiming that space (`tocLeftBound()`):
63
+ * offered the choice, it centers itself between the left nav and the
64
+ * rail's left edge, and only spills past that edge — as far as the
65
+ * viewport edge, if it must — once the table genuinely doesn't fit without
66
+ * doing so. That handoff is a continuous function of window width, not a
67
+ * branch between two different layouts: see the comment on `rightBound` in
68
+ * `reposition()` for why nothing jumps at the point where the table starts
69
+ * spilling past the rail. A zero-height placeholder left behind at
70
+ * the original DOM position keeps
71
+ * the page from reflowing, and is swapped back in on collapse
72
+ * (`placeholder.replaceWith(target)`), restoring the exact original spot.
73
+ * Moving the real node rather than `position: fixed`-ing it in place is
74
+ * required, not stylistic: `.main-pane` sets `isolation: isolate`
75
+ * (`TwoColumnContent.astro`), which traps any z-index attempt to paint over
76
+ * the "On this page" rail — `FloatRefLayer.astro` hit the identical trap
77
+ * and documents the same escape.
78
+ *
79
+ * Moving `target` out to `<body>` also moves it out of `.sl-markdown-content`
80
+ * — which is where every bit of Starlight's own table styling (row-line
81
+ * borders, header color) and this file's own zebra-stripe/hover rule live,
82
+ * since they're all written as `.sl-markdown-content …` descendant
83
+ * selectors. Rather than duplicate that styling, `target` is wrapped in a
84
+ * throwaway `<div class="sl-markdown-content">` (itself `display: contents`,
85
+ * so it never generates a box of its own or affects layout) before being
86
+ * appended to `<body>` — a genuine ancestor for CSS purposes, torn down
87
+ * again on collapse.
88
+ *
89
+ * The expanded table tracks the *placeholder's* live position on
90
+ * scroll/resize (where the table would be, were it still in flow) exactly,
91
+ * unclamped — it scrolls with the page the same as the in-flow element it
92
+ * stands in for, sliding up past the header and out the top of the
93
+ * viewport rather than sticking there. Only once that position has
94
+ * scrolled entirely out of the visible band does the expanded table hide
95
+ * (`visibility: hidden`) outright — there is no reason to keep any part of
96
+ * it rendered once the reader has scrolled past it.
97
+ *
98
+ * Only one table is ever expanded at a time, and expanding one closes
99
+ * whichever other overlay — a hover-preview card, a notation popup — was
100
+ * open, via the same `claimActiveSurface`/`releaseActiveSurface` arbiter
101
+ * `FloatRefLayer.astro` and the citation/notation layers already share. That
102
+ * claim is released again immediately rather than held, though: unlike
103
+ * those layers, the table doesn't stay registered as the arbiter's active
104
+ * surface while expanded, so a hover card opening *afterward* — over an
105
+ * explainable quantity inside the table itself, say — can't turn around and
106
+ * force the table closed the way claiming the arbiter normally would. The
107
+ * table closes only through its own explicit gestures — the collapse
108
+ * buttons or Escape — and deliberately *not* an outside click: a reader
109
+ * following a link in the "On this page" rail, or clicking anywhere else to
110
+ * keep reading, should find the table exactly as they left it, not
111
+ * collapsed back to its normal size out from under them.
112
+ *
113
+ * No persisted state here (unlike Parts 1/3): expand/collapse is transient
114
+ * per-view UI, not a reader preference worth remembering across visits.
115
+ */
116
+ ---
117
+
118
+ <script>
119
+ import { claimActiveSurface, releaseActiveSurface } from "../hover-panel";
120
+
121
+ const GROUP = "table-overflow";
122
+ const EDGE_PX = 16; // ~1em at the default root font size
123
+ const WIDTH_BUFFER_PX = 2; // rounding slack so the minimal width never re-triggers a scrollbar
124
+ const SIDE_COLUMN_PX = 24; // must match `.table-overflow-expand-btn`'s `width: 1.5rem`
125
+
126
+ interface Expansion {
127
+ container: HTMLElement;
128
+ target: HTMLElement;
129
+ scroll: HTMLElement;
130
+ placeholder: HTMLElement;
131
+ spoofWrapper: HTMLElement;
132
+ button: HTMLButtonElement;
133
+ leadingButton: HTMLButtonElement;
134
+ reposition: () => void;
135
+ }
136
+
137
+ let active: Expansion | null = null;
138
+
139
+ function sidebarLeftBound(): number {
140
+ const sidebar = document.querySelector<HTMLElement>(".sidebar-pane");
141
+ const visible =
142
+ !!sidebar && getComputedStyle(sidebar).visibility !== "hidden";
143
+ return visible ? sidebar!.getBoundingClientRect().right + EDGE_PX : EDGE_PX;
144
+ }
145
+
146
+ // Left edge of the "On this page" rail's own rendered box (not
147
+ // `.right-sidebar-container`, the flex row's reserved-but-empty column
148
+ // for it — `.right-sidebar` is `position: fixed`, so its real box can sit
149
+ // narrower than that reservation, and it's the real box this needs to
150
+ // stay clear of), but only while it's actually shown — not the desktop
151
+ // breakpoint's collapsed edge rail (`data-page-sidebar-hidden`, mirroring
152
+ // the left nav's own collapsed-rail affordance) and not the narrow-
153
+ // viewport layout, where there's no real right column at all. `null`
154
+ // means "no rail to avoid" — the table may use the full
155
+ // sidebar-to-viewport-edge region, same as before.
156
+ function tocLeftBound(): number | null {
157
+ const toc = document.querySelector<HTMLElement>(".right-sidebar");
158
+ if (!toc) return null;
159
+ if (document.documentElement.hasAttribute("data-page-sidebar-hidden")) {
160
+ return null;
161
+ }
162
+ if (!window.matchMedia("(min-width: 72rem)").matches) return null;
163
+ return toc.getBoundingClientRect().left;
164
+ }
165
+
166
+ function captionFor(container: HTMLElement): string {
167
+ const figure = container.closest<HTMLElement>(".keyed-table");
168
+ const text = figure
169
+ ?.querySelector(".keyed-table__caption-text")
170
+ ?.textContent?.trim();
171
+ const number = figure?.dataset.tabNumber;
172
+ if (number && text) return `Table ${number} — ${text}`;
173
+ return text || "this table";
174
+ }
175
+
176
+ function updateButtonLabel(
177
+ container: HTMLElement,
178
+ button: HTMLButtonElement,
179
+ expanded: boolean,
180
+ ) {
181
+ button.textContent = expanded ? "⟨" : "⇔";
182
+ button.setAttribute(
183
+ "aria-label",
184
+ expanded
185
+ ? `Collapse ${captionFor(container)}`
186
+ : `Expand ${captionFor(container)} to full width`,
187
+ );
188
+ button.title = expanded ? "Collapse" : "Expand to full width";
189
+ button.setAttribute("aria-expanded", String(expanded));
190
+ }
191
+
192
+ // A scrollable region must itself be reachable by keyboard (WCAG 2.1.1 —
193
+ // axe's `scrollable-region-focusable`), so arrow keys can scroll it once
194
+ // focused; a `tabindex` is only added while it is actually scrollable, not
195
+ // unconditionally.
196
+ function updateScrollFocusability(
197
+ scroll: HTMLElement,
198
+ container: HTMLElement,
199
+ ) {
200
+ if (scroll.scrollWidth > scroll.clientWidth + 1) {
201
+ scroll.tabIndex = 0;
202
+ scroll.setAttribute(
203
+ "aria-label",
204
+ `Scrollable table: ${captionFor(container)}`,
205
+ );
206
+ } else {
207
+ scroll.removeAttribute("tabindex");
208
+ scroll.removeAttribute("aria-label");
209
+ }
210
+ }
211
+
212
+ function evaluateOverflow(
213
+ scroll: HTMLElement,
214
+ button: HTMLButtonElement,
215
+ container: HTMLElement,
216
+ ) {
217
+ if (active?.scroll === scroll) return; // never hide the one currently expanded
218
+ button.hidden = scroll.scrollWidth <= scroll.clientWidth + 1;
219
+ updateScrollFocusability(scroll, container);
220
+ }
221
+
222
+ function collapse() {
223
+ if (!active) return;
224
+ const {
225
+ container,
226
+ target,
227
+ scroll,
228
+ placeholder,
229
+ spoofWrapper,
230
+ button,
231
+ leadingButton,
232
+ reposition,
233
+ } = active;
234
+ window.removeEventListener("scroll", reposition, true);
235
+ window.removeEventListener("resize", reposition);
236
+ target.classList.remove("table-overflow-expanded");
237
+ target.style.cssText = "";
238
+ leadingButton.remove();
239
+ placeholder.replaceWith(target);
240
+ spoofWrapper.remove();
241
+ active = null;
242
+ updateButtonLabel(container, button, false);
243
+ evaluateOverflow(scroll, button, container);
244
+ }
245
+
246
+ function expand(
247
+ container: HTMLElement,
248
+ button: HTMLButtonElement,
249
+ scroll: HTMLElement,
250
+ ) {
251
+ if (active) collapse();
252
+
253
+ // Close whatever other overlay (a hover-preview card, a notation popup)
254
+ // was open, via the same shared arbiter every layer uses — but
255
+ // immediately release the claim rather than leaving the table
256
+ // registered as the arbiter's active surface. If it stayed registered,
257
+ // a hover card opening *afterward* over the table's own content (an
258
+ // explainable quantity inside it, say) would claim the arbiter in turn
259
+ // and force this table closed the same way a genuinely different
260
+ // overlay replacing it would — which is exactly backwards: the card
261
+ // should simply float above the still-open table (it already
262
+ // out-ranks it on z-index), and the table should close only through
263
+ // its own explicit gestures (the collapse buttons, an outside click, or
264
+ // Escape — all wired below, independent of this arbiter).
265
+ claimActiveSurface(GROUP, () => {});
266
+ releaseActiveSurface(GROUP);
267
+
268
+ // Measured *before* anything moves: `scrollWidth` reports the table's
269
+ // full, unclipped content width regardless of how narrow this wrapper
270
+ // currently renders it. Expanded, the row gains *two* button columns —
271
+ // the always-present trailing one plus the prepended leading one — so
272
+ // both must be budgeted for, not just one; short-changing this by even
273
+ // one column leaves `.table-overflow-scroll` a hair narrower than its
274
+ // content, which forces a needless horizontal scrollbar — and that
275
+ // scrollbar's own thickness then eats into the row's height, which can
276
+ // in turn push the whole figure past `maxHeight` and trigger a needless
277
+ // *vertical* scrollbar too.
278
+ const neededWidth =
279
+ scroll.scrollWidth + WIDTH_BUFFER_PX + SIDE_COLUMN_PX * 2;
280
+
281
+ // The whole referenceable figure (table + caption) moves together, not
282
+ // just its scrolling body — otherwise the caption is left behind,
283
+ // orphaned, in the narrow column while the table pops out beside it.
284
+ const target = container.closest<HTMLElement>(".keyed-table") ?? container;
285
+
286
+ const rect = target.getBoundingClientRect();
287
+ const placeholder = document.createElement("div");
288
+ placeholder.className = "table-overflow-placeholder";
289
+ placeholder.style.height = `${rect.height}px`;
290
+ target.replaceWith(placeholder);
291
+
292
+ // A genuine CSS ancestor (not a clone/relabel of `target` itself) so
293
+ // every `.sl-markdown-content …` rule — Starlight's row/border styling,
294
+ // this file's own zebra-stripe/hover rule — still applies once `target`
295
+ // is no longer a real descendant of the page's actual
296
+ // `.sl-markdown-content`. `display: contents` keeps it from generating
297
+ // any box of its own or affecting layout.
298
+ const spoofWrapper = document.createElement("div");
299
+ spoofWrapper.className = "sl-markdown-content";
300
+ spoofWrapper.setAttribute("role", "region");
301
+ spoofWrapper.setAttribute("aria-label", `Expanded ${captionFor(container)}`);
302
+ spoofWrapper.style.display = "contents";
303
+ document.body.appendChild(spoofWrapper);
304
+ spoofWrapper.appendChild(target);
305
+
306
+ target.classList.add("table-overflow-expanded");
307
+ // Preserve the target's native semantics while it is moved. The generated
308
+ // ancestor above supplies the landmark axe expects outside `<main>`;
309
+ // applying `role="region"` directly to a keyed table's `<figure>` would be
310
+ // invalid ARIA. Its `<figcaption>` continues to name the figure itself.
311
+
312
+ // The "0th column" — a second collapse button, prepended so the row
313
+ // reads as `[collapse, scroll, collapse]`, exactly mirroring the
314
+ // always-present trailing button. Both are real flex items, so table
315
+ // content is pushed clear of them rather than floating an overlay on
316
+ // top of it.
317
+ const leadingButton = document.createElement("button");
318
+ leadingButton.type = "button";
319
+ leadingButton.className = "table-overflow-expand-btn";
320
+ leadingButton.textContent = "⟩";
321
+ leadingButton.setAttribute(
322
+ "aria-label",
323
+ `Collapse ${captionFor(container)}`,
324
+ );
325
+ leadingButton.title = "Collapse";
326
+ leadingButton.addEventListener("click", collapse);
327
+ container.prepend(leadingButton);
328
+
329
+ const reposition = () => {
330
+ const headerBottom =
331
+ document.querySelector<HTMLElement>("header.header")?.getBoundingClientRect()
332
+ .bottom ?? 0;
333
+ const verticalStart = Math.max(EDGE_PX, headerBottom + EDGE_PX);
334
+ const placeholderRect = placeholder.getBoundingClientRect();
335
+
336
+ // Track where the table would be in normal flow; once that position
337
+ // has scrolled entirely past the visible band, there is nothing to
338
+ // keep on screen — hide rather than cling to the viewport edge.
339
+ const inView =
340
+ placeholderRect.bottom > verticalStart &&
341
+ placeholderRect.top < window.innerHeight;
342
+ target.style.visibility = inView ? "" : "hidden";
343
+ if (!inView) return;
344
+
345
+ // Tracks the placeholder's own top exactly, unclamped — the table
346
+ // scrolls with the page like the in-flow element it stands in for,
347
+ // sliding up and off the top edge as the reader scrolls past, rather
348
+ // than sticking at `verticalStart` and then vanishing outright once
349
+ // `inView` above finally goes false.
350
+ const top = placeholderRect.top;
351
+ const sidebarEdge = sidebarLeftBound();
352
+ const viewportEdge = window.innerWidth - EDGE_PX;
353
+ // The "On this page" rail's left edge is a *soft* boundary, not a hard
354
+ // one: the table would rather stay clear of it, but is willing to
355
+ // spill past it, as far as `viewportEdge`, if `neededWidth` doesn't
356
+ // fit before it. `Math.max(tocBoundary, sidebarEdge + neededWidth)`
357
+ // is what makes that soft — as the window narrows and `tocBoundary`
358
+ // creeps left of `sidebarEdge + neededWidth`, this expression stops
359
+ // tracking `tocBoundary` and holds steady at `sidebarEdge +
360
+ // neededWidth` instead (the table's own width doesn't change), so the
361
+ // handoff between "centered before the rail" and "spilling past it"
362
+ // has no seam to jump across — `left`/`width` below vary continuously
363
+ // with window size the whole way through, never snapping between two
364
+ // different formulas.
365
+ const tocBoundary = tocLeftBound();
366
+ const rightBound = Math.min(
367
+ viewportEdge,
368
+ Math.max(
369
+ tocBoundary !== null ? tocBoundary - EDGE_PX : viewportEdge,
370
+ sidebarEdge + neededWidth,
371
+ ),
372
+ );
373
+ const availableWidth = Math.max(rightBound - sidebarEdge, 80);
374
+ // Only as wide as the table (and its two button columns) needs, up to
375
+ // `rightBound` — never wider just because the room is there.
376
+ const width = Math.min(neededWidth, availableWidth);
377
+ // Centered between `sidebarEdge` and `rightBound`. While the table
378
+ // fits before the rail, that's a true center within the sidebar-to-
379
+ // rail gap. Once it doesn't, `rightBound` above has already opened up
380
+ // to exactly `sidebarEdge + neededWidth` (or `viewportEdge`, if even
381
+ // that overflows), so `availableWidth` equals `width` and this
382
+ // reduces to `sidebarEdge` — flush against the nav, extending only as
383
+ // far right as it has to, rather than centered across room it isn't
384
+ // using.
385
+ const left = sidebarEdge + (availableWidth - width) / 2;
386
+ target.style.position = "fixed";
387
+ target.style.top = `${top}px`;
388
+ target.style.left = `${left}px`;
389
+ target.style.width = `${width}px`;
390
+ // Sized against the visible top of the band (never above
391
+ // `verticalStart`, however far off-screen `top` itself has scrolled)
392
+ // so this stays a sane ceiling on the box's own height rather than
393
+ // ballooning once the table starts sliding above the header.
394
+ const visibleTop = Math.max(verticalStart, top);
395
+ target.style.maxHeight = `${Math.max(window.innerHeight - visibleTop - EDGE_PX, 80)}px`;
396
+ // Re-check: at its (possibly capped) expanded width the table may or
397
+ // may not still need to scroll internally.
398
+ updateScrollFocusability(scroll, container);
399
+ };
400
+ reposition();
401
+ window.addEventListener("scroll", reposition, {
402
+ passive: true,
403
+ capture: true,
404
+ });
405
+ window.addEventListener("resize", reposition, { passive: true });
406
+
407
+ active = {
408
+ container,
409
+ target,
410
+ scroll,
411
+ placeholder,
412
+ spoofWrapper,
413
+ button,
414
+ leadingButton,
415
+ reposition,
416
+ };
417
+ updateButtonLabel(container, button, true);
418
+ }
419
+
420
+ function wrapBareTable(table: HTMLTableElement): HTMLElement {
421
+ const wrapper = document.createElement("div");
422
+ wrapper.className = "table-overflow-body";
423
+ table.replaceWith(wrapper);
424
+ wrapper.append(table);
425
+ return wrapper;
426
+ }
427
+
428
+ function wire(container: HTMLElement) {
429
+ // The table itself moves into a dedicated scroll clip, freeing the
430
+ // button to be a full-height flex sibling instead of an overlay
431
+ // floating on top of table content.
432
+ const scroll = document.createElement("div");
433
+ scroll.className = "table-overflow-scroll";
434
+ while (container.firstChild) scroll.append(container.firstChild);
435
+ container.append(scroll);
436
+
437
+ const button = document.createElement("button");
438
+ button.type = "button";
439
+ button.className = "table-overflow-expand-btn";
440
+ button.hidden = true;
441
+ updateButtonLabel(container, button, false);
442
+ container.append(button);
443
+
444
+ button.addEventListener("click", () => {
445
+ if (active?.container === container) collapse();
446
+ else expand(container, button, scroll);
447
+ });
448
+
449
+ evaluateOverflow(scroll, button, container);
450
+
451
+ let resizeTimer: number | null = null;
452
+ window.addEventListener("resize", () => {
453
+ if (resizeTimer !== null) window.clearTimeout(resizeTimer);
454
+ resizeTimer = window.setTimeout(
455
+ () => evaluateOverflow(scroll, button, container),
456
+ 150,
457
+ );
458
+ });
459
+ }
460
+
461
+ function setup() {
462
+ const root = document.querySelector(".sl-markdown-content");
463
+ if (!root) return;
464
+
465
+ const containers = new Set<HTMLElement>();
466
+ root
467
+ .querySelectorAll<HTMLElement>(".keyed-table__body")
468
+ .forEach((el) => containers.add(el));
469
+ root
470
+ .querySelectorAll<HTMLTableElement>(
471
+ "table:not(.keyed-table__body table):not(.lab-shell table)",
472
+ )
473
+ .forEach((table) => containers.add(wrapBareTable(table)));
474
+
475
+ if (containers.size === 0) return;
476
+ containers.forEach(wire);
477
+
478
+ document.addEventListener("keydown", (event) => {
479
+ if (event.key === "Escape" && active) collapse();
480
+ });
481
+ // Deliberately no "click outside collapses it": a reader following a
482
+ // link in the "On this page" rail, or clicking anywhere else on the
483
+ // page to keep reading, is not asking for the table back at its normal
484
+ // size — only Escape or the collapse buttons themselves are.
485
+ }
486
+
487
+ setup();
488
+ window.addEventListener("explico:prepare-static-export", collapse);
489
+ </script>
@@ -287,6 +287,25 @@ const notation = defineCollection({
287
287
  }),
288
288
  });
289
289
 
290
+ /**
291
+ * Abbreviations table (rule N14) — deliberately a lighter tier than a
292
+ * `notation` card: just a name resolution, no `formula`, `units`, or
293
+ * curriculum `alignment`. `abbr` is the canonical written form (`OLS`);
294
+ * `aliases` are other written forms of the same abbreviation that should
295
+ * resolve the same way (`w.r.t.` might alias `wrt`).
296
+ */
297
+ const abbreviations = defineCollection({
298
+ loader: glob({ pattern: '**/*.md', base: './content/abbreviations' }),
299
+ schema: z.object({
300
+ key: id,
301
+ abbr: z.string().min(1),
302
+ expansion: notationProse,
303
+ aliases: z.array(z.string().min(1)).default([]),
304
+ domain: id.optional(),
305
+ editorialStatus: editorialStatus.default('draft'),
306
+ }),
307
+ });
308
+
290
309
  export const collections = {
291
310
  docs,
292
311
  competencies,
@@ -294,4 +313,5 @@ export const collections = {
294
313
  tracks,
295
314
  sources,
296
315
  notation,
316
+ abbreviations,
297
317
  };
@@ -0,0 +1,60 @@
1
+ /**
2
+ * The base abbreviation baseline: acronyms and dotted abbreviations every
3
+ * reader is assumed to know, so a lesson never has to introduce them with an
4
+ * abbreviations-table entry. The `\operatorname{…}` / `\mathrm{…}` twins are
5
+ * `base-operators.mjs` (rule N8) / `base-roman-labels.mjs` (rule N10); this is
6
+ * the plain-prose equivalent for rule N14.
7
+ *
8
+ * Kept intentionally small, same rationale as `BASE_ROMAN_LABELS`: a
9
+ * course-specific acronym (`OLS`, `SSR`, `MSE`) is exactly the thing a reader
10
+ * benefits from having spelled out, so it belongs in the course's own
11
+ * `content/abbreviations/*.md` table, not here. This baseline only holds
12
+ * abbreviations so common in ordinary English/academic writing that spelling
13
+ * them out on every course would be noise, not help. A course adds a real
14
+ * one via `abbreviations.allow` in `course.config.ts`, the same shape as
15
+ * `operators.allow` / `romanLabels.allow`.
16
+ */
17
+ export const BASE_ABBREVIATIONS = Object.freeze(
18
+ new Set([
19
+ // dotted Latin/English abbreviations
20
+ 'e.g.',
21
+ 'i.e.',
22
+ 'etc.',
23
+ 'cf.',
24
+ 'vs.',
25
+ 'a.m.',
26
+ 'p.m.',
27
+ 'a.k.a.',
28
+ 'et al.',
29
+ // generic acronyms with no domain-specific meaning in this engine
30
+ 'AI',
31
+ 'ID',
32
+ 'OK',
33
+ 'URL',
34
+ 'FAQ',
35
+ 'US',
36
+ 'UK',
37
+ 'EU',
38
+ ]),
39
+ );
40
+
41
+ /**
42
+ * The abbreviations a course treats as common, given its `course.config.ts`
43
+ * `abbreviations` block: the shipped baseline, plus `allow`, minus `deny`.
44
+ *
45
+ * @param {{ allow?: readonly string[], deny?: readonly string[] } | undefined} abbreviations
46
+ * @returns {Set<string>}
47
+ */
48
+ export function effectiveAbbreviationAllowlist(abbreviations) {
49
+ const allow = abbreviations?.allow ?? [];
50
+ const deny = new Set(abbreviations?.deny ?? []);
51
+ const effective = new Set(BASE_ABBREVIATIONS);
52
+ for (const name of allow) effective.add(name);
53
+ for (const name of deny) effective.delete(name);
54
+ return effective;
55
+ }
56
+
57
+ /** True when `token` needs no abbreviations-table entry for this course. */
58
+ export function isBaseAbbreviation(token, abbreviations) {
59
+ return effectiveAbbreviationAllowlist(abbreviations).has(token);
60
+ }
@@ -81,6 +81,46 @@ export const BASE_OPERATORS = Object.freeze(
81
81
  ]),
82
82
  );
83
83
 
84
+ /**
85
+ * The subset of {@link BASE_OPERATORS} that must never appear with a bare
86
+ * (unparenthesized) argument: every trigonometric, inverse-trigonometric, and
87
+ * hyperbolic function, plus the exponential and logarithms (N13). `\sin x`
88
+ * leaves how far the argument extends ambiguous the moment anything follows
89
+ * it (`\sin x + 1` — is the `+1` inside or outside the sine?); `\sin(x)`
90
+ * never does, even for a single-symbol argument. Kept separate from
91
+ * `BASE_OPERATORS`: this is a formatting rule layered on top of the "does it
92
+ * need a notation card" question that set answers, so an operator can be
93
+ * universal (N8) and still owe a parenthesized argument. Enforced by
94
+ * `resolveMathGlyphs` in `math-glyphs.mjs`, which every rendered-math context
95
+ * (lesson body, notation `formula`, assessments, `Diagram` nodes, lab math)
96
+ * already calls.
97
+ */
98
+ export const PARENTHESIZED_FUNCTIONS = Object.freeze(
99
+ new Set([
100
+ // exponential / logarithms
101
+ 'exp',
102
+ 'log',
103
+ 'ln',
104
+ 'lg',
105
+ // trigonometric
106
+ 'sin',
107
+ 'cos',
108
+ 'tan',
109
+ 'cot',
110
+ 'sec',
111
+ 'csc',
112
+ // inverse trigonometric
113
+ 'arcsin',
114
+ 'arccos',
115
+ 'arctan',
116
+ // hyperbolic
117
+ 'sinh',
118
+ 'cosh',
119
+ 'tanh',
120
+ 'coth',
121
+ ]),
122
+ );
123
+
84
124
  /**
85
125
  * The operators a course treats as common, given its `course.config.ts`
86
126
  * `operators` block: the shipped baseline, plus `allow`, minus `deny`.
@@ -0,0 +1,43 @@
1
+ /**
2
+ * The base roman-label baseline: `\mathrm{…}` multi-letter labels every
3
+ * reader is assumed to know, so a lesson never has to introduce them with a
4
+ * notation entry. The `\operatorname{…}` counterpart is `base-operators.mjs`
5
+ * (rule N8); this is its `\mathrm{…}` twin (rule N10).
6
+ *
7
+ * Rationale: the completeness gate has no opinion about `\mathrm{…}` bodies —
8
+ * they are a typographic label to KaTeX, not a bindable symbol, and N3
9
+ * requires the format without asking whether the label is ever explained.
10
+ * The `roman-label-explained` consistency check fills that gap: a label a
11
+ * lesson uses is either on this list (or the course's additions to it) or it
12
+ * carries a notation entry the reader can hover.
13
+ *
14
+ * Kept intentionally small — unlike `sin`/`exp`, an upright label is almost
15
+ * always a course-specific named quantity (`KE`, `PV`, `Var`-as-a-label) that
16
+ * genuinely benefits from a hover card, so the shipped baseline holds only
17
+ * the qualifiers that function like an operator's extremum subscript
18
+ * (`x_{\mathrm{opt}}`) rather than naming a quantity of their own. A course
19
+ * adds a real one via `romanLabels.allow` in `course.config.ts`, the same
20
+ * shape as `operators.allow`.
21
+ */
22
+ export const BASE_ROMAN_LABELS = Object.freeze(new Set(['opt']));
23
+
24
+ /**
25
+ * The roman labels a course treats as common, given its `course.config.ts`
26
+ * `romanLabels` block: the shipped baseline, plus `allow`, minus `deny`.
27
+ *
28
+ * @param {{ allow?: readonly string[], deny?: readonly string[] } | undefined} romanLabels
29
+ * @returns {Set<string>}
30
+ */
31
+ export function effectiveRomanLabelAllowlist(romanLabels) {
32
+ const allow = romanLabels?.allow ?? [];
33
+ const deny = new Set(romanLabels?.deny ?? []);
34
+ const effective = new Set(BASE_ROMAN_LABELS);
35
+ for (const name of allow) effective.add(name);
36
+ for (const name of deny) effective.delete(name);
37
+ return effective;
38
+ }
39
+
40
+ /** True when `name` needs no notation entry for this course. */
41
+ export function isBaseRomanLabel(name, romanLabels) {
42
+ return effectiveRomanLabelAllowlist(romanLabels).has(name);
43
+ }