explico 0.4.0 → 0.6.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 (150) hide show
  1. package/CHANGELOG.md +382 -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 +88 -1
  10. package/dist/compiler/course-config.js +35 -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 +98 -6
  14. package/dist/compiler/manifest.js +154 -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-heading-length.d.ts +12 -0
  31. package/dist/reference/gate-heading-length.js +79 -0
  32. package/dist/reference/gate-math.d.ts +32 -5
  33. package/dist/reference/gate-math.js +6 -2
  34. package/dist/reference/gate-prose-notation.d.ts +60 -0
  35. package/dist/reference/gate-prose-notation.js +125 -0
  36. package/dist/reference/keyed-float-validate.d.ts +5 -5
  37. package/dist/reference/keyed-jsx-floats.d.mts +7 -12
  38. package/dist/reference/keyed-jsx-floats.mjs +50 -25
  39. package/dist/reference/knowledge-checks-validate.d.ts +35 -0
  40. package/dist/reference/knowledge-checks-validate.js +78 -0
  41. package/dist/reference/knowledge-checks.d.mts +25 -0
  42. package/dist/reference/knowledge-checks.mjs +45 -0
  43. package/dist/reference/lab-math-scope.d.ts +9 -1
  44. package/dist/reference/lab-math-scope.js +32 -4
  45. package/dist/reference/math-glyphs.d.mts +42 -0
  46. package/dist/reference/math-glyphs.mjs +548 -40
  47. package/dist/reference/mdx-attributes.d.mts +13 -0
  48. package/dist/reference/mdx-attributes.mjs +38 -0
  49. package/dist/reference/mixed-content.d.ts +17 -0
  50. package/dist/reference/mixed-content.js +29 -0
  51. package/dist/reference/page-notation.d.ts +52 -0
  52. package/dist/reference/page-notation.js +171 -0
  53. package/dist/reference/raw-floats-validate.d.ts +18 -0
  54. package/dist/reference/raw-floats-validate.js +53 -0
  55. package/dist/reference/raw-floats.d.mts +18 -0
  56. package/dist/reference/raw-floats.mjs +129 -0
  57. package/dist/reference/rehype-heading-anchors.d.mts +1 -1
  58. package/dist/reference/rehype-heading-anchors.mjs +107 -1
  59. package/dist/reference/rehype-table-header-labels.d.mts +26 -0
  60. package/dist/reference/rehype-table-header-labels.mjs +152 -0
  61. package/dist/reference/remark-abbreviations.d.mts +7 -0
  62. package/dist/reference/remark-abbreviations.mjs +174 -0
  63. package/dist/reference/remark-citation.d.mts +5 -1
  64. package/dist/reference/remark-citation.mjs +0 -0
  65. package/dist/reference/remark-memo.d.mts +16 -0
  66. package/dist/reference/remark-memo.mjs +339 -0
  67. package/dist/reference/remark-notation.d.mts +57 -9
  68. package/dist/reference/remark-notation.mjs +354 -42
  69. package/dist/reference/remark-short-note.d.mts +4 -0
  70. package/dist/reference/remark-short-note.mjs +237 -0
  71. package/dist/reference/types.d.ts +16 -1
  72. package/dist/reference/units-vocab.js +4 -0
  73. package/docs/VERSIONING.md +53 -4
  74. package/docs/authoring/README.md +10 -0
  75. package/docs/authoring/agents.md +17 -2
  76. package/docs/authoring/components.md +138 -0
  77. package/docs/authoring/migrating.md +275 -7
  78. package/docs/authoring/notation.md +615 -1
  79. package/docs/authoring/structure-rules.md +69 -0
  80. package/docs/authoring/structure.md +120 -8
  81. package/docs/authoring/visualizations.md +36 -3
  82. package/package.json +5 -2
  83. package/src/compiler/astro.ts +208 -3
  84. package/src/compiler/cli.ts +29 -2
  85. package/src/compiler/collections.ts +45 -4
  86. package/src/compiler/course-config.ts +132 -0
  87. package/src/compiler/exports/standalone-html.ts +963 -0
  88. package/src/compiler/manifest.ts +281 -22
  89. package/src/components/HoverPanel.astro +45 -4
  90. package/src/components/abbreviation/AbbreviationGlossary.astro +70 -0
  91. package/src/components/abbreviation/AbbreviationLayer.astro +127 -0
  92. package/src/components/assessments/AssessmentRunner.tsx +52 -17
  93. package/src/components/assessments/AssessmentSet.astro +98 -14
  94. package/src/components/assessments/RenderedContent.tsx +55 -0
  95. package/src/components/assessments/assessment-randomization.ts +16 -0
  96. package/src/components/diagram/Diagram.astro +409 -103
  97. package/src/components/disclosure/hover-preview.ts +202 -0
  98. package/src/components/disclosure/persisted-disclosure.ts +66 -0
  99. package/src/components/examples/CompactExamples.astro +2 -0
  100. package/src/components/figure/Figure.astro +279 -10
  101. package/src/components/memo/Memo.astro +453 -0
  102. package/src/components/memo/ShortNote.astro +223 -0
  103. package/src/components/notation/NotationFormula.astro +18 -1
  104. package/src/components/notation/NotationLayer.astro +260 -83
  105. package/src/components/notation/render-notation-math.ts +39 -2
  106. package/src/components/notation/safe-math-to-react.ts +50 -0
  107. package/src/components/reference/FloatRefLayer.astro +233 -15
  108. package/src/components/starlight/CollapsibleSections.astro +461 -0
  109. package/src/components/starlight/DiagramEnhancer.astro +385 -0
  110. package/src/components/starlight/HeadingAnchorEnhancer.astro +5 -17
  111. package/src/components/starlight/KeyedFloatEnhancer.astro +81 -3
  112. package/src/components/starlight/LayoutHeader.astro +50 -0
  113. package/src/components/starlight/LayoutSocialIcons.astro +41 -2
  114. package/src/components/starlight/LessonExportActions.astro +436 -0
  115. package/src/components/starlight/LessonFooter.astro +30 -3
  116. package/src/components/starlight/section-parts.ts +31 -0
  117. package/src/components/table/Table.astro +293 -7
  118. package/src/components/table/TableOverflowExpander.astro +489 -0
  119. package/src/content-config.ts +36 -1
  120. package/src/reference/base-abbreviations.mjs +60 -0
  121. package/src/reference/base-operators.mjs +40 -0
  122. package/src/reference/base-roman-labels.mjs +43 -0
  123. package/src/reference/consistency.ts +73 -2
  124. package/src/reference/diagrams-validate.ts +40 -0
  125. package/src/reference/equations.mjs +67 -14
  126. package/src/reference/gate-abbreviations.ts +128 -0
  127. package/src/reference/gate-heading-length.ts +131 -0
  128. package/src/reference/gate-math.ts +32 -7
  129. package/src/reference/gate-prose-notation.ts +224 -0
  130. package/src/reference/keyed-float-validate.ts +5 -5
  131. package/src/reference/keyed-jsx-floats.mjs +50 -25
  132. package/src/reference/knowledge-checks-validate.ts +130 -0
  133. package/src/reference/knowledge-checks.mjs +45 -0
  134. package/src/reference/lab-math-scope.ts +45 -3
  135. package/src/reference/math-glyphs.mjs +548 -40
  136. package/src/reference/mdx-attributes.mjs +38 -0
  137. package/src/reference/mixed-content.ts +69 -0
  138. package/src/reference/page-notation.ts +252 -0
  139. package/src/reference/raw-floats-validate.ts +103 -0
  140. package/src/reference/raw-floats.mjs +129 -0
  141. package/src/reference/rehype-heading-anchors.mjs +107 -1
  142. package/src/reference/rehype-table-header-labels.mjs +152 -0
  143. package/src/reference/remark-abbreviations.mjs +174 -0
  144. package/src/reference/remark-citation.mjs +0 -0
  145. package/src/reference/remark-memo.mjs +339 -0
  146. package/src/reference/remark-notation.mjs +354 -42
  147. package/src/reference/remark-short-note.mjs +237 -0
  148. package/src/reference/types.ts +16 -0
  149. package/src/reference/units-vocab.ts +4 -0
  150. package/src/styles/global.css +996 -50
