explico 0.6.0 → 0.7.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 (73) hide show
  1. package/CHANGELOG.md +180 -1
  2. package/README.md +16 -2
  3. package/dist/compiler/astro.d.ts +13 -2
  4. package/dist/compiler/astro.js +39 -0
  5. package/dist/compiler/cli.d.ts +22 -3
  6. package/dist/compiler/cli.js +71 -10
  7. package/dist/compiler/exports/standalone-html.js +13 -0
  8. package/dist/compiler/logging.d.ts +109 -0
  9. package/dist/compiler/logging.js +107 -0
  10. package/dist/compiler/manifest.d.ts +7 -3
  11. package/dist/compiler/manifest.js +1 -1
  12. package/dist/reference/base-abbreviations.mjs +8 -2
  13. package/dist/reference/consistency.d.ts +8 -10
  14. package/dist/reference/consistency.js +3 -47
  15. package/dist/reference/delimiter-size.d.ts +39 -0
  16. package/dist/reference/delimiter-size.js +177 -0
  17. package/dist/reference/diagrams-validate.d.ts +1 -1
  18. package/dist/reference/equations-validate.d.ts +2 -2
  19. package/dist/reference/equations-validate.js +1 -1
  20. package/dist/reference/figures-validate.d.ts +1 -1
  21. package/dist/reference/gate-math.d.ts +2 -0
  22. package/dist/reference/gate-math.js +28 -6
  23. package/dist/reference/keyed-float-validate.d.ts +2 -2
  24. package/dist/reference/keyed-float-validate.js +1 -1
  25. package/dist/reference/references.js +2 -1
  26. package/dist/reference/registry.js +1 -1
  27. package/dist/reference/rehype-base-links.d.mts +23 -0
  28. package/dist/reference/rehype-base-links.mjs +40 -0
  29. package/dist/reference/remark-abbreviations.mjs +10 -12
  30. package/dist/reference/remark-citation.mjs +0 -0
  31. package/dist/reference/render-lab-math.d.ts +18 -1
  32. package/dist/reference/render-lab-math.js +10 -3
  33. package/dist/reference/tables-validate.d.ts +1 -1
  34. package/dist/reference/types.d.ts +1 -1
  35. package/docs/VERSIONING.md +2 -2
  36. package/docs/authoring/README.md +12 -2
  37. package/docs/authoring/agents.md +7 -3
  38. package/docs/authoring/migrating.md +286 -0
  39. package/docs/authoring/notation.md +139 -7
  40. package/docs/authoring/prose.md +64 -0
  41. package/package.json +1 -1
  42. package/src/compiler/astro.ts +89 -0
  43. package/src/compiler/cli.ts +144 -12
  44. package/src/compiler/exports/standalone-html.ts +15 -0
  45. package/src/compiler/logging.ts +248 -0
  46. package/src/compiler/manifest.ts +7 -3
  47. package/src/components/memo/Memo.astro +2 -5
  48. package/src/components/memo/ShortNote.astro +2 -5
  49. package/src/components/notation/NotationFormula.astro +199 -10
  50. package/src/components/notation/NotationGlossary.astro +89 -58
  51. package/src/components/notation/NotationLayer.astro +83 -81
  52. package/src/components/scroll-animation.ts +160 -0
  53. package/src/components/starlight/AnchorScrollEnhancer.astro +102 -0
  54. package/src/components/starlight/HeadingAnchorEnhancer.astro +2 -4
  55. package/src/components/starlight/KeyedFloatEnhancer.astro +17 -16
  56. package/src/components/starlight/LessonFooter.astro +2 -0
  57. package/src/reference/base-abbreviations.mjs +8 -2
  58. package/src/reference/consistency.ts +10 -66
  59. package/src/reference/delimiter-size.ts +301 -0
  60. package/src/reference/diagrams-validate.ts +1 -1
  61. package/src/reference/equations-validate.ts +3 -3
  62. package/src/reference/figures-validate.ts +1 -1
  63. package/src/reference/gate-math.ts +41 -6
  64. package/src/reference/keyed-float-validate.ts +3 -3
  65. package/src/reference/references.ts +2 -1
  66. package/src/reference/registry.ts +1 -1
  67. package/src/reference/rehype-base-links.mjs +40 -0
  68. package/src/reference/remark-abbreviations.mjs +10 -12
  69. package/src/reference/remark-citation.mjs +0 -0
  70. package/src/reference/render-lab-math.ts +24 -1
  71. package/src/reference/tables-validate.ts +1 -1
  72. package/src/reference/types.ts +1 -1
  73. package/src/styles/global.css +119 -14
