explico 0.7.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 (236) hide show
  1. package/CHANGELOG.md +1409 -1
  2. package/README.md +60 -15
  3. package/dist/compiler/astro.d.ts +6 -1
  4. package/dist/compiler/cli.d.ts +54 -3
  5. package/dist/compiler/cli.js +80 -6
  6. package/dist/compiler/collections.d.ts +35 -2
  7. package/dist/compiler/collections.js +84 -8
  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 +14 -1
  15. package/dist/compiler/logging.js +6 -2
  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 +21 -11
  27. package/dist/reference/delimiter-size.js +15 -39
  28. package/dist/reference/diagrams-validate.d.ts +7 -2
  29. package/dist/reference/diagrams-validate.js +9 -1
  30. package/dist/reference/equations-validate.d.ts +8 -5
  31. package/dist/reference/equations-validate.js +1 -0
  32. package/dist/reference/equations.d.mts +69 -34
  33. package/dist/reference/equations.mjs +90 -44
  34. package/dist/reference/examples-validate.d.ts +31 -0
  35. package/dist/reference/examples-validate.js +48 -0
  36. package/dist/reference/figures-validate.d.ts +1 -1
  37. package/dist/reference/figures-validate.js +4 -1
  38. package/dist/reference/folds-validate.d.ts +33 -0
  39. package/dist/reference/folds-validate.js +32 -0
  40. package/dist/reference/gate-abbreviations.d.ts +3 -1
  41. package/dist/reference/gate-abbreviations.js +3 -2
  42. package/dist/reference/gate-course-rules.d.ts +32 -0
  43. package/dist/reference/gate-course-rules.js +85 -0
  44. package/dist/reference/gate-heading-length.d.ts +5 -2
  45. package/dist/reference/gate-heading-length.js +47 -23
  46. package/dist/reference/gate-heading-structure.d.ts +41 -0
  47. package/dist/reference/gate-heading-structure.js +113 -0
  48. package/dist/reference/gate-math-punctuation.d.ts +71 -0
  49. package/dist/reference/gate-math-punctuation.js +251 -0
  50. package/dist/reference/gate-math.d.ts +44 -6
  51. package/dist/reference/gate-math.js +63 -10
  52. package/dist/reference/gate-part-pages.d.ts +50 -0
  53. package/dist/reference/gate-part-pages.js +118 -0
  54. package/dist/reference/gate-prose-notation.d.ts +32 -11
  55. package/dist/reference/gate-prose-notation.js +30 -8
  56. package/dist/reference/katex-delimiters.d.mts +15 -0
  57. package/dist/reference/katex-delimiters.mjs +138 -0
  58. package/dist/reference/katex-options.mjs +4 -0
  59. package/dist/reference/keyed-float-validate.d.ts +27 -10
  60. package/dist/reference/keyed-float-validate.js +15 -1
  61. package/dist/reference/keyed-jsx-floats.d.mts +11 -5
  62. package/dist/reference/keyed-jsx-floats.mjs +73 -20
  63. package/dist/reference/knowledge-checks-validate.d.ts +1 -1
  64. package/dist/reference/knowledge-checks.d.mts +3 -1
  65. package/dist/reference/knowledge-checks.mjs +9 -4
  66. package/dist/reference/lab-math-scope.d.ts +1 -0
  67. package/dist/reference/lab-math-slot.d.ts +34 -0
  68. package/dist/reference/lab-math-slot.js +6 -0
  69. package/dist/reference/math-glyphs.d.mts +80 -4
  70. package/dist/reference/math-glyphs.mjs +588 -39
  71. package/dist/reference/page-notation.d.ts +6 -1
  72. package/dist/reference/raw-floats-validate.js +1 -1
  73. package/dist/reference/raw-floats.mjs +1 -1
  74. package/dist/reference/rehype-heading-anchors.d.mts +14 -0
  75. package/dist/reference/rehype-heading-anchors.mjs +116 -17
  76. package/dist/reference/rehype-table-header-labels.d.mts +6 -1
  77. package/dist/reference/rehype-table-header-labels.mjs +21 -3
  78. package/dist/reference/remark-asides.d.mts +3 -0
  79. package/dist/reference/remark-asides.mjs +180 -0
  80. package/dist/reference/remark-memo.d.mts +3 -1
  81. package/dist/reference/remark-memo.mjs +42 -11
  82. package/dist/reference/remark-notation.d.mts +60 -10
  83. package/dist/reference/remark-notation.mjs +387 -36
  84. package/dist/reference/render-lab-math.d.ts +4 -26
  85. package/dist/reference/render-lab-math.js +5 -5
  86. package/dist/reference/sections-validate.d.ts +27 -0
  87. package/dist/reference/sections-validate.js +41 -0
  88. package/dist/reference/sections.d.mts +97 -0
  89. package/dist/reference/sections.mjs +306 -0
  90. package/dist/reference/tables-validate.d.ts +1 -1
  91. package/dist/reference/types.d.ts +24 -1
  92. package/dist/reference/unit-words.d.ts +37 -0
  93. package/dist/reference/unit-words.js +170 -0
  94. package/dist/rules/catalog.d.ts +176 -0
  95. package/dist/rules/catalog.js +983 -0
  96. package/dist/rules/course-rules.d.ts +115 -0
  97. package/dist/rules/course-rules.js +292 -0
  98. package/dist/rules/course-rules.schema.json +138 -0
  99. package/dist/rules/in-effect.d.ts +53 -0
  100. package/dist/rules/in-effect.js +142 -0
  101. package/dist/rules/rules.schema.json +1072 -0
  102. package/dist/rules/settings.d.ts +3 -0
  103. package/dist/rules/settings.js +290 -0
  104. package/dist/rules/waivers.d.ts +53 -0
  105. package/dist/rules/waivers.js +132 -0
  106. package/dist/session/PreferenceStore.d.ts +5 -0
  107. package/dist/session/PreferenceStore.js +9 -0
  108. package/docs/VERSIONING.md +2 -2
  109. package/docs/authoring/README.md +232 -40
  110. package/docs/authoring/agents.md +55 -9
  111. package/docs/authoring/components.md +178 -22
  112. package/docs/authoring/migrating.md +1324 -2
  113. package/docs/authoring/notation.md +616 -344
  114. package/docs/authoring/prose.md +551 -0
  115. package/docs/authoring/retired.md +35 -0
  116. package/docs/authoring/structure-rules.md +422 -40
  117. package/docs/authoring/structure.md +445 -62
  118. package/docs/authoring/versioning.md +20 -28
  119. package/docs/authoring/visualizations.md +142 -20
  120. package/docs/katex-bracket-scaling.md +191 -0
  121. package/package.json +3 -2
  122. package/src/compiler/cli.ts +139 -6
  123. package/src/compiler/collections.ts +144 -10
  124. package/src/compiler/course-config.ts +273 -34
  125. package/src/compiler/knowledge-version.ts +1 -1
  126. package/src/compiler/lesson-derivation.ts +54 -15
  127. package/src/compiler/logging.ts +21 -4
  128. package/src/compiler/manifest.ts +573 -104
  129. package/src/components/BlockHeading.astro +113 -0
  130. package/src/components/CourseContents.astro +221 -0
  131. package/src/components/CurriculumMap.astro +5 -6
  132. package/src/components/FloatIndex.astro +200 -0
  133. package/src/components/HoverPanel.astro +431 -79
  134. package/src/components/PartBridge.astro +27 -0
  135. package/src/components/PartOverview.astro +401 -0
  136. package/src/components/PartScope.astro +27 -0
  137. package/src/components/abbreviation/AbbreviationLayer.astro +49 -51
  138. package/src/components/assessments/AssessmentRunner.tsx +8 -5
  139. package/src/components/assessments/AssessmentSet.astro +23 -26
  140. package/src/components/block-heading.ts +146 -0
  141. package/src/components/card-arrow.ts +243 -0
  142. package/src/components/card-drag.ts +631 -0
  143. package/src/components/card-jump.ts +45 -0
  144. package/src/components/card-manager.ts +1589 -0
  145. package/src/components/card-memory.ts +177 -0
  146. package/src/components/card-placement.ts +504 -0
  147. package/src/components/card-side.ts +336 -0
  148. package/src/components/card-travel.ts +197 -0
  149. package/src/components/citation/CitationLayer.astro +77 -76
  150. package/src/components/diagram/Diagram.astro +161 -316
  151. package/src/components/disclosure/course-disclosure.ts +24 -0
  152. package/src/components/disclosure/hover-preview.ts +122 -42
  153. package/src/components/disclosure/persisted-disclosure.ts +3 -3
  154. package/src/components/disclosure/print-as-shown.ts +252 -0
  155. package/src/components/examples/CompactExample.astro +3 -1
  156. package/src/components/examples/CompactExamples.astro +101 -94
  157. package/src/components/figure/Figure.astro +174 -274
  158. package/src/components/figure/FloatCaption.astro +87 -0
  159. package/src/components/figure/figure-state.ts +58 -0
  160. package/src/components/figure/figure-tools.ts +87 -0
  161. package/src/components/float-name.ts +101 -0
  162. package/src/components/fold/Fold.astro +125 -0
  163. package/src/components/fold/fold-id.ts +40 -0
  164. package/src/components/memo/Memo.astro +162 -231
  165. package/src/components/memo/ShortNote.astro +6 -6
  166. package/src/components/notation/NotationFormula.astro +5 -1
  167. package/src/components/notation/NotationGlossary.astro +24 -14
  168. package/src/components/notation/NotationLayer.astro +169 -162
  169. package/src/components/notation/NotationMath.astro +10 -1
  170. package/src/components/notation/render-notation-math.ts +111 -10
  171. package/src/components/reference/FloatRefLayer.astro +485 -423
  172. package/src/components/starlight/AboutPanel.astro +22 -6
  173. package/src/components/starlight/CollapsibleSections.astro +207 -93
  174. package/src/components/starlight/DiagramEnhancer.astro +15 -138
  175. package/src/components/starlight/HeadingAnchorEnhancer.astro +193 -1
  176. package/src/components/starlight/KeyedFloatEnhancer.astro +100 -9
  177. package/src/components/starlight/LayoutHeader.astro +298 -19
  178. package/src/components/starlight/LayoutPageSidebar.astro +99 -12
  179. package/src/components/starlight/LayoutPageTitle.astro +120 -0
  180. package/src/components/starlight/LayoutSidebar.astro +229 -11
  181. package/src/components/starlight/LessonExportActions.astro +53 -72
  182. package/src/components/starlight/LessonFooter.astro +26 -4
  183. package/src/components/starlight/section-parts.ts +15 -2
  184. package/src/components/starlight/section-summary.ts +181 -0
  185. package/src/components/table/Table.astro +95 -76
  186. package/src/components/table/TableOverflowExpander.astro +65 -28
  187. package/src/content-config.ts +40 -4
  188. package/src/reference/base-accents.mjs +31 -0
  189. package/src/reference/base-roman-labels.mjs +2 -2
  190. package/src/reference/consistency.ts +176 -5
  191. package/src/reference/delimiter-size.ts +40 -61
  192. package/src/reference/diagrams-validate.ts +17 -1
  193. package/src/reference/equations-validate.ts +10 -5
  194. package/src/reference/equations.mjs +90 -44
  195. package/src/reference/examples-validate.ts +97 -0
  196. package/src/reference/figures-validate.ts +6 -1
  197. package/src/reference/folds-validate.ts +65 -0
  198. package/src/reference/gate-abbreviations.ts +3 -2
  199. package/src/reference/gate-course-rules.ts +134 -0
  200. package/src/reference/gate-heading-length.ts +68 -37
  201. package/src/reference/gate-heading-structure.ts +193 -0
  202. package/src/reference/gate-math-punctuation.ts +381 -0
  203. package/src/reference/gate-math.ts +119 -12
  204. package/src/reference/gate-part-pages.ts +217 -0
  205. package/src/reference/gate-prose-notation.ts +74 -13
  206. package/src/reference/katex-delimiters.mjs +138 -0
  207. package/src/reference/katex-options.mjs +4 -0
  208. package/src/reference/keyed-float-validate.ts +45 -11
  209. package/src/reference/keyed-jsx-floats.mjs +73 -20
  210. package/src/reference/knowledge-checks-validate.ts +1 -1
  211. package/src/reference/knowledge-checks.mjs +9 -4
  212. package/src/reference/lab-math-slot.ts +36 -0
  213. package/src/reference/math-glyphs.mjs +588 -39
  214. package/src/reference/raw-floats-validate.ts +1 -1
  215. package/src/reference/raw-floats.mjs +1 -1
  216. package/src/reference/rehype-heading-anchors.mjs +116 -17
  217. package/src/reference/rehype-table-header-labels.mjs +21 -3
  218. package/src/reference/remark-asides.mjs +180 -0
  219. package/src/reference/remark-memo.mjs +42 -11
  220. package/src/reference/remark-notation.mjs +387 -36
  221. package/src/reference/render-lab-math.ts +17 -32
  222. package/src/reference/sections-validate.ts +81 -0
  223. package/src/reference/sections.mjs +306 -0
  224. package/src/reference/tables-validate.ts +2 -1
  225. package/src/reference/types.ts +24 -0
  226. package/src/reference/unit-words.ts +233 -0
  227. package/src/rules/catalog.ts +1298 -0
  228. package/src/rules/course-rules.ts +456 -0
  229. package/src/rules/in-effect.ts +235 -0
  230. package/src/rules/settings.ts +448 -0
  231. package/src/rules/waivers.ts +249 -0
  232. package/src/session/PreferenceStore.ts +15 -0
  233. package/src/styles/global.css +1471 -163
  234. package/src/styles/surfaces.css +182 -0
  235. package/src/components/hover-panel.ts +0 -339
  236. package/src/components/popup-stack.ts +0 -345