@@ -15,8 +15,15 @@
15
15
  */
16
16
  import NotationMath from './NotationMath.astro';
17
17
  import SafeMathNode from './SafeMathNode.astro';
18
- import { renderNotationFormula } from './render-notation-math';
18
+ import {
19
+ renderNotationFormula,
20
+ stripNotationMarkers,
21
+ } from './render-notation-math';
19
22
  import type { GlyphScopeEntry, ResolvedGloss } from '../../reference/gloss';
23
+ import {
24
+ notationKeysInMathNodes,
25
+ registerPageNotationDependencies,
26
+ } from '../../reference/page-notation';
20
27
 
21
28
  export interface Props {
22
29
  formula: string;
@@ -36,12 +43,18 @@ const {
36
43
  entryLabel,
37
44
  } = Astro.props;
38
45
  const { nodes } = renderNotationFormula(formula, scope);
46
+ registerPageNotationDependencies(
47
+ Astro.locals,
48
+ notationKeysInMathNodes(nodes),
49
+ `NotationFormula formula "${formula}"`,
50
+ );
39
51
  ---
40
52
 
41
53
  <div class="notation-formula-block">
42
54
  <div
43
55
  class="notation-formula-math"
44
56
  data-notation-formula
57
+ data-latex={stripNotationMarkers(formula)}
45
58
  role="group"
46
59
  aria-label={entryLabel ? `${entryLabel} formula` : 'Formula'}
47
60
  tabindex="0"
@@ -88,6 +101,10 @@ const { nodes } = renderNotationFormula(formula, scope);
88
101
  container is the fix; this is its focus ring. */
89
102
  .notation-formula-math {
90
103
  overflow-x: auto;
104
+ /* `overflow-x: auto` alone makes the block axis compute to `auto` too
105
+ (CSS Overflow §3) — equations must never scroll vertically on their
106
+ own, only sideways. */
107
+ overflow-y: hidden;
91
108
  padding-block: 0.15rem;
92
109
  color: var(--sl-color-text);
93
110
  }
@@ -42,10 +42,15 @@ export interface Props {
42
42
  * links to reveal an explanation on hover.
43
43
  */
44
44
  presentation?: 'full' | 'panel';
45
+ /** Author-chosen state for a reader with nothing saved yet. Default: `false` (closed) — see `docs/authoring` on `CourseConfig.disclosureDefaults`. */
46
+ defaultOpen?: boolean;
47
+ /** Persist the reader's open/closed choice across visits, per page. Default: `true`. */
48
+ rememberState?: boolean;
45
49
  }
46
50
 
47
51
  import NotationMath from './NotationMath.astro';
48
52
  import NotationFormula from './NotationFormula.astro';
53
+ import { stripNotationMarkers } from './render-notation-math';
49
54
  import type { GlyphScopeEntry, ResolvedGloss } from '../../reference/gloss';
50
55
 
51
56
  const {
@@ -54,6 +59,8 @@ const {
54
59
  label = 'Notation used on this page',
55
60
  glossaryHref = `${import.meta.env.BASE_URL.replace(/\/$/, '')}/glossary/`,
56
61
  presentation = 'full',
62
+ defaultOpen = false,
63
+ rememberState = true,
57
64
  } = Astro.props;
58
65
 
59
66
  const panelOnly = presentation === 'panel';
@@ -110,16 +117,16 @@ for (const gloss of glossItems) {
110
117
  class:list={['notation-layer', panelOnly && 'notation-layer--panel-only']}
111
118
  data-notation-layer
112
119
  data-notation-layer-id={id}
113
- aria-labelledby={panelOnly ? undefined : `${id}-heading`}
114
- aria-label={panelOnly ? 'Notation explanations' : undefined}
120
+ aria-label={panelOnly ? 'Notation explanations' : label}
115
121
  >
116
- {!panelOnly && (
117
- <h2 id={`${id}-heading`} class="notation-layer-heading">
118
- Page notation
119
- </h2>
120
- )}
121
-
122
- <details class="notation-list" hidden={panelOnly}>
122
+ <div class="notation-list-titlebar">
123
+ <details
124
+ class="notation-list"
125
+ id={id}
126
+ hidden={panelOnly}
127
+ data-remember-state={String(rememberState)}
128
+ open={defaultOpen}
129
+ >
123
130
  <summary>
124
131
  {label} <span class="notation-count">({items.length})</span>
125
132
  </summary>
@@ -169,7 +176,11 @@ for (const gloss of glossItems) {
169
176
  entryLabel={definition.label}
170
177
  />
171
178
  ) : (
172
- <span class="notation-formula" data-notation-formula>
179
+ <span
180
+ class="notation-formula"
181
+ data-notation-formula
182
+ data-latex={stripNotationMarkers(definition.formula)}
183
+ >
173
184
  <NotationMath latex={definition.formula} />
174
185
  </span>
175
186
  ))}
@@ -178,16 +189,6 @@ for (const gloss of glossItems) {
178
189
  <strong>Units:</strong> {definition.units}
179
190
  </p>
180
191
  )}