@@ -33,6 +33,10 @@ export interface Props {
33
33
  glossLabel?: string;
34
34
  /** The owning entry's display label, used to name the scrollable region. */
35
35
  entryLabel?: string;
36
+ /** `G.<n>` — the glossary's own equation numbering. Omitted renders no number. */
37
+ number?: string;
38
+ /** Same-page anchor the number links to; ignored unless `number` is set. */
39
+ numberHref?: string;
36
40
  }
37
41
 
38
42
  const {
@@ -41,6 +45,8 @@ const {
41
45
  glosses = [],
42
46
  glossLabel = 'Symbols',
43
47
  entryLabel,
48
+ number,
49
+ numberHref,
44
50
  } = Astro.props;
45
51
  const { nodes } = renderNotationFormula(formula, scope);
46
52
  registerPageNotationDependencies(
@@ -48,20 +54,49 @@ registerPageNotationDependencies(
48
54
  notationKeysInMathNodes(nodes),
49
55
  `NotationFormula formula "${formula}"`,
50
56
  );
57
+ const latex = stripNotationMarkers(formula);
58
+ const numbered = Boolean(number && numberHref);
51
59
  ---
52
60
 
53
61
  <div class="notation-formula-block">
54
62
  <div
55
- class="notation-formula-math"
63
+ class:list={['notation-formula-math', numbered && 'notation-formula-math--numbered']}
56
64
  data-notation-formula
57
- data-latex={stripNotationMarkers(formula)}
65
+ data-latex={latex}
58
66
  role="group"
59
67
  aria-label={entryLabel ? `${entryLabel} formula` : 'Formula'}
60
- tabindex="0"
61
68
  >
62
- <span class="notation-math">
63
- {nodes.map((node) => <SafeMathNode node={node} />)}
64
- </span>
69
+ <div class="notation-formula-scroll" tabindex="0">
70
+ <span class="notation-math">
71
+ {nodes.map((node) => <SafeMathNode node={node} />)}
72
+ </span>
73
+ </div>
74
+ {numbered && (
75
+ <div class="notation-formula-controls">
76
+ <a
77
+ class="notation-formula-number"
78
+ href={numberHref}
79
+ aria-label={`Equation ${number}`}
80
+ title="Anchor to this equation"
81
+ >
82
+ ({number})
83
+ </a>
84
+ <button
85
+ class="notation-formula-copy"
86
+ type="button"
87
+ data-notation-formula-copy
88
+ data-latex={latex}
89
+ aria-label={`Copy LaTeX for Equation ${number}`}
90
+ title="Copy LaTeX"
91
+ >
92
+ <svg viewBox="0 0 24 24" aria-hidden="true">
93
+ <rect x="9" y="9" width="10" height="10" rx="1"></rect>
94
+ <path d="M6 15H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v1"
95
+ ></path>
96
+ </svg>
97
+ </button>
98
+ </div>
99
+ )}
65
100
  </div>
66
101
 
67
102
  {
@@ -95,25 +130,179 @@ registerPageNotationDependencies(
95
130
  gap: 0.5rem;
96
131
  }
97
132
 
133
+ /* The outer box is deliberately never a scroll clip itself — it hosts the
134
+ absolutely-positioned number/copy controls and their "Copied!" flash,
135
+ which a clipping ancestor would cut off. Mirrors `.keyed-equation` in
136
+ `global.css`, which splits the same way (its own overflow lives on the
137
+ nested `.katex-display`, not on `.keyed-equation` itself). Its cursor,
138
+ hover highlight, and "Copied!" flash are the generic
139
+ `[data-notation-formula]` rules in `global.css` — shared with every
140
+ other producer of that attribute, not reimplemented here. */
141
+ .notation-formula-math {
142
+ color: var(--sl-color-text);
143
+ }
144
+
145
+ .notation-formula-math--numbered {
146
+ padding-inline-end: 5.25rem;
147
+ }
148
+
98
149
  /* A wide equation scrolls inside its own box, which makes the box a
99
150
  scrollable region — and a scrollable region a keyboard user cannot reach
100
151
  is unreachable content, not just an awkward one. `tabindex` on the
101
- container is the fix; this is its focus ring. */
102
- .notation-formula-math {
152
+ scrolling element itself (not an ancestor) is the fix, same convention
153
+ as `.table-overflow-scroll` in `global.css`; this is its focus ring. */
154
+ .notation-formula-scroll {
103
155
  overflow-x: auto;
104
156
  /* `overflow-x: auto` alone makes the block axis compute to `auto` too
105
157
  (CSS Overflow §3) — equations must never scroll vertically on their
106
158
  own, only sideways. */
107
159
  overflow-y: hidden;
108
160
  padding-block: 0.15rem;
109
- color: var(--sl-color-text);
110
161
  }
111
162
 
112
- .notation-formula-math:focus-visible {
163
+ .notation-formula-scroll:focus-visible {
113
164
  outline: 3px solid var(--sl-color-accent);
114
165
  outline-offset: 2px;
115
166
  }
116
167
 
168
+ /* Numbered controls — a glossary entry's own equation number and copy
169
+ button, styled to match `.keyed-equation__controls`/`__number`/`__copy`
170
+ in `global.css` exactly (same values), so a reader sees one consistent
171
+ "numbered equation" affordance across lessons and the glossary. */
172
+ .notation-formula-controls {
173
+ position: absolute;
174
+ inset-inline-end: 0.25rem;
175
+ inset-block-start: 50%;
176
+ transform: translateY(-50%);
177
+ display: inline-flex;
178
+ align-items: center;
179
+ gap: 0.4rem;
180
+ /* Starlight's generic prose "sibling spacing" rule adds a margin-top
181
+ to any block-level element that follows another (here, following
182
+ `.notation-formula-scroll`) — harmless for flow content, but it
183
+ shifts an absolutely-positioned box's `top: 50%` off-center, since
184
+ margin still applies to an out-of-flow element's own offset. */
185
+ margin: 0;
186
+ }
187
+
188
+ .notation-formula-number {
189
+ font-variant-numeric: tabular-nums;
190
+ font-size: var(--sl-text-sm);
191
+ color: var(--sl-color-text-accent);
192
+ text-decoration: none;
193
+ /* Without this it would inherit the container's `cursor: copy` above
194
+ instead of the link's usual pointer, since an inherited value beats
195
+ the browser's own "auto → hand cursor for a link" special case. */
196
+ cursor: pointer;
197
+ opacity: 0.45;
198
+ transition: opacity 120ms ease-out;
199
+ }
200
+
201
+ .notation-formula-copy {
202
+ display: inline-flex;
203
+ align-items: center;
204
+ justify-content: center;
205
+ width: 1.5rem;
206
+ height: 1.5rem;
207
+ padding: 0;
208
+ border: 1px solid var(--sl-color-gray-5);
209
+ border-radius: 999px;
210
+ color: var(--sl-color-gray-1);
211
+ background: transparent;
212
+ font: inherit;
213
+ cursor: pointer;
214
+ opacity: 0.45;
215
+ transition:
216
+ opacity 120ms ease-out,
217
+ border-color 120ms ease-out,
218
+ color 120ms ease-out;
219
+ }
220
+
221
+ .notation-formula-copy svg {
222
+ width: 0.85rem;
223
+ height: 0.85rem;
224
+ fill: none;
225
+ stroke: currentColor;
226
+ stroke-width: 1.75;
227
+ stroke-linecap: round;
228
+ stroke-linejoin: round;
229
+ }
230
+
231
+ @media (hover: none) {
232
+ .notation-formula-number,
233
+ .notation-formula-copy {
234
+ opacity: 1;
235
+ }
236
+ }
237
+
238
+ /* Hovering (or focusing) anywhere in the equation reveals the number and
239
+ copy button together — the "highlight" that signals the whole box is
240
+ interactive, not just its own small controls corner. */
241
+ .notation-formula-math:hover .notation-formula-number,
242
+ .notation-formula-math:focus-within .notation-formula-number,
243
+ .notation-formula-number:focus-visible {
244
+ opacity: 1;
245
+ text-decoration: underline;
246
+ }
247
+
248
+ .notation-formula-math:hover .notation-formula-copy,
249
+ .notation-formula-math:focus-within .notation-formula-copy,
250
+ .notation-formula-copy:focus-visible {
251
+ opacity: 1;
252
+ }
253
+
254
+ .notation-formula-copy:hover,
255
+ .notation-formula-copy:focus-visible {
256
+ border-color: var(--sl-color-accent);
257
+ color: var(--sl-color-text-accent);
258
+ }
259
+
260
+ .notation-formula-copy:focus-visible {
261
+ outline: 2px solid var(--sl-color-accent);
262
+ outline-offset: 2px;
263
+ }
264
+
265
+ /* `data-copied` is set briefly by the click handler in
266
+ `NotationLayer.astro` — same "flash a tooltip, then remove the
267
+ attribute" pattern as `.keyed-equation__copy[data-copied]`. */
268
+ .notation-formula-copy[data-copied] {
269
+ opacity: 1;
270
+ }
271
+
272
+ .notation-formula-copy[data-copied]::after {
273
+ content: 'Copied!';
274
+ position: absolute;
275
+ inset-block-end: calc(100% + 0.4rem);
276
+ inset-inline-end: 0;
277
+ padding: 0.15rem 0.45rem;
278
+ border-radius: 0.35rem;
279
+ background: var(--sl-color-accent-low);
280
+ color: var(--sl-color-text-accent);
281
+ font-size: var(--sl-text-xs);
282
+ white-space: nowrap;
283
+ pointer-events: none;
284
+ }
285
+
286
+ /* The non-numbered case (a formula inside a hover popup, the "Notation
287
+ used on this page" list, a `Diagram`, or a lab's interactive equation)
288
+ has no controls to flash against, so the box itself flashes instead —
289
+ the generic `[data-notation-formula][data-copied]::after` rule in
290
+ `global.css`, not reimplemented here. */
291
+
292
+ @media (prefers-reduced-motion: reduce) {
293
+ .notation-formula-number,
294
+ .notation-formula-copy {
295
+ transition: none;
296
+ }
297
+ }
298
+
299
+ @media (forced-colors: active) {
300
+ .notation-formula-number,
301
+ .notation-formula-copy {
302
+ opacity: 1;
303
+ }
304
+ }
305
+
117
306
  .notation-gloss-list {
118
307
  display: flex;
119
308
  flex-wrap: wrap;
@@ -19,6 +19,15 @@ const [notationEntries, lessonEntries, manifest] = await Promise.all([
19
19
  getCollection('docs', ({ id }) => isLessonSlug(id)),
20
20
  loadManifest(),
21
21
  ]);
22
+ // Starlight's `link-alt` icon — the same glyph its own heading anchors use
23
+ // (`rehype-heading-anchors.mjs`). Reimplemented here rather than reused via
24
+ // `.sl-anchor-link`/`.sl-heading-wrapper` because that CSS assumes a single
25
+ // inline prose heading, not this entry's three-column grid header (symbol,
26
+ // name, editorial status) — the glyph carries over, the layout doesn't, so
27
+ // this file supplies its own reveal styling (below).
28
+ const HEADING_LINK_ICON_PATH =
29
+ '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';
30
+
22
31
  const labelOf = (data: { key: string; label?: string }) =>
23
32
  resolveLabel(data.key, data.label);
24
33
  const notationLabelByKey = new Map(
@@ -83,6 +92,18 @@ const renderedEntries = await Promise.all(
83
92
  })),
84
93
  );
85
94
 
95
+ // Every entry with a rigorous `formula` gets its own equation number, `G.<n>`
96
+ // in page order — the glossary's own numbering, distinct from a lesson's
97
+ // chapter-qualified `(2.1)` (`equations-validate.ts`), since a glossary
98
+ // formula has no lesson/part to qualify it against.
99
+ let equationCounter = 0;
100
+ const equationNumberByKey = new Map<string, string>();
101
+ for (const { entry } of renderedEntries) {
102
+ if (!entry.data.formula) continue;
103
+ equationCounter += 1;
104
+ equationNumberByKey.set(entry.data.key, `G.${equationCounter}`);
105
+ }
106
+
86
107
  // Drives the hover explanation for `[[key]]` links inside the definition
87
108
  // bodies. Every entry is already shown in full on this page, so the layer
88
109
  // renders in panel-only mode: no visible list, just the shared panel.
@@ -138,17 +159,26 @@ const panelDefinitions: NotationLayerDefinition[] = notationEntries.map(
138
159
  <NotationMath class="glossary-symbol" latex={entry.data.latex} />
139
160
  </span>
140
161
  <div>
141
- <h2>{labelOf(entry.data)}</h2>
162
+ <h2>
163
+ <a
164
+ class="glossary-term-link"
165
+ href={`#notation-${entry.data.key}`}
166
+ >
167
+ {labelOf(entry.data)}
168
+ </a>
169
+ <a
170
+ class="glossary-anchor"
171
+ href={`#notation-${entry.data.key}`}
172
+ aria-label={`Link to “${labelOf(entry.data)}”`}
173
+ >
174
+ <span class="glossary-anchor-icon" aria-hidden="true">
175
+ <svg viewBox="0 0 24 24" fill="currentColor">
176
+ <path d={HEADING_LINK_ICON_PATH}></path>
177
+ </svg>
178
+ </span>
179
+ </a>
180
+ </h2>
142
181
  <code>{entry.data.key}</code>
143
- <a
144
- class="glossary-anchor"
145
- href={`#notation-${entry.data.key}`}
146
- data-glossary-anchor
147
- aria-label={`Copy link to “${labelOf(entry.data)}”`}
148
- title="Copy link to this entry"
149
- >
150
- <span aria-hidden="true">#</span>
151
- </a>
152
182
  </div>
153
183
  <span class="glossary-status">{entry.data.editorialStatus}</span>
154
184
  </header>
@@ -193,6 +223,8 @@ const panelDefinitions: NotationLayerDefinition[] = notationEntries.map(
193
223
  scope={formulaScopeByKey.get(entry.data.key) ?? []}
194
224
  glosses={glossesByKey.get(entry.data.key)}
195
225
  entryLabel={labelOf(entry.data)}
226
+ number={equationNumberByKey.get(entry.data.key)}
227
+ numberHref={`#notation-${entry.data.key}`}
196
228
  />
197
229
  </div>
198
230
  )}
@@ -264,34 +296,6 @@ const panelDefinitions: NotationLayerDefinition[] = notationEntries.map(
264
296
  definitions={panelDefinitions}
265
297
  />
266
298
 
267
- <script>
268
- const RESET_MS = 1200;
269
-
270
- for (const anchor of document.querySelectorAll<HTMLAnchorElement>(
271
- '[data-glossary-anchor]',
272
- )) {
273
- anchor.addEventListener('click', () => {
274
- const href = anchor.getAttribute('href');
275
- if (!href || !navigator.clipboard) return;
276
-
277
- // Keep the native hash navigation (scroll + history) and copy the
278
- // absolute URL alongside it.
279
- const url = new URL(href, window.location.href).href;
280
- navigator.clipboard.writeText(url).then(
281
- () => {
282
- anchor.dataset.copied = 'true';
283
- window.setTimeout(() => {
284
- delete anchor.dataset.copied;
285
- }, RESET_MS);
286
- },
287
- () => {
288
- /* Clipboard blocked: the plain anchor link still works. */
289
- },
290
- );
291
- });
292
- }
293
- </script>
294
-
295
299
  <style>
296
300
  .glossary-index ul {
297
301
  display: grid;
@@ -326,6 +330,17 @@ const panelDefinitions: NotationLayerDefinition[] = notationEntries.map(
326
330
  font-size: var(--sl-text-xl);
327
331
  }
328
332
 
333
+ .glossary-term-link {
334
+ color: inherit;
335
+ text-decoration: none;
336
+ }
337
+
338
+ .glossary-term-link:hover,
339
+ .glossary-term-link:focus-visible {
340
+ color: var(--sl-color-text-accent);
341
+ text-decoration: underline;
342
+ }
343
+
329
344
  .glossary-symbol {
330
345
  color: var(--sl-color-accent-high);
331
346
  font-size: 1.2rem;
@@ -353,14 +368,22 @@ const panelDefinitions: NotationLayerDefinition[] = notationEntries.map(
353
368
  }
354
369
  }
355
370
 
371
+ /* Mirrors the hover/focus-reveal treatment of a content heading's own
372
+ `.sl-anchor-link` (`rehype-heading-anchors.mjs`), reimplemented here
373
+ rather than reused because that class's CSS assumes a single inline
374
+ prose heading, not this entry's grid-based header. */
356
375
  .glossary-anchor {
357
- position: relative;
358
- margin-inline-start: 0.5rem;
359
- padding-inline: 0.2rem;
376
+ display: inline-flex;
377
+ align-items: center;
378
+ margin-inline-start: 0.4rem;
360
379
  color: var(--sl-color-gray-3);
361
- font-family: monospace;
362
- text-decoration: none;
363
- vertical-align: baseline;
380
+ vertical-align: middle;
381
+ }
382
+
383
+ .glossary-anchor-icon svg {
384
+ width: 0.85em;
385
+ height: 0.85em;
386
+ display: block;
364
387
  }
365
388
 
366
389
  .glossary-anchor:hover,
@@ -368,20 +391,28 @@ const panelDefinitions: NotationLayerDefinition[] = notationEntries.map(
368
391
  color: var(--sl-color-text-accent);
369
392
  }
370
393
 
371
- .glossary-anchor[data-copied]::after {
372
- content: 'Link copied';
373
- position: absolute;
374
- inset-block-end: calc(100% + 0.35rem);
375
- inset-inline-start: 50%;
376
- translate: -50% 0;
377
- padding: 0.15rem 0.45rem;
378
- border-radius: 0.35rem;
379
- background: var(--sl-color-accent-low);
380
- color: var(--sl-color-text-accent);
381
- font-family: var(--sl-font, sans-serif);
382
- font-size: var(--sl-text-xs);
383
- white-space: nowrap;
384
- pointer-events: none;
394
+ @media (hover: hover) {
395
+ .glossary-anchor {
396
+ opacity: 0;
397
+ }
398
+
399
+ h2:hover .glossary-anchor,
400
+ h2:focus-within .glossary-anchor,
401
+ .glossary-anchor:focus-visible {
402
+ opacity: 1;
403
+ }
404
+ }
405
+
406
+ @media (prefers-reduced-motion: no-preference) {
407
+ .glossary-anchor {
408
+ transition: opacity 120ms ease-out, color 120ms ease-out;
409
+ }
410
+ }
411
+
412
+ @media (forced-colors: active) {
413
+ .glossary-anchor {
414
+ opacity: 1;
415
+ }
385
416
  }
386
417
 
387
418
  .glossary-status {
@@ -263,14 +263,6 @@ for (const gloss of glossItems) {
263
263
  </header>
264
264
  <p data-slot-summary hidden />
265
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>
274
266
  <p class="notation-units" data-slot-units hidden />
275
267
  <a data-slot-link hidden>
276
268
  Open the full definition
@@ -405,18 +397,7 @@ for (const gloss of glossItems) {
405
397
  const units = fragment.querySelector<HTMLElement>('[data-slot-units]');
406
398
  const link =
407
399
  fragment.querySelector<HTMLAnchorElement>('[data-slot-link]');
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
- ) {
400
+ if (!el || !symbolHost || !title || !summary || !formulaHost || !units) {
420
401
  return null;
421
402
  }
422
403
 
@@ -443,34 +424,6 @@ for (const gloss of glossItems) {
443
424
  );
444
425
  formulaHost.hidden = sourceFormula === null;
445
426
 
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
-
474
427
  setOptionalText(
475
428
  units,
476
429
  entry
@@ -547,6 +500,84 @@ for (const gloss of glossItems) {
547
500
  );
548
501
  if (details) setupNotationDisclosure(details);
549
502
  }
503
+
504
+ // "Copy LaTeX" for a notation formula (a glossary entry's own numbered
505
+ // formula, or one named inline via `\explain{}` inside a popup/`Diagram`),
506
+ // two ways: its own button (only a numbered formula has one —
507
+ // `data-notation-formula-copy`, `NotationFormula.astro`), or a click
508
+ // anywhere else in the box that isn't the number link or an explained
509
+ // symbol — the same region `.notation-formula-math:hover` highlights
510
+ // (`NotationFormula.astro`), which includes the reserved gutter between
511
+ // the equation and its number, not just the rendered `.katex-html`. This
512
+ // listener lives here, not in `NotationFormula.astro` or
513
+ // `KeyedFloatEnhancer.astro`, because this component loads on every page —
514
+ // it's the Starlight `Footer` override — while `KeyedFloatEnhancer` is
515
+ // lesson-only and a formula can appear inside a hover popup mounted
516
+ // outside `.sl-markdown-content`.
517
+ document.addEventListener('click', (event) => {
518
+ const origin = event.target instanceof Element ? event.target : null;
519
+ if (!origin) return;
520
+
521
+ const ownButton = origin.closest<HTMLButtonElement>(
522
+ '[data-notation-formula-copy]',
523
+ );
524
+ const container = ownButton
525
+ ? null
526
+ : origin.closest<HTMLElement>('[data-notation-formula]');
527
+ if (!ownButton) {
528
+ if (origin.closest('[data-notation-key], .notation-formula-number')) {
529
+ return;
530
+ }
531
+ if (!container) return;
532
+ }
533
+
534
+ const feedbackTarget =
535
+ ownButton ??
536
+ container?.querySelector<HTMLElement>('[data-notation-formula-copy]') ??
537
+ container;
538
+ const latex =
539
+ ownButton?.dataset.latex ?? feedbackTarget?.dataset.latex ?? '';
540
+ if (!feedbackTarget || latex.length === 0 || !navigator.clipboard) return;
541
+
542
+ navigator.clipboard.writeText(latex).then(
543
+ () => {
544
+ feedbackTarget.dataset.copied = 'true';
545
+ window.setTimeout(() => {
546
+ delete feedbackTarget.dataset.copied;
547
+ }, 1200);
548
+ },
549
+ () => {
550
+ /* Clipboard blocked: nothing more we can do here. */
551
+ },
552
+ );
553
+ });
554
+
555
+ // A tooltip for the click gesture above, and only for the part of the
556
+ // equation it actually applies to — an explained symbol has its own hover
557
+ // card (created above) and needs no tooltip layered on top of it, and the
558
+ // copy button already carries a static `title`. `mouseover`/`mouseout` (not
559
+ // `mouseenter`/`mouseleave`) because they bubble, which this document-level
560
+ // delegation needs.
561
+ document.addEventListener('mouseover', (event) => {
562
+ const origin = event.target instanceof Element ? event.target : null;
563
+ if (!origin || origin.getAttribute('title') === 'Copy LaTeX') return;
564
+ if (
565
+ origin.closest(
566
+ '[data-notation-key], [data-notation-formula-copy], .notation-formula-number',
567
+ )
568
+ ) {
569
+ return;
570
+ }
571
+ if (!origin.closest('[data-notation-formula]')) return;
572
+ origin.setAttribute('title', 'Copy LaTeX');
573
+ });
574
+
575
+ document.addEventListener('mouseout', (event) => {
576
+ const origin = event.target instanceof Element ? event.target : null;
577
+ if (origin?.getAttribute('title') === 'Copy LaTeX') {
578
+ origin.removeAttribute('title');
579
+ }
580
+ });
550
581
  </script>
551
582
 
552
583
  <style>
@@ -722,6 +753,10 @@ for (const gloss of glossItems) {
722
753
  font-size: var(--sl-text-sm);
723
754
  }
724
755
 
756
+ /* Cursor, hover highlight, and "Copied!" flash for this plain-span
757
+ formula fallback (a definition with no `formulaScope`) come from the
758
+ generic `[data-notation-formula]` rules in `global.css` — shared with
759
+ every other producer of that attribute, not reimplemented here. */
725
760
  .notation-formula {
726
761
  overflow-x: auto;
727
762
  /* `overflow-x: auto` alone makes the block axis compute to `auto` too
@@ -823,39 +858,6 @@ for (const gloss of glossItems) {
823
858
  line-height: 1.5;
824
859
  }
825
860
 
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
-
859
861
  .notation-panel-body [data-slot-formula] {
860
862
  overflow-x: auto;
861
863
  /* See `.notation-formula` above: pairs with `overflow-x` to stop the