explico 0.5.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 (88) hide show
  1. package/CHANGELOG.md +295 -7
  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/course-config.d.ts +31 -1
  8. package/dist/compiler/course-config.js +7 -0
  9. package/dist/compiler/exports/standalone-html.js +13 -0
  10. package/dist/compiler/logging.d.ts +109 -0
  11. package/dist/compiler/logging.js +107 -0
  12. package/dist/compiler/manifest.d.ts +16 -4
  13. package/dist/compiler/manifest.js +7 -3
  14. package/dist/reference/base-abbreviations.mjs +8 -2
  15. package/dist/reference/consistency.d.ts +8 -10
  16. package/dist/reference/consistency.js +3 -47
  17. package/dist/reference/delimiter-size.d.ts +39 -0
  18. package/dist/reference/delimiter-size.js +177 -0
  19. package/dist/reference/diagrams-validate.d.ts +1 -1
  20. package/dist/reference/equations-validate.d.ts +2 -2
  21. package/dist/reference/equations-validate.js +1 -1
  22. package/dist/reference/figures-validate.d.ts +1 -1
  23. package/dist/reference/gate-heading-length.d.ts +12 -0
  24. package/dist/reference/gate-heading-length.js +79 -0
  25. package/dist/reference/gate-math.d.ts +8 -2
  26. package/dist/reference/gate-math.js +30 -7
  27. package/dist/reference/keyed-float-validate.d.ts +2 -2
  28. package/dist/reference/keyed-float-validate.js +1 -1
  29. package/dist/reference/math-glyphs.d.mts +12 -0
  30. package/dist/reference/math-glyphs.mjs +183 -42
  31. package/dist/reference/references.js +2 -1
  32. package/dist/reference/registry.js +1 -1
  33. package/dist/reference/rehype-base-links.d.mts +23 -0
  34. package/dist/reference/rehype-base-links.mjs +40 -0
  35. package/dist/reference/remark-abbreviations.mjs +10 -12
  36. package/dist/reference/remark-citation.d.mts +5 -1
  37. package/dist/reference/remark-citation.mjs +0 -0
  38. package/dist/reference/render-lab-math.d.ts +18 -1
  39. package/dist/reference/render-lab-math.js +10 -3
  40. package/dist/reference/tables-validate.d.ts +1 -1
  41. package/dist/reference/types.d.ts +1 -1
  42. package/docs/VERSIONING.md +24 -4
  43. package/docs/authoring/README.md +14 -2
  44. package/docs/authoring/agents.md +7 -3
  45. package/docs/authoring/components.md +4 -1
  46. package/docs/authoring/migrating.md +477 -8
  47. package/docs/authoring/notation.md +194 -7
  48. package/docs/authoring/prose.md +64 -0
  49. package/docs/authoring/structure-rules.md +69 -0
  50. package/docs/authoring/visualizations.md +2 -2
  51. package/package.json +1 -1
  52. package/src/compiler/astro.ts +89 -0
  53. package/src/compiler/cli.ts +144 -12
  54. package/src/compiler/course-config.ts +40 -0
  55. package/src/compiler/exports/standalone-html.ts +15 -0
  56. package/src/compiler/logging.ts +248 -0
  57. package/src/compiler/manifest.ts +20 -3
  58. package/src/components/diagram/Diagram.astro +0 -55
  59. package/src/components/memo/Memo.astro +2 -5
  60. package/src/components/memo/ShortNote.astro +2 -5
  61. package/src/components/notation/NotationFormula.astro +199 -10
  62. package/src/components/notation/NotationGlossary.astro +89 -58
  63. package/src/components/notation/NotationLayer.astro +238 -156
  64. package/src/components/scroll-animation.ts +160 -0
  65. package/src/components/starlight/AnchorScrollEnhancer.astro +102 -0
  66. package/src/components/starlight/HeadingAnchorEnhancer.astro +2 -4
  67. package/src/components/starlight/KeyedFloatEnhancer.astro +17 -16
  68. package/src/components/starlight/LessonFooter.astro +5 -1
  69. package/src/content-config.ts +16 -1
  70. package/src/reference/base-abbreviations.mjs +8 -2
  71. package/src/reference/consistency.ts +10 -66
  72. package/src/reference/delimiter-size.ts +301 -0
  73. package/src/reference/diagrams-validate.ts +1 -1
  74. package/src/reference/equations-validate.ts +3 -3
  75. package/src/reference/figures-validate.ts +1 -1
  76. package/src/reference/gate-heading-length.ts +131 -0
  77. package/src/reference/gate-math.ts +48 -8
  78. package/src/reference/keyed-float-validate.ts +3 -3
  79. package/src/reference/math-glyphs.mjs +183 -42
  80. package/src/reference/references.ts +2 -1
  81. package/src/reference/registry.ts +1 -1
  82. package/src/reference/rehype-base-links.mjs +40 -0
  83. package/src/reference/remark-abbreviations.mjs +10 -12
  84. package/src/reference/remark-citation.mjs +0 -0
  85. package/src/reference/render-lab-math.ts +24 -1
  86. package/src/reference/tables-validate.ts +1 -1
  87. package/src/reference/types.ts +1 -1
  88. package/src/styles/global.css +120 -15