181
- <button
182
- class="notation-pin"
183
- type="button"
184
- data-notation-control={definition.key}
185
- aria-expanded="false"
186
- aria-pressed="false"
187
- hidden
188
- >
189
- Pin explanation
190
- </button>
191
192
  </dd>
192
193
  </div>
193
194
  ))}
@@ -211,14 +212,32 @@ for (const gloss of glossItems) {
211
212
  </div>
212
213
  ))}
213
214
  </details>
215
+ {!panelOnly && (
216
+ <a
217
+ class="notation-list-anchor"
218
+ href={`#${id}`}
219
+ aria-label={`${label}, titled section`}
220
+ >
221
+ <svg
222
+ aria-hidden="true"
223
+ width="16"
224
+ height="16"
225
+ viewBox="0 0 24 24"
226
+ fill="currentColor"
227
+ ><path
228
+ 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"
229
+ ></path></svg
230
+ >
231
+ </a>
232
+ )}
233
+ </div>
214
234
 
215
235
  {!panelOnly && (
216
236
  <p class="notation-enhancement-help" data-notation-help hidden>
217
237
  Point to a marked symbol for a quick explanation, or use the list
218
238
  above — pointing at a symbol inside an open card explains that one
219
- too, alongside it. Click a symbol, or select “Pin explanation”, to
220
- keep a card open; click the card (or its “×”) to close it, or press
221
- Escape.
239
+ too, alongside it. Click a symbol to keep its card open; click the
240
+ card (or its “×”) to close it, or press Escape.
222
241
  </p>
223
242
  )}