@@ -0,0 +1,181 @@
1
+ /**
2
+ * The "Hidden here: …" line a *closed* collapsible section shows under its
3
+ * title (see `CollapsibleSections.astro`), so a reader can tell there is
4
+ * content behind the title — and roughly how much — without opening it.
5
+ *
6
+ * A numbered float with a short caption is named, number first, before the
7
+ * counts of everything else: "Hidden here: **Figure 2.1.1** Projectile
8
+ * trajectory explorer, 2 paragraphs" (docs/plans/figures-and-short-captions.md,
9
+ * decision 9.2); a float without one stays in the counts ("1 table").
10
+ *
11
+ * Split in two so the wording is unit-testable without a DOM:
12
+ * `collectHiddenContent` walks a section body once (browser only), and
13
+ * `hiddenContentParts` / `describeHiddenContent` turn what it found into the
14
+ * reader-facing line.
15
+ */
16
+ import { FLOAT_KIND_WORDS, floatOf, type NamedFloat } from '../float-name';
17
+
18
+ export interface HiddenContentCounts {
19
+ readonly subsections: number;
20
+ readonly paragraphs: number;
21
+ readonly equations: number;
22
+ readonly figures: number;
23
+ readonly tables: number;
24
+ readonly diagrams: number;
25
+ readonly lists: number;
26
+ readonly codeBlocks: number;
27
+ readonly memos: number;
28
+ readonly examples: number;
29
+ readonly questions: number;
30
+ }
31
+
32
+ // Reading order of the summary line: structure first, then prose, then the
33
+ // numbered floats, then everything else.
34
+ const LABELS: readonly (readonly [keyof HiddenContentCounts, string, string])[] = [
35
+ ['subsections', 'subsection', 'subsections'],
36
+ ['paragraphs', 'paragraph', 'paragraphs'],
37
+ ['equations', 'equation', 'equations'],
38
+ ['figures', 'figure', 'figures'],
39
+ ['tables', 'table', 'tables'],
40
+ ['diagrams', 'diagram', 'diagrams'],
41
+ ['lists', 'list', 'lists'],
42
+ ['codeBlocks', 'code block', 'code blocks'],
43
+ ['memos', 'memo', 'memos'],
44
+ ['examples', 'worked example', 'worked examples'],
45
+ ['questions', 'question', 'questions'],
46
+ ];
47
+
48
+ /** One item of the line: a named float — its number set in bold — or a count. */
49
+ export type HiddenPart = string | { readonly number: string; readonly short: string };
50
+
51
+ /**
52
+ * The line's items: every named float, in page order, then the counts; or
53
+ * `null` when nothing countable is inside (the caller then shows no line at
54
+ * all rather than a vague one). `named` holds only floats with a short
55
+ * caption, already left out of `counts`.
56
+ */
57
+ export function hiddenContentParts(
58
+ counts: HiddenContentCounts,
59
+ named: readonly NamedFloat[] = [],
60
+ ): HiddenPart[] | null {
61
+ const parts: HiddenPart[] = [
62
+ ...named.flatMap((float) =>
63
+ float.short
64
+ ? [{ number: `${FLOAT_KIND_WORDS[float.kind]} ${float.number}`, short: float.short }]
65
+ : [],
66
+ ),
67
+ ...LABELS.filter(([key]) => counts[key] > 0).map(
68
+ ([key, singular, plural]) =>
69
+ `${counts[key]} ${counts[key] === 1 ? singular : plural}`,
70
+ ),
71
+ ];
72
+ return parts.length > 0 ? parts : null;
73
+ }
74
+
75
+ /**
76
+ * `"Hidden here: Figure 2.1.1 Projectile trajectory explorer, 2 paragraphs,
77
+ * 1 equation"` — the line as text — or `null` with nothing to say.
78
+ */
79
+ export function describeHiddenContent(
80
+ counts: HiddenContentCounts,
81
+ named: readonly NamedFloat[] = [],
82
+ ): string | null {
83
+ const parts = hiddenContentParts(counts, named);
84
+ if (!parts) return null;
85
+ const text = parts.map((part) =>
86
+ typeof part === 'string' ? part : `${part.number} ${part.short}`,
87
+ );
88
+ return `Hidden here: ${text.join(', ')}`;
89
+ }
90
+
91
+ /**
92
+ * Components counted as one item each. Anything *inside* one of them (a
93
+ * figure's caption paragraph, a memo's equation, a question's option list,
94
+ * a worked example's working) belongs to that component and is not counted
95
+ * again on its own.
96
+ */
97
+ const CONTAINERS =
98
+ '.keyed-figure, .keyed-table, .diagram, .memo-block, .compact-examples, .compact-example, .assessment-shell, .short-note';
99
+
100
+ /** Whether any ancestor of `element` strictly between it and `root` matches `selector`. */
101
+ function hasAncestorWithin(element: Element, root: Element, selector: string): boolean {
102
+ for (
103
+ let node = element.parentElement;
104
+ node && node !== root;
105
+ node = node.parentElement
106
+ ) {
107
+ if (node.matches(selector)) return true;
108
+ }
109
+ return false;
110
+ }
111
+
112
+ export interface HiddenContent {
113
+ readonly counts: HiddenContentCounts;
114
+ /** The numbered floats with a short caption, in page order; not in `counts`. */
115
+ readonly named: readonly NamedFloat[];
116
+ }
117
+
118
+ /**
119
+ * What a section body holds, at every depth — a nested subsection's content
120
+ * is hidden along with its parent's, so it counts too. `headings` is the
121
+ * heading selector (`HEADINGS` from `./section-parts`). With `nameFloats:
122
+ * false` — a `<Fold>` whose bar already names its floats — every float stays
123
+ * a count.
124
+ */
125
+ export function collectHiddenContent(
126
+ body: Element,
127
+ headings: string,
128
+ { nameFloats = true }: { readonly nameFloats?: boolean } = {},
129
+ ): HiddenContent {
130
+ const outside = (selector: string, alsoExclude = '') =>
131
+ Array.from(body.querySelectorAll(selector)).filter(
132
+ (element) =>
133
+ !hasAncestorWithin(element, body, CONTAINERS) &&
134
+ !(alsoExclude && hasAncestorWithin(element, body, alsoExclude)),
135
+ );
136
+ const itemsIn = (container: string, item: string) =>
137
+ outside(container).reduce(
138
+ (total, element) => total + element.querySelectorAll(item).length,
139
+ 0,
140
+ );
141
+ const named = nameFloats
142
+ ? outside('.keyed-figure, .keyed-table, .diagram').flatMap((element) => {
143
+ const float = floatOf(element);
144
+ return float?.short ? [float] : [];
145
+ })
146
+ : [];
147
+ const unnamed = (selector: string) =>
148
+ outside(selector).filter(
149
+ (element) => !named.some((float) => float.anchorId === element.id),
150
+ ).length;
151
+
152
+ const counts: HiddenContentCounts = {
153
+ // Only Markdown section headings — each sits in its own
154
+ // `.sl-heading-wrapper` — not a heading a component draws for itself
155
+ // (a lab's title, an assessment runner's, a `<Fold>`'s bar, which is out
156
+ // of the accessibility tree's headings with `role="none"`).
157
+ subsections: outside(headings).filter(
158
+ (heading) =>
159
+ hasAncestorWithin(heading, body, '.sl-heading-wrapper') &&
160
+ heading.getAttribute('role') !== 'none',
161
+ ).length,
162
+ // A loose list's `<li><p>` is part of the list, not a paragraph of its own.
163
+ paragraphs: outside('p', 'ul, ol').length,
164
+ equations: outside('.katex-display').length,
165
+ figures: unnamed('.keyed-figure'),
166
+ // Keyed tables, plus any raw Markdown table (warned about, but possible).
167
+ tables: unnamed('.keyed-table') + outside('table').length,
168
+ diagrams: unnamed('.diagram'),
169
+ lists: outside('ul, ol', 'ul, ol').length,
170
+ codeBlocks: outside('pre').length,
171
+ memos: outside('.memo-block').length,
172
+ // A set of worked examples counts its examples; folded itself (the set
173
+ // is a section of its own, `CompactExamples.astro`), its body holds them
174
+ // directly.
175
+ examples:
176
+ itemsIn('.compact-examples', '.compact-example') +
177
+ outside('.compact-example').length,
178
+ questions: itemsIn('.assessment-shell', '.assessment-item'),
179
+ };
180
+ return { counts, named };
181
+ }
@@ -13,23 +13,40 @@
13
13
  * `[[tab-<id>]]` (same page) or `[[lesson-slug#tab-<id>]]` (cross page); a