@@ -70,12 +70,6 @@ export interface Props {
70
70
  * Set `false` only when horizontal overflow is deliberate. Default: `800`.
71
71
  */
72
72
  warnAboveWidth?: number | false;
73
- /**
74
- * Prose text alternative for the SVG. When omitted, the fallback is
75
- * generated from the node labels and the edge chain — readable, but a
76
- * hand-written sentence usually reads better for a screen-reader user.
77
- */
78
- description?: string;
79
73
  }
80
74
 
81
75
  const {
@@ -85,7 +79,6 @@ const {
85
79
  edges,
86
80
  scope = [],
87
81
  direction = 'TB',
88
- description,
89
82
  number,
90
83
  anchorId,
91
84
  defaultOpen = true,
@@ -190,12 +183,6 @@ if (warnAboveWidth !== false && layout.width > warnAboveWidth) {
190
183
  );
191
184
  }
192
185
 
193
- /** A node's human label for the text fallback — never the internal id. */
194
- function fallbackLabel(nodeId: string): string {
195
- const node = nodeById.get(nodeId);
196
- return node?.text ?? node?.formula ?? nodeId;
197
- }
198
-
199
186
  function pathFrom(points: readonly { x: number; y: number }[]): string {
200
187
  if (points.length === 0) return '';
201
188
  const [first, ...rest] = points;
@@ -318,33 +305,6 @@ function pathFrom(points: readonly { x: number; y: number }[]): string {
318
305
  </div>
319
306
  </div>
320
307
  </div>
321
-
322
- <details class="diagram-fallback">
323
- <summary>Diagram description</summary>
324
- {
325
- description ? (
326
- <p>{description}</p>
327
- ) : (
328
- <Fragment>
329
- <ol>
330
- {rendered.map((node) => (
331
- <li>{fallbackLabel(node.id)}</li>
332
- ))}
333
- </ol>
334
- {edges.length > 0 && (
335
- <p class="diagram-fallback-flow">
336
- {edges.map((edge, index) => (
337
- <Fragment>
338
- {index > 0 && '; '}
339
- {fallbackLabel(edge.from)} → {fallbackLabel(edge.to)}
340
- </Fragment>
341
- ))}
342
- </p>
343
- )}
344
- </Fragment>
345
- )
346
- }
347
- </details>
348
308
  </div>
349
309
  </details>
350
310
 
@@ -635,21 +595,6 @@ function pathFrom(points: readonly { x: number; y: number }[]): string {
635
595
  fill: var(--sl-color-gray-4);
636
596
  }
637
597
 
638
- .diagram-fallback {
639
- margin-block-start: 0.5rem;
640
- color: var(--sl-color-gray-3);
641
- font-size: var(--sl-text-sm);
642
- }
643
-
644
- .diagram-fallback ol,
645
- .diagram-fallback p {
646
- margin: 0.35rem 0 0;
647
- }
648
-
649
- .diagram-fallback ol {
650
- padding-inline-start: 1.25rem;
651
- }
652
-
653
598
  @media print {
654
599
  .diagram-toolbar,
655
600
  .diagram__title-anchor,
@@ -239,8 +239,8 @@ if (!number || !anchorId) {
239
239
  <script>
240
240
  import { attachHoverPeek, closeHoverPeek } from '../disclosure/hover-preview';
241
241
  import { wirePersistedDetails } from '../disclosure/persisted-disclosure';
242
+ import { animatedScrollIntoView } from '../scroll-animation';
242
243
 
243
- const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
244
244
  let memoPreviewSerial = 0;
245
245
 
246
246
  function cloneMemoBody(body: HTMLElement): HTMLElement {
@@ -442,10 +442,7 @@ if (!number || !anchorId) {
442
442
  history.pushState(null, '', `#${details.id}`);
443
443
  }
444
444
  requestAnimationFrame(() => {
445
- details.scrollIntoView({
446
- behavior: reduceMotion.matches ? 'auto' : 'smooth',
447
- block: 'start',
448
- });
445
+ animatedScrollIntoView(details, { block: 'start' });
449
446
  });
450
447
  });
451
448
  }
@@ -116,7 +116,7 @@ if (!anchorId) {
116
116
  </style>
117
117
 
118
118
  <script>
119
- const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
119
+ import { animatedScrollIntoView } from '../scroll-animation';
120
120
 
121
121
  document
122
122
  .querySelectorAll<HTMLElement>('.short-note:not([data-short-note-wired])')
@@ -185,10 +185,7 @@ if (!anchorId) {
185
185
  history.pushState(null, '', `#${note.id}`);
186
186
  }
187
187
  requestAnimationFrame(() => {
188
- note.scrollIntoView({
189
- behavior: reduceMotion.matches ? 'auto' : 'smooth',
190
- block: 'nearest',
191
- });
188
+ animatedScrollIntoView(note, { block: 'nearest' });
192
189
  });
193
190
  };
194
191
 
@@ -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 {