224
243
 
@@ -244,6 +263,14 @@ for (const gloss of glossItems) {
244
263
  </header>
245
264
  <p data-slot-summary hidden />
246
265
  <div data-slot-formula hidden />
266
+ <button
267
+ class="notation-popup-copy"
268
+ type="button"
269
+ data-popup-copy
270
+ hidden
271
+ >
272
+ Copy LaTeX
273
+ </button>
247
274
  <p class="notation-units" data-slot-units hidden />
248
275
  <a data-slot-link hidden>
249
276
  Open the full definition
@@ -257,6 +284,7 @@ for (const gloss of glossItems) {
257
284
 
258
285
  <script>
259
286
  import { createPopupStack, closestElement } from '../popup-stack';
287
+ import { wirePersistedDetails } from '../disclosure/persisted-disclosure';
260
288
 
261
289
  const triggerSelector = '[data-notation-key]';
262
290
 
@@ -283,10 +311,6 @@ for (const gloss of glossItems) {
283
311
  );
284
312
  if (!template) return;
285
313
 
286
- const controls = Array.from(
287
- root.querySelectorAll<HTMLButtonElement>('[data-notation-control]'),
288
- );
289
-
290
314
  // Static triggers, computed once: markers embedded in prose or an
291
315
  // equation, and the symbol glyphs in the "Notation used on this page"
292
316
  // list. These get the always-on `.notation-trigger` cursor/hover
@@ -311,20 +335,32 @@ for (const gloss of glossItems) {
311
335
  for (const trigger of highlightTargets) {
312
336
  trigger.classList.add('notation-trigger');
313
337
  }
314
- for (const control of controls) control.hidden = false;
315
338
  const help = root.querySelector<HTMLElement>('[data-notation-help]');
316
339
  if (help) help.hidden = false;
317
340
 
318
- const keyFor = (element: HTMLElement | null) =>
319
- element?.dataset.notationKey ??
320
- element?.dataset.notationControl ??
321
- element?.dataset.notationLink ??
322
- null;
341
+ // The visible list's own symbol glyphs are the only keyboard path to
342
+ // pinning a card from this list (the old "Pin explanation" button was
343
+ // the other one, and is gone) — make them a real, labelled, Enter/Space-
344
+ // operable control rather than a mouse-only `<span>`. `symbolTriggers`
345
+ // (defined above) is already scoped to entries inside this list.
346
+ for (const trigger of symbolTriggers) {
347
+ const key = trigger.dataset.notationKey ?? '';
348
+ const entryLabel =
349
+ entries.get(key)?.querySelector<HTMLElement>('[data-notation-link]')
350
+ ?.textContent?.trim() ?? key;
351
+ trigger.setAttribute('role', 'button');
352
+ trigger.tabIndex = 0;
353
+ trigger.setAttribute('aria-pressed', 'false');
354
+ trigger.setAttribute('aria-label', `Pin the ${entryLabel} explanation`);
355
+ trigger.addEventListener('keydown', (event) => {
356
+ if (event.key !== 'Enter' && event.key !== ' ') return;
357
+ event.preventDefault();
358
+ trigger.click();
359
+ });
360
+ }
323
361
 
324
- const controlFrom = (node: EventTarget | null) => {
325
- const candidate = closestElement(node, '[data-notation-control]');
326
- return candidate && root.contains(candidate) ? candidate : null;
327
- };
362
+ const keyFor = (element: HTMLElement | null) =>
363
+ element?.dataset.notationKey ?? element?.dataset.notationLink ?? null;
328
364
 
329
365
  const staticLinkFrom = (node: EventTarget | null) => {
330
366
  const candidate = closestElement(node, '[data-notation-link]');
@@ -344,7 +380,7 @@ for (const gloss of glossItems) {
344
380
  };
345
381
 
346
382
  const triggerFrom = (node: EventTarget | null) =>
347
- controlFrom(node) ?? staticLinkFrom(node) ?? symbolFrom(node);
383
+ staticLinkFrom(node) ?? symbolFrom(node);
348
384
 
349
385
  const labelOf = (entry: HTMLElement, key: string) =>
350
386
  entry
@@ -369,7 +405,18 @@ for (const gloss of glossItems) {
369
405
  const units = fragment.querySelector<HTMLElement>('[data-slot-units]');
370
406
  const link =
371
407
  fragment.querySelector<HTMLAnchorElement>('[data-slot-link]');
372
- if (!el || !symbolHost || !title || !summary || !formulaHost || !units) {
408
+ const copyButton = fragment.querySelector<HTMLButtonElement>(
409
+ '[data-popup-copy]',
410
+ );
411
+ if (
412
+ !el ||
413
+ !symbolHost ||
414
+ !title ||
415
+ !summary ||
416
+ !formulaHost ||
417
+ !units ||
418
+ !copyButton
419
+ ) {
373
420
  return null;
374
421
  }
375
422
 
@@ -395,6 +442,35 @@ for (const gloss of glossItems) {
395
442
  ...(sourceFormula ? [sourceFormula.cloneNode(true)] : []),
396
443
  );
397
444
  formulaHost.hidden = sourceFormula === null;
445
+
446
+ // The formula's own `data-latex` carries the plain, authored LaTeX —
447
+ // not the `\explain{key}{…}`-wrapped source KaTeX actually rendered —
448
+ // so what a reader copies is what they'd write themselves, not an
449
+ // internal marker macro. See `render-notation-math.ts:renderNotationFormula`.
450
+ const formulaLatex = sourceFormula?.dataset.latex ?? '';
451
+ copyButton.hidden = formulaLatex.length === 0;
452
+ if (formulaLatex.length > 0) {
453
+ copyButton.addEventListener('click', (event) => {
454
+ // Otherwise this bubbles to the document click handler in
455
+ // `popup-stack.ts`, which treats any click inside a popup that
456
+ // isn't a trigger or a link as "close it" — that would remove the
457
+ // card before the "Copied!" flash below ever paints.
458
+ event.stopPropagation();
459
+ if (!navigator.clipboard) return;
460
+ navigator.clipboard.writeText(formulaLatex).then(
461
+ () => {
462
+ copyButton.dataset.copied = 'true';
463
+ window.setTimeout(() => {
464
+ delete copyButton.dataset.copied;
465
+ }, 1200);
466
+ },
467
+ () => {
468
+ /* Clipboard blocked: nothing more we can do here. */
469
+ },
470
+ );
471
+ });
472
+ }
473
+
398
474
  setOptionalText(
399
475
  units,
400
476
  entry
@@ -412,21 +488,6 @@ for (const gloss of glossItems) {
412
488
  return el;
413
489
  };
414
490
 
415
- const updateControls = (
416
- open: ReadonlySet<string>,
417
- pinned: ReadonlySet<string>,
418
- ) => {
419
- for (const control of controls) {
420
- const key = control.dataset.notationControl ?? '';
421
- const selected = pinned.has(key);
422
- control.setAttribute('aria-pressed', String(selected));
423
- control.setAttribute('aria-expanded', String(open.has(key)));
424
- control.textContent = selected
425
- ? 'Unpin explanation'
426
- : 'Pin explanation';
427
- }
428
- };
429
-
430
491
  createPopupStack({
431
492
  // Popups are `position: fixed` and float above everything regardless
432
493
  // of DOM placement — *except* z-index only ever wins within its own
@@ -440,7 +501,6 @@ for (const gloss of glossItems) {
440
501
  triggerFrom,
441
502
  keyFor,
442
503
  createPopup,
443
- pinControlFrom: controlFrom,
444
504
  onChange: (open, pinned) => {
445
505
  for (const trigger of highlightTargets) {
446
506
  trigger.classList.toggle(
@@ -448,15 +508,44 @@ for (const gloss of glossItems) {
448
508
  open.has(trigger.dataset.notationKey ?? ''),
449
509
  );
450
510
  }
451
- updateControls(open, pinned);
511
+ for (const trigger of symbolTriggers) {
512
+ trigger.setAttribute(
513
+ 'aria-pressed',
514
+ String(pinned.has(trigger.dataset.notationKey ?? '')),
515
+ );
516
+ }
452
517
  },
453
518
  });
454
519
  }
455
520
 
521
+ function setupNotationDisclosure(details: HTMLDetailsElement) {
522
+ if (details.dataset.disclosureWired === 'true') return;
523
+ details.dataset.disclosureWired = 'true';
524
+
525
+ // Same convention as `Figure.astro`: the SSR-rendered `open` attribute
526
+ // already carries the author's `defaultOpen` prop verbatim.
527
+ const defaultOpen = details.open;
528
+ if (details.dataset.rememberState === 'true') {
529
+ wirePersistedDetails(details, `notation:${details.id}`, defaultOpen);
530
+ }
531
+
532
+ // Deep-linking to `#page-notation` (the anchor link, or a shared URL)
533
+ // must reveal it even if a reader left it collapsed.
534
+ const openIfTargeted = () => {
535
+ if (location.hash === `#${details.id}`) details.open = true;
536
+ };
537
+ openIfTargeted();
538
+ window.addEventListener('hashchange', openIfTargeted);
539
+ }
540
+
456
541
  for (const root of document.querySelectorAll<HTMLElement>(
457
542
  '[data-notation-layer]',
458
543
  )) {
459
544
  setupNotationLayer(root);
545
+ const details = root.querySelector<HTMLDetailsElement>(
546
+ ':scope > .notation-list-titlebar > .notation-list[id]',
547
+ );
548
+ if (details) setupNotationDisclosure(details);
460
549
  }
461
550
  </script>
462
551
 
@@ -473,9 +562,13 @@ for (const gloss of glossItems) {
473
562
  margin: 0;
474
563
  }
475
564
 
476
- .notation-layer-heading {
477
- margin: 0 0 0.65rem;
478
- font-size: var(--sl-text-lg);
565
+ /* Positioning context for `.notation-list-anchor`, mirroring
566
+ `Figure.astro`'s `.keyed-figure__titlebar` — the anchor link is a DOM
567
+ sibling of `<details>`, not nested inside `<summary>` (a focusable `<a>`
568
+ inside `<summary>` trips axe's `nested-interactive` check the same way
569
+ it would there). */
570
+ .notation-list-titlebar {
571
+ position: relative;
479
572
  }
480
573
 
481
574
  .notation-list {
@@ -486,8 +579,52 @@ for (const gloss of glossItems) {
486
579
 
487
580
  .notation-list > summary {
488
581
  padding: 0.8rem 1rem;
582
+ padding-inline-end: 2.2rem;
489
583
  cursor: pointer;
490
584
  font-weight: 600;
585
+ font-size: var(--sl-text-lg);
586
+ }
587
+
588
+ .notation-list-anchor {
589
+ position: absolute;
590
+ top: 0.9rem;
591
+ inset-inline-end: 1rem;
592
+ display: inline-flex;
593
+ align-items: center;
594
+ justify-content: center;
595
+ width: 1em;
596
+ height: 1em;
597
+ font-size: var(--sl-text-lg);
598
+ color: var(--sl-color-gray-3);
599
+ text-decoration: none;
600
+ -webkit-user-select: none;
601
+ user-select: none;
602
+ }
603
+
604
+ .notation-list-anchor svg {
605
+ width: 0.7em;
606
+ height: 0.7em;
607
+ }
608
+
609
+ .notation-list-anchor:hover,
610
+ .notation-list-anchor:focus-visible {
611
+ color: var(--sl-color-text-accent);
612
+ }
613
+
614
+ .notation-list-anchor:focus-visible {
615
+ outline: 2px solid var(--sl-color-accent);
616
+ outline-offset: 0.15rem;
617
+ }
618
+
619
+ @media (hover: hover) {
620
+ .notation-list-anchor {
621
+ opacity: 0;
622
+ }
623
+
624
+ .notation-list-anchor:focus-visible,
625
+ .notation-list-titlebar:hover .notation-list-anchor {
626
+ opacity: 1;
627
+ }
491
628
  }
492
629
 
493
630
  .notation-count,
@@ -541,14 +678,28 @@ for (const gloss of glossItems) {
541
678
  cursor: help;
542
679
  }
543
680
 
681
+ /* Entries in the visible list are keyboard-operable (Enter/Space pins
682
+ the same card a click would) now that the list has no separate "Pin
683
+ explanation" button of its own — see the setup script below. */
684
+ .notation-entry .notation-symbol-trigger {
685
+ cursor: pointer;
686
+ }
687
+
544
688
  .notation-symbol-trigger:hover,
689
+ .notation-symbol-trigger:focus-visible,
545
690
  .notation-symbol-trigger.is-notation-active {
546
691
  background: var(--sl-color-accent-low);
547
692
  box-shadow: 0 0 0 0.12em var(--sl-color-accent-low);
548
693
  }
549
694
 
695
+ .notation-symbol-trigger:focus-visible {
696
+ outline: 2px solid var(--sl-color-accent);
697
+ outline-offset: 0.1em;
698
+ }
699
+
550
700
  @media (forced-colors: active) {
551
701
  .notation-symbol-trigger:hover,
702
+ .notation-symbol-trigger:focus-visible,
552
703
  .notation-symbol-trigger.is-notation-active {
553
704
  outline: 2px solid Highlight;
554
705
  color: HighlightText;
@@ -573,28 +724,14 @@ for (const gloss of glossItems) {
573
724
 
574
725
  .notation-formula {
575
726
  overflow-x: auto;
727
+ /* `overflow-x: auto` alone makes the block axis compute to `auto` too
728
+ (CSS Overflow §3) — equations must never scroll vertically on their
729
+ own, only sideways. */
730
+ overflow-y: hidden;
576
731
  padding-block: 0.15rem;
577
732
  color: var(--sl-color-text);
578
733
  }
579
734
 
580
- .notation-pin {
581
- justify-self: start;
582
- padding: 0.25rem 0.55rem;
583
- border: 1px solid var(--sl-color-gray-5);
584
- border-radius: 0.35rem;
585
- color: var(--sl-color-text-accent);
586
- background: var(--sl-color-bg);
587
- font: inherit;
588
- font-size: var(--sl-text-sm);
589
- cursor: pointer;
590
- }
591
-
592
- .notation-pin:hover,
593
- .notation-pin[aria-pressed='true'] {
594
- border-color: var(--sl-color-accent);
595
- background: var(--sl-color-accent-low);
596
- }
597
-
598
735
  .notation-enhancement-help {
599
736
  margin: 0.55rem 0 0;
600
737
  font-size: var(--sl-text-xs);
@@ -612,15 +749,19 @@ for (const gloss of glossItems) {
612
749
  margin: 0;
613
750
  width: min(23rem, calc(100vw - 1.5rem));
614
751
  overflow: auto;
615
- padding: 1rem;
616
- padding-inline-end: 2.4rem;
752
+ padding-block: 0.75rem;
753
+ padding-inline: 1rem 2.4rem;
617
754
  border: 1px solid var(--sl-color-gray-5);
618
755
  border-top: 0.25rem solid var(--sl-color-accent);
619
756
  border-radius: 0.65rem;
620
757
  color: var(--sl-color-text);
621
758
  background: var(--sl-color-bg);
622
759
  box-shadow: var(--sl-shadow-lg);
623
- font-size: var(--sl-text-sm);
760
+ /* Relative to the surrounding prose's own font-size, not a fixed
761
+ Starlight step — the card is mounted on `<body>`, alongside the
762
+ article rather than nested inside it, so `%` still resolves against
763
+ the same base the article's paragraphs use. */
764
+ font-size: 90%;
624
765
  }
625
766
 
626
767
  .notation-popup-close {
@@ -677,15 +818,51 @@ for (const gloss of glossItems) {
677
818
  }
678
819
 
679
820
  .notation-panel-body p {
680
- margin-block: 0.65rem;
821
+ margin-block: 0.5rem;
681
822
  color: var(--sl-color-gray-2);
682
- font-size: var(--sl-text-sm);
683
823
  line-height: 1.5;
684
824
  }
685
825
 
826
+ .notation-popup-copy {
827
+ position: relative;
828
+ margin-block: 0.4rem 0.6rem;
829
+ padding: 0.2rem 0.55rem;
830
+ border: 1px solid var(--sl-color-gray-5);
831
+ border-radius: 0.35rem;
832
+ color: var(--sl-color-text-accent);
833
+ background: var(--sl-color-bg);
834
+ font: inherit;
835
+ font-size: var(--sl-text-xs);
836
+ cursor: pointer;
837
+ }
838
+
839
+ .notation-popup-copy:hover {
840
+ border-color: var(--sl-color-accent);
841
+ background: var(--sl-color-accent-low);
842
+ }
843
+
844
+ .notation-popup-copy[data-copied]::after {
845
+ content: 'Copied!';
846
+ position: absolute;
847
+ inset-block-end: calc(100% + 0.35rem);
848
+ inset-inline-start: 50%;
849
+ translate: -50% 0;
850
+ padding: 0.15rem 0.45rem;
851
+ border-radius: 0.35rem;
852
+ background: var(--sl-color-accent-low);
853
+ color: var(--sl-color-text-accent);
854
+ font-size: var(--sl-text-xs);
855
+ white-space: nowrap;
856
+ pointer-events: none;
857
+ }
858
+
686
859
  .notation-panel-body [data-slot-formula] {
687
860
  overflow-x: auto;
688
- margin-block: 0.65rem;
861
+ /* See `.notation-formula` above: pairs with `overflow-x` to stop the
862
+ auto-both-axes quirk from giving the formula its own vertical
863
+ scrollbar. */
864
+ overflow-y: hidden;
865
+ margin-block: 0.5rem;
689
866
  color: var(--sl-color-text);
690
867
  }
691
868
 
@@ -1,7 +1,11 @@
1
1
  import rehypeKatex from 'rehype-katex';
2
2
  import { createNotationKatexOptions } from '../../reference/katex-options.mjs';
3
3
  import { NOTATION_KEY_PATTERN } from '../../reference/remark-notation.mjs';
4
- import { resolveMathGlyphs } from '../../reference/math-glyphs.mjs';
4
+ import {
5
+ GlyphResolutionError,
6
+ resolveMathGlyphs,
7
+ stripNotationMarkers,
8
+ } from '../../reference/math-glyphs.mjs';
5
9
  import type { GlyphScopeEntry } from '../../reference/gloss';
6
10
 
7
11
  interface HastText {
@@ -32,6 +36,19 @@ export type SafeMathNode =
32
36
  children: readonly SafeMathNode[];
33
37
  };
34
38
 
39
+ /**
40
+ * A string that mixes prose and math, already split and rendered — text runs
41
+ * verbatim, a math run as the same safe, structured tree `SafeMathNode` is.
42
+ * Produced by `AssessmentSet.astro` from `manifest.assessmentContent`
43
+ * (`ContentSegment[]`, unrendered LaTeX) via {@link renderNotationMath}, and
44
+ * consumed by `AssessmentRunner`'s React-side recursive renderer
45
+ * (`RenderedContent.tsx`) — the React equivalent of `SafeMathNode.astro`,
46
+ * since a React island can't render an `.astro` component.
47
+ */
48
+ export type MixedContentNode =
49
+ | { type: 'text'; value: string }
50
+ | { type: 'math'; nodes: readonly SafeMathNode[] };
51
+
35
52
  const allowedTags = new Set([
36
53
  'annotation',
37
54
  'math',
@@ -203,6 +220,14 @@ function sanitizeNode(node: HastNode): SafeMathNode {
203
220
  });
204
221
  }
205
222
 
223
+ // Re-exported so existing imports of `stripNotationMarkers` from this module
224
+ // (the notation popup card's "Copy LaTeX" button) keep working. The
225
+ // implementation lives in `math-glyphs.mjs` because `remark-notation.mjs`
226
+ // (a numbered display equation's own copy button, at build time) needs it
227
+ // too, and importing it from here would make that a circular dependency —
228
+ // this module already imports from `remark-notation.mjs`.
229
+ export { stripNotationMarkers };
230
+
206
231
  /**
207
232
  * Render repository-authored LaTeX through the same build-time KaTeX adapter
208
233
  * as lesson math, then expose a constrained structured tree to Astro. No HTML
@@ -274,7 +299,19 @@ export function renderNotationFormula(
274
299
  const result = resolveMathGlyphs(formula, [...scope]);
275
300
  bound = result.latex;
276
301
  unresolved = result.unresolved.map(({ token }: { token: string }) => token);
277
- } catch {
302
+ } catch (error) {
303
+ // N13 (a nonlinear function with no parenthesized argument) is a hard,
304
+ // no-exceptions rule like N9 — never degrade it to inert math, so both
305
+ // `Diagram` (which turns `degraded` into a build error anyway, but with
306
+ // a misleading "notation scope" message) and `NotationFormula` (which
307
+ // otherwise keeps a degraded formula's inert-but-correct fallback) fail
308
+ // loudly with the actual fix instead.
309
+ if (
310
+ error instanceof GlyphResolutionError &&
311
+ error.code === 'unparenthesized-function'
312
+ ) {
313
+ throw error;
314
+ }
278
315
  bound = formula;
279
316
  degraded = true;
280
317
  }