14
14
  * table without `id` is still numbered and deep-linkable, just not
15
15
  * referenceable by name — the same rule an unkeyed equation follows.
16
+ *
17
+ * Named by the caption pair every float takes
18
+ * (docs/plans/titles-and-containers.md Part 3): the long `caption`, in the
19
+ * shared caption line (`global.css` `.float-caption`) above the table, and an
20
+ * optional `short` caption that heads the `[[tab-…]]` preview card. Minimized,
21
+ * a table keeps that caption line and its header row (decision Q6).
16
22
  */
23
+ import { courseDisclosure } from '../disclosure/course-disclosure';
24
+ import { captionTitle } from '../float-name';
25
+
17
26
  export interface Props {
18
27
  /** Stable key, e.g. `id="kinematics-summary"`. Omit for a positional-only table. */
19
28
  id?: string;
20
- /** Visible caption, shown above the table (the LaTeX convention — the reverse of a figure caption, which goes below) and in the hover-preview card. */
29
+ /** The long caption, shown above the table (the LaTeX convention — the reverse of a figure caption, which goes below) and in the hover-preview card. */
21
30
  caption: string;
31
+ /** Optional short caption: heads the `[[tab-…]]` preview card; rule S1 measures it. */
32
+ short?: string;
22
33
  /** Computed by `remark-notation.mjs` — never author this directly. */
23
34
  number?: string;
24
35
  /** Computed by `remark-notation.mjs` — never author this directly. */
25
36
  anchorId?: string;
26
37
  }
27
38
 
28
- const { id, caption, number, anchorId } = Astro.props;
39
+ const { id, caption, short, number, anchorId } = Astro.props;
40
+ // A table longer than this starts truncated (the course's
41
+ // `disclosure.tables.truncateAfter`, default 6).
42
+ const { truncateAfter } = (await courseDisclosure()).tables;
29
43
 
30
44
  if (typeof caption !== 'string' || !caption.trim()) {
31
45
  throw new Error('Table requires a non-empty `caption` prop.');
32
46
  }
47
+ if (short !== undefined && (typeof short !== 'string' || !short.trim())) {
48
+ throw new Error('Table `short` must be a non-empty string when given.');
49
+ }
33
50
  if (!number || !anchorId) {
34
51
  throw new Error(
35
52
  'Table is missing its computed `number`/`anchorId` — this page must run remark-notation with a `tables` cross-page table configured (see astro.config.mjs).',
@@ -42,14 +59,31 @@ if (!number || !anchorId) {
42
59
  id={anchorId}
43
60
  data-tab-number={number}
44
61
  data-tab-key={id}
62
+ data-tab-short={short}
63
+ data-truncate-after={truncateAfter}
45
64
  >
46
- <figcaption class="keyed-table__caption">
65
+ <figcaption class="keyed-table__caption float-caption">
47
66
  <a
48
- class="keyed-table__number"
67
+ class="keyed-table__number float-caption__number"
49
68
  href={`#${anchorId}`}
50
69
  aria-label={`Table ${number}`}>Table {number}</a
51
70
  >
52
- <span class="keyed-table__caption-text">{caption}</span>
71
+ <a
72
+ class="float-caption__anchor"
73
+ href={`#${anchorId}`}
74
+ aria-label={`Copy the link to Table ${number}`}
75
+ title="Copy link"
76
+ data-copy-link={`Table ${number}`}
77
+ >
78
+ <svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="currentColor"
79
+ ><path
80
+ d="m12.11 15.39-3.88 3.88a2.52 2.52 0 0 1-3.5 0 2.47 2.47 0 0 1 0-3.5l3.88-3.88a1 1 0 1 0-1.42-1.42l-3.88 3.89a4.48 4.48 0 0 0 6.33 6.33l3.89-3.88a1 1 0 0 0-1.42-1.42m8.58-12.08a4.49 4.49 0 0 0-6.33 0l-3.89 3.88a1 1 0 1 0 1.42 1.42l3.88-3.88a2.52 2.52 0 0 1 3.5 0 2.47 2.47 0 0 1 0 3.5l-3.88 3.88a1 1 0 0 0 0 1.42 1 1 0 0 0 1.42 0l3.88-3.89a4.49 4.49 0 0 0 0-6.33M8.83 15.17a1 1 0 0 0 .71.29 1 1 0 0 0 .71-.29l4.92-4.92a1 1 0 1 0-1.42-1.42l-4.92 4.92a1 1 0 0 0 0 1.42"
81
+ ></path></svg
82
+ >
83
+ </a>
84
+ {short && <b class="float-caption__title">{captionTitle(short)}</b>}{short && ' '}<span
85
+ class="keyed-table__caption-text float-caption__text">{caption}</span
86
+ >
53
87
  </figcaption>
54
88
  <div class="keyed-table__body">
55
89
  <slot />
@@ -76,45 +110,33 @@ if (!number || !anchorId) {
76
110
  margin-inline: auto;
77
111
  }
78
112
 
79
- .keyed-table__caption {
80
- position: relative;
81
- margin-block-end: 0.5rem;
82
- font-size: var(--sl-text-base);
83
- color: var(--sl-color-gray-3);
84
- }
85
-
86
- /* Added by the row-pagination script below for every table (it always
87
- gets a minimize-to-header-row toggle, regardless of row count) —
88
- reserves room so caption text never runs under the absolutely-positioned
89
- toggle button in the corner. */
90
- .keyed-table__caption--paginated {
91
- padding-inline-end: 1.75em;
92
- }
93
-
94
- /* The toggle button and everything else this rule block styles are
95
- created at runtime (see the `<script>` below), so they never carry this
96
- component's `data-astro-cid-*` scoping attribute — `:global()` is
97
- required for the same reason `.keyed-table__body :global(table)` above
98
- needs it. Reuses `.table-overflow-expand-btn` from
99
- `TableOverflowExpander.astro` (Part 4) for its base look
100
- (border/hover/focus/print-hidden), just repositioned into the caption's
101
- top-right corner instead of a flex-row side column. */
102
- :global(.keyed-table__pagination-toggle) {
103
- position: absolute;
104
- top: 0;
105
- inset-inline-end: 0;
106
- border: 1px solid var(--sl-color-gray-5);
107
- border-radius: 0.3rem;
108
- }
109
-
113
+ /* Everything this rule block styles is created at runtime (see the
114
+ `<script>` below), so it never carries this component's
115
+ `data-astro-cid-*` scoping attribute — `:global()` is required for the
116
+ same reason `.keyed-table__body :global(table)` above needs it. The fold
117
+ toggle is a `.float-tool` in the caption's button row (`global.css`). */
110
118
  :global(.keyed-table__more-row td) {
111
119
  padding-block: 0.4rem;
120
+ padding-inline: 0;
112
121
  color: var(--sl-color-gray-3);
113
122
  font-size: var(--sl-text-sm);
114
123
  font-style: italic;
115
124
  text-align: center;
116
125
  }
117
126
 
127
+ /* Centred on the part of the table on screen: the cell spans every column,
128
+ so a wide table scrolled or clipped sideways would otherwise centre the
129
+ label on columns the reader cannot see. The label sticks to the scroll
130
+ clip's start edge and is as wide as the clip (`100cqi`, the clip being a
131
+ size container), or as the row when the whole table fits. */
132
+ :global(.keyed-table__more-label) {
133
+ position: sticky;
134
+ inset-inline-start: 0;
135
+ display: block;
136
+ width: 100cqi;
137
+ max-width: 100%;
138
+ }
139
+
118
140
  /* Only the live table's own more-row gets this class — a hover-card
119
141
  preview clone (`FloatRefLayer.astro`) strips it so the same row reads
120
142
  as an inert truncation marker there instead of a dead-looking link. */
@@ -163,32 +185,7 @@ if (!number || !anchorId) {
163
185
  margin-inline-start: 1.25rem;
164
186
  }
165
187
 
166
- .keyed-table__number {
167
- font-weight: 600;
168
- font-variant-numeric: tabular-nums;
169
- color: var(--sl-color-text-accent);
170
- text-decoration: none;
171
- }
172
-
173
- .keyed-table__number:hover,
174
- .keyed-table__number:focus-visible {
175
- text-decoration: underline;
176
- }
177
-
178
- .keyed-table__number:focus-visible {
179
- outline: 2px solid var(--sl-color-accent);
180
- outline-offset: 0.15rem;
181
- }
182
-
183
- .keyed-table__caption-text::before {
184
- content: ' — ';
185
- }
186
-
187
188
  @media print {
188
- .keyed-table__number {
189
- color: inherit;
190
- }
191
-
192
189
  :global(.keyed-table__pagination-links),
193
190
  :global(.keyed-table__more-row) {
194
191
  display: none !important;
@@ -211,7 +208,7 @@ if (!number || !anchorId) {
211
208
  * `truncated` (first/last few rows, the default once a table is long
212
209
  * enough to bother) -> `expanded` (every row). Every table gets the
213
210
  * minimize-to-header-row toggle in its caption, regardless of row count —
214
- * a short table (`totalRows <= PAGINATE_ABOVE_ROWS`) just never grows the
211
+ * a short table (`totalRows <= truncateAfter`) just never grows the
215
212
  * "… N more rows …" truncation row or the "Show less"/"Minimize" links
216
213
  * below it, so it only ever has two reachable states, `minimized` and
217
214
  * `expanded`. No persisted state here, same as `TableOverflowExpander.astro`'s
@@ -231,8 +228,6 @@ if (!number || !anchorId) {
231
228
 
232
229
  type PaginationState = 'minimized' | 'truncated' | 'expanded';
233
230
 
234
- const KEEP_EACH_END = 3;
235
- const PAGINATE_ABOVE_ROWS = 6;
236
231
 
237
232
  // Mirrors `TableOverflowExpander.astro`'s own `captionFor` exactly, for
238
233
  // the same aria-label wording across both tables' controls.
@@ -245,6 +240,25 @@ if (!number || !anchorId) {
245
240
  return text || 'this table';
246
241
  }
247
242
 
243
+ // The fold toggle's chevron: ⌃ folds the rows away, ⌄ brings them back.
244
+ const FOLD_ICON = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 15l6-6 6 6"/></svg>';
245
+ const UNFOLD_ICON = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 9l6 6 6-6"/></svg>';
246
+
247
+ /**
248
+ * The caption's button row (`global.css` `.float-tools`), shared with
249
+ * `TableOverflowExpander.astro`'s widen button: whichever script runs
250
+ * first makes it. The widen button goes first, the fold toggle last.
251
+ */
252
+ function floatTools(caption: HTMLElement): HTMLElement {
253
+ let tools = caption.querySelector<HTMLElement>(':scope > .float-tools');
254
+ if (!tools) {
255
+ tools = document.createElement('span');
256
+ tools.className = 'float-tools';
257
+ caption.append(tools);
258
+ }
259
+ return tools;
260
+ }
261
+
248
262
  function wireTable(figure: HTMLElement) {
249
263
  figure.dataset.paginationWired = 'true';
250
264
 
@@ -260,18 +274,20 @@ if (!number || !anchorId) {
260
274
  if (totalRows === 0) return;
261
275
 
262
276
  // Long enough to also need truncation, on top of the minimize toggle
263
- // every table gets below.
264
- const paginated = totalRows > PAGINATE_ABOVE_ROWS;
277
+ // every table gets below: more rows than the course's
278
+ // `disclosure.tables.truncateAfter`. A truncated table shows up to that
279
+ // many rows, half from each end.
280
+ const truncateAfter = Number(figure.dataset.truncateAfter) || 6;
281
+ const keepEachEnd = Math.floor(truncateAfter / 2);
282
+ const paginated = totalRows > truncateAfter;
265
283
 
266
284
  const caption = figure.querySelector<HTMLElement>(
267
285
  ':scope > .keyed-table__caption',
268
286
  );
269
- caption?.classList.add('keyed-table__caption--paginated');
270
287
  const toggle = document.createElement('button');
271
288
  toggle.type = 'button';
272
- toggle.className =
273
- 'table-overflow-expand-btn keyed-table__pagination-toggle';
274
- caption?.append(toggle);
289
+ toggle.className = 'float-tool keyed-table__pagination-toggle';
290
+ if (caption) floatTools(caption).append(toggle);
275
291
 
276
292
  let moreRow: HTMLTableRowElement | null = null;
277
293
  let links: HTMLElement | null = null;
@@ -279,7 +295,7 @@ if (!number || !anchorId) {
279
295
  let minimizeAll: HTMLButtonElement | null = null;
280
296
 
281
297
  if (paginated) {
282
- const hiddenCount = totalRows - KEEP_EACH_END * 2;
298
+ const hiddenCount = totalRows - keepEachEnd * 2;
283
299
  const headerCells = table.tHead?.rows[0]?.cells ?? rows[0].cells;
284
300
  const colCount =
285
301
  Array.from(headerCells).reduce(
@@ -302,10 +318,13 @@ if (!number || !anchorId) {
302
318
  moreRow.setAttribute('aria-label', `Show all ${totalRows} rows`);
303
319
  const moreCell = document.createElement('td');
304
320
  moreCell.colSpan = colCount;
305
- moreCell.textContent =
321
+ const moreLabel = document.createElement('span');
322
+ moreLabel.className = 'keyed-table__more-label';
323
+ moreLabel.textContent =
306
324
  hiddenCount === 1 ? '… 1 more row …' : `… ${hiddenCount} more rows …`;
325
+ moreCell.append(moreLabel);
307
326
  moreRow.append(moreCell);
308
- tbody.insertBefore(moreRow, rows[KEEP_EACH_END]);
327
+ tbody.insertBefore(moreRow, rows[keepEachEnd]);
309
328
 
310
329
  // Centered below the table body, and only ever shown once every row is
311
330
  // — "Show less" un-truncates back down, "Minimize to header row"
@@ -336,13 +355,13 @@ if (!number || !anchorId) {
336
355
  rows.forEach((row, index) => {
337
356
  const inMiddle =
338
357
  paginated &&
339
- index >= KEEP_EACH_END &&
340
- index < totalRows - KEEP_EACH_END;
358
+ index >= keepEachEnd &&
359
+ index < totalRows - keepEachEnd;
341
360
  row.hidden = minimized || (inMiddle && !expanded);
342
361
  });
343
362
  if (moreRow) moreRow.hidden = minimized || expanded;
344
363
 
345
- toggle.textContent = minimized ? '▾' : '▴';
364
+ toggle.innerHTML = minimized ? UNFOLD_ICON : FOLD_ICON;
346
365
  toggle.setAttribute('aria-expanded', String(!minimized));
347
366
  toggle.setAttribute(
348
367
  'aria-label',
@@ -95,15 +95,11 @@
95
95
  * (`visibility: hidden`) outright — there is no reason to keep any part of
96
96
  * it rendered once the reader has scrolled past it.
97
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
98
+ * Only one table is ever expanded at a time, and expanding one closes the
99
+ * explanation cards only the pointer was keeping open
100
+ * (`closeTransientCards`, `../card-manager.ts`); pinned cards stay. A card
101
+ * opening *afterward* — over an explainable quantity inside the table
102
+ * itself, say — simply floats above the still-open table. The
107
103
  * table closes only through its own explicit gestures — the collapse
108
104
  * buttons or Escape — and deliberately *not* an outside click: a reader
109
105
  * following a link in the "On this page" rail, or clicking anywhere else to
@@ -112,13 +108,18 @@
112
108
  *
113
109
  * No persisted state here (unlike Parts 1/3): expand/collapse is transient
114
110
  * per-view UI, not a reader preference worth remembering across visits.
111
+ *
112
+ * **Caption button**: a keyed table also gets a widen button ↔ in its
113
+ * caption's button row at the top right (`global.css` `.float-tools`), next
114
+ * to the fold toggle — the same action as the right-edge column, found
115
+ * where a reader looks for a table's controls. It shows whenever that
116
+ * column does, and turns into →← while the table is widened.
115
117
  */
116
118
  ---
117
119
 
118
120
  <script>
119
- import { claimActiveSurface, releaseActiveSurface } from "../hover-panel";
121
+ import { closeTransientCards } from "../card-manager";
120
122
 
121
- const GROUP = "table-overflow";
122
123
  const EDGE_PX = 16; // ~1em at the default root font size
123
124
  const WIDTH_BUFFER_PX = 2; // rounding slack so the minimal width never re-triggers a scrollbar
124
125
  const SIDE_COLUMN_PX = 24; // must match `.table-overflow-expand-btn`'s `width: 1.5rem`
@@ -136,6 +137,28 @@
136
137
 
137
138
  let active: Expansion | null = null;
138
139
 
140
+ /** Each keyed table's caption widen button, by its body (the container). */
141
+ const captionButtons = new WeakMap<HTMLElement, HTMLButtonElement>();
142
+
143
+ const WIDEN_ICON =
144
+ '<svg viewBox="0 0 24 24" aria-hidden="true">' +
145
+ '<path class="float-tool__widen" d="M3 12h18M8 7l-5 5 5 5M16 7l5 5-5 5"/>' +
146
+ '<path class="float-tool__narrow" d="M3 12h7M7 9l3 3-3 3M21 12h-7M17 9l-3 3 3 3"/></svg>';
147
+
148
+ /**
149
+ * The caption's button row, shared with `Table.astro`'s fold toggle
150
+ * (which has the same helper): whichever script runs first makes it.
151
+ */
152
+ function floatTools(caption: HTMLElement): HTMLElement {
153
+ let tools = caption.querySelector<HTMLElement>(":scope > .float-tools");
154
+ if (!tools) {
155
+ tools = document.createElement("span");
156
+ tools.className = "float-tools";
157
+ caption.append(tools);
158
+ }
159
+ return tools;
160
+ }
161
+
139
162
  function sidebarLeftBound(): number {
140
163
  const sidebar = document.querySelector<HTMLElement>(".sidebar-pane");
141
164
  const visible =
@@ -187,6 +210,12 @@
187
210
  );
188
211
  button.title = expanded ? "Collapse" : "Expand to full width";
189
212
  button.setAttribute("aria-expanded", String(expanded));
213
+ const captionButton = captionButtons.get(container);
214
+ if (captionButton) {
215
+ captionButton.setAttribute("aria-label", button.getAttribute("aria-label") ?? "");
216
+ captionButton.title = button.title;
217
+ captionButton.setAttribute("aria-expanded", String(expanded));
218
+ }
190
219
  }
191
220
 
192
221
  // A scrollable region must itself be reachable by keyboard (WCAG 2.1.1 —
@@ -216,6 +245,8 @@
216
245
  ) {
217
246
  if (active?.scroll === scroll) return; // never hide the one currently expanded
218
247
  button.hidden = scroll.scrollWidth <= scroll.clientWidth + 1;
248
+ const captionButton = captionButtons.get(container);
249
+ if (captionButton) captionButton.hidden = button.hidden;
219
250
  updateScrollFocusability(scroll, container);
220
251
  }
221
252
 
@@ -250,20 +281,11 @@
250
281
  ) {
251
282
  if (active) collapse();
252
283
 
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);
284
+ // A hovered card would linger over the expanded table; pinned ones stay.
285
+ // A card opened afterward over the table's own content floats above it
286
+ // (the card layer out-ranks it on z-index), and the table closes only
287
+ // through its own gestures (the collapse buttons or Escape, wired below).
288
+ closeTransientCards();
267
289
 
268
290
  // Measured *before* anything moves: `scrollWidth` reports the table's
269
291
  // full, unclipped content width regardless of how narrow this wrapper
@@ -438,13 +460,28 @@
438
460
  button.type = "button";
439
461
  button.className = "table-overflow-expand-btn";
440
462
  button.hidden = true;
441
- updateButtonLabel(container, button, false);
442
463
  container.append(button);
443
464
 
444
- button.addEventListener("click", () => {
465
+ const caption = container
466
+ .closest<HTMLElement>(".keyed-table")
467
+ ?.querySelector<HTMLElement>(":scope > .keyed-table__caption");
468
+ if (caption) {
469
+ const captionButton = document.createElement("button");
470
+ captionButton.type = "button";
471
+ captionButton.className = "float-tool keyed-table__widen-toggle";
472
+ captionButton.hidden = true;
473
+ captionButton.innerHTML = WIDEN_ICON;
474
+ floatTools(caption).prepend(captionButton);
475
+ captionButtons.set(container, captionButton);
476
+ }
477
+ updateButtonLabel(container, button, false);
478
+
479
+ const toggle = () => {
445
480
  if (active?.container === container) collapse();
446
481
  else expand(container, button, scroll);
447
- });
482
+ };
483
+ button.addEventListener("click", toggle);
484
+ captionButtons.get(container)?.addEventListener("click", toggle);
448
485
 
449
486
  evaluateOverflow(scroll, button, container);
450
487