explico 0.6.0 → 0.8.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 (95) hide show
  1. package/CHANGELOG.md +350 -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 +30 -4
  6. package/dist/compiler/cli.js +79 -10
  7. package/dist/compiler/collections.js +16 -3
  8. package/dist/compiler/exports/standalone-html.js +13 -0
  9. package/dist/compiler/logging.d.ts +119 -0
  10. package/dist/compiler/logging.js +108 -0
  11. package/dist/compiler/manifest.d.ts +7 -3
  12. package/dist/compiler/manifest.js +1 -1
  13. package/dist/reference/base-abbreviations.mjs +8 -2
  14. package/dist/reference/consistency.d.ts +8 -10
  15. package/dist/reference/consistency.js +3 -47
  16. package/dist/reference/delimiter-size.d.ts +48 -0
  17. package/dist/reference/delimiter-size.js +153 -0
  18. package/dist/reference/diagrams-validate.d.ts +1 -1
  19. package/dist/reference/equations-validate.d.ts +2 -2
  20. package/dist/reference/equations-validate.js +1 -1
  21. package/dist/reference/equations.d.mts +9 -3
  22. package/dist/reference/equations.mjs +7 -1
  23. package/dist/reference/figures-validate.d.ts +1 -1
  24. package/dist/reference/gate-abbreviations.js +2 -2
  25. package/dist/reference/gate-heading-length.js +2 -2
  26. package/dist/reference/gate-math.d.ts +25 -1
  27. package/dist/reference/gate-math.js +50 -9
  28. package/dist/reference/gate-prose-notation.d.ts +12 -1
  29. package/dist/reference/gate-prose-notation.js +5 -1
  30. package/dist/reference/katex-delimiters.d.mts +15 -0
  31. package/dist/reference/katex-delimiters.mjs +125 -0
  32. package/dist/reference/katex-options.mjs +4 -0
  33. package/dist/reference/keyed-float-validate.d.ts +2 -2
  34. package/dist/reference/keyed-float-validate.js +1 -1
  35. package/dist/reference/math-glyphs.mjs +16 -2
  36. package/dist/reference/references.js +2 -1
  37. package/dist/reference/registry.js +1 -1
  38. package/dist/reference/rehype-base-links.d.mts +23 -0
  39. package/dist/reference/rehype-base-links.mjs +40 -0
  40. package/dist/reference/remark-abbreviations.mjs +10 -12
  41. package/dist/reference/remark-citation.mjs +0 -0
  42. package/dist/reference/render-lab-math.d.ts +18 -1
  43. package/dist/reference/render-lab-math.js +11 -4
  44. package/dist/reference/tables-validate.d.ts +1 -1
  45. package/dist/reference/types.d.ts +8 -1
  46. package/docs/VERSIONING.md +2 -2
  47. package/docs/authoring/README.md +12 -2
  48. package/docs/authoring/agents.md +7 -3
  49. package/docs/authoring/migrating.md +514 -0
  50. package/docs/authoring/notation.md +157 -7
  51. package/docs/authoring/prose.md +64 -0
  52. package/docs/katex-bracket-scaling.md +191 -0
  53. package/package.json +2 -1
  54. package/src/compiler/astro.ts +89 -0
  55. package/src/compiler/cli.ts +167 -12
  56. package/src/compiler/collections.ts +33 -3
  57. package/src/compiler/exports/standalone-html.ts +15 -0
  58. package/src/compiler/logging.ts +259 -0
  59. package/src/compiler/manifest.ts +7 -3
  60. package/src/components/memo/Memo.astro +2 -5
  61. package/src/components/memo/ShortNote.astro +2 -5
  62. package/src/components/notation/NotationFormula.astro +199 -10
  63. package/src/components/notation/NotationGlossary.astro +100 -58
  64. package/src/components/notation/NotationLayer.astro +83 -81
  65. package/src/components/notation/render-notation-math.ts +111 -10
  66. package/src/components/scroll-animation.ts +160 -0
  67. package/src/components/starlight/AboutPanel.astro +17 -2
  68. package/src/components/starlight/AnchorScrollEnhancer.astro +102 -0
  69. package/src/components/starlight/HeadingAnchorEnhancer.astro +2 -4
  70. package/src/components/starlight/KeyedFloatEnhancer.astro +17 -16
  71. package/src/components/starlight/LessonFooter.astro +2 -0
  72. package/src/reference/base-abbreviations.mjs +8 -2
  73. package/src/reference/consistency.ts +10 -66
  74. package/src/reference/delimiter-size.ts +279 -0
  75. package/src/reference/diagrams-validate.ts +1 -1
  76. package/src/reference/equations-validate.ts +3 -3
  77. package/src/reference/equations.mjs +7 -1
  78. package/src/reference/figures-validate.ts +1 -1
  79. package/src/reference/gate-abbreviations.ts +2 -2
  80. package/src/reference/gate-heading-length.ts +2 -2
  81. package/src/reference/gate-math.ts +91 -8
  82. package/src/reference/gate-prose-notation.ts +15 -1
  83. package/src/reference/katex-delimiters.mjs +125 -0
  84. package/src/reference/katex-options.mjs +4 -0
  85. package/src/reference/keyed-float-validate.ts +3 -3
  86. package/src/reference/math-glyphs.mjs +16 -2
  87. package/src/reference/references.ts +2 -1
  88. package/src/reference/registry.ts +1 -1
  89. package/src/reference/rehype-base-links.mjs +40 -0
  90. package/src/reference/remark-abbreviations.mjs +10 -12
  91. package/src/reference/remark-citation.mjs +0 -0
  92. package/src/reference/render-lab-math.ts +25 -2
  93. package/src/reference/tables-validate.ts +1 -1
  94. package/src/reference/types.ts +8 -1
  95. package/src/styles/global.css +119 -14
@@ -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
@@ -1,5 +1,8 @@
1
1
  import rehypeKatex from 'rehype-katex';
2
- import { createNotationKatexOptions } from '../../reference/katex-options.mjs';
2
+ import {
3
+ createNotationKatexOptions,
4
+ trustNotationMarker,
5
+ } from '../../reference/katex-options.mjs';
3
6
  import { NOTATION_KEY_PATTERN } from '../../reference/remark-notation.mjs';
4
7
  import {
5
8
  GlyphResolutionError,
@@ -81,32 +84,96 @@ const allowedTags = new Set([
81
84
  'line',
82
85
  ]);
83
86
 
87
+ /**
88
+ * Every inert presentation attribute KaTeX's MathML builder can set, sorted.
89
+ *
90
+ * This list is deliberately KaTeX's whole surface rather than the attributes
91
+ * the corpus happens to have hit: anything missing here is a hard build error
92
+ * on valid, ordinary math, and the author has no way to work around it. `\Bigl`
93
+ * (`minsize`/`maxsize`), `\boxed` (`notation`), and `\colorbox`
94
+ * (`voffset`/`mathbackground`) each used to fail that way.
95
+ * `tests/notation/render-notation-math.test.ts` scans KaTeX's own
96
+ * `setAttribute` calls and fails when a release adds a name that is in neither
97
+ * this set nor {@link deniedAttributeNames}, so a KaTeX upgrade cannot quietly
98
+ * reintroduce the gap.
99
+ */
84
100
  const simpleAttributeNames = new Set([
85
101
  'accent',
86
102
  'accentunder',
87
103
  'columnalign',
104
+ 'columnlines',
88
105
  'columnspacing',
106
+ 'depth',
107
+ 'display',
89
108
  'displaystyle',
90
109
  'fence',
91
110
  'height',
111
+ 'largeop',
112
+ 'linebreak',
113
+ 'linethickness',
92
114
  'lspace',
115
+ 'mathbackground',
116
+ 'mathcolor',
117
+ 'mathsize',
93
118
  'mathvariant',
119
+ 'maxsize',
120
+ 'minsize',
121
+ 'notation',
94
122
  'preserveaspectratio',
123
+ 'rowlines',
95
124
  'rowspacing',
96
125
  'rspace',
97
126
  'scriptlevel',
98
127
  'separator',
99
128
  'stretchy',
129
+ 'valign',
100
130
  'viewbox',
131
+ 'voffset',
101
132
  'width',
102
133
  ]);
103
134
 
104
- const safeAttributeValue = /^[ A-Za-z0-9+.,()\-/%]*$/;
135
+ /**
136
+ * The attributes KaTeX can set that must never reach the page, each one a
137
+ * navigation or external-resource target: `href` (`\href`), `src`/`alt`
138
+ * (`\includegraphics`), and `title` (`\htmlTitle`). Every command that
139
+ * produces one already needs KaTeX's `trust` callback and is refused earlier by
140
+ * {@link unsafeExtensionCommand}, so this set is the second line — and the
141
+ * reason the completeness scan above can tell "KaTeX added an attribute nobody
142
+ * classified" apart from "we decided against this one".
143
+ */
144
+ const deniedAttributeNames = new Set(['alt', 'href', 'src', 'title']);
145
+
146
+ // `#` is here for `mathcolor` / `mathbackground` / a `background-color:` style,
147
+ // which KaTeX writes as a hex literal. No attribute reaching this point can
148
+ // carry a URL — `href` and `src` are denied above — so it opens no target.
149
+ const safeAttributeValue = /^[ A-Za-z0-9+.,()\-/%#]*$/;
105
150
  const safeClassName = /^[A-Za-z0-9_-]+$/;
106
- const safeStyle = /^(?:[a-z-]+:[ A-Za-z0-9+.,()\-/%]*;)*$/;
151
+ const safeDeclaration = /^\s*[a-z-]+:[ A-Za-z0-9+.,()\-/%#]*$/;
107
152
  const safePathData = /^[\sA-Za-z0-9+.,\-]*$/;
108
153
  const unsafeExtensionCommand =
109
154
  /\\(?:href|url|includegraphics|htmlClass|htmlData|htmlId|htmlStyle)\b/;
155
+ // `\explain{key}{latex}` and `\slot{name}{latex}`, the two macros that expand
156
+ // to a trusted `\htmlData` marker. Only the first argument is captured.
157
+ const MARKER_MACRO = /\\(explain|slot)\{([^{}]*)\}/g;
158
+
159
+ /**
160
+ * A declaration list KaTeX generated: inert properties only, and no
161
+ * `url(...)`/`expression(...)` to fetch or evaluate anything.
162
+ *
163
+ * Validated per declaration rather than with one regex over the whole string,
164
+ * because KaTeX does not always terminate the last one — `\vec` emits
165
+ * `style="width:0.471em"` with no trailing `;`, which a
166
+ * `(?:declaration;)*`-shaped pattern rejects outright.
167
+ */
168
+ function isSafeStyle(value: string): boolean {
169
+ if (/(?:expression|url)\s*\(/i.test(value)) return false;
170
+ return value
171
+ .split(';')
172
+ .every(
173
+ (declaration) =>
174
+ declaration.trim().length === 0 || safeDeclaration.test(declaration),
175
+ );
176
+ }
110
177
 
111
178
  function attributeValue(value: unknown, propertyName: string): string {
112
179
  if (typeof value === 'boolean') return String(value);
@@ -170,22 +237,27 @@ function safeProperties(
170
237
  continue;
171
238
  }
172
239
 
173
- if (
174
- name === 'style' &&
175
- typeof value === 'string' &&
176
- safeStyle.test(value) &&
177
- !/(?:expression|url)\s*\(/i.test(value)
178
- ) {
240
+ if (name === 'style') {
241
+ if (typeof value !== 'string' || !isSafeStyle(value)) {
242
+ throw new Error('Unsafe KaTeX style value.');
243
+ }
179
244
  properties.style = value;
180
245
  continue;
181
246
  }
182
247
 
183
- if (name === 'd' && typeof value === 'string' && safePathData.test(value)) {
248
+ if (name === 'd') {
249
+ if (typeof value !== 'string' || !safePathData.test(value)) {
250
+ throw new Error('Unsafe KaTeX path data.');
251
+ }
184
252
  properties.d = value;
185
253
  continue;
186
254
  }
187
255
 
188
256
  const normalizedName = name.toLowerCase();
257
+ if (deniedAttributeNames.has(normalizedName)) {
258
+ throw new Error(`Disallowed KaTeX property: ${name}.`);
259
+ }
260
+
189
261
  if (simpleAttributeNames.has(normalizedName)) {
190
262
  const outputName =
191
263
  normalizedName === 'viewbox'
@@ -228,6 +300,34 @@ function sanitizeNode(node: HastNode): SafeMathNode {
228
300
  // this module already imports from `remark-notation.mjs`.
229
301
  export { stripNotationMarkers };
230
302
 
303
+ /**
304
+ * Refuse a marker argument the trust callback would reject, before KaTeX sees
305
+ * it.
306
+ *
307
+ * KaTeX does not treat a denied `trust` as a parse error: it renders the
308
+ * command name in `errorColor` and carries on, so `\explain{Not A Key}{\Omega}`
309
+ * would otherwise reach a reader as red `\htmlData` text with the build still
310
+ * green. `\htmlData` itself is already refused by {@link
311
+ * unsafeExtensionCommand}, so the two marker macros are the only way into that
312
+ * callback and this check covers all of them. It asks
313
+ * {@link trustNotationMarker} rather than re-deriving the key patterns, so the
314
+ * answer here cannot drift from the answer KaTeX gets.
315
+ */
316
+ function assertTrustedMarkers(latex: string): void {
317
+ for (const [, macro, argument] of latex.matchAll(MARKER_MACRO)) {
318
+ const attribute =
319
+ macro === 'explain' ? 'data-notation-key' : 'data-lab-slot';
320
+ if (
321
+ !trustNotationMarker({
322
+ command: '\\htmlData',
323
+ attributes: { [attribute]: argument },
324
+ })
325
+ ) {
326
+ throw new Error(`Notation LaTeX has an invalid \\${macro} key: ${argument}`);
327
+ }
328
+ }
329
+ }
330
+
231
331
  /**
232
332
  * Render repository-authored LaTeX through the same build-time KaTeX adapter
233
333
  * as lesson math, then expose a constrained structured tree to Astro. No HTML
@@ -240,6 +340,7 @@ export function renderNotationMath(latex: string): readonly SafeMathNode[] {
240
340
  if (unsafeExtensionCommand.test(latex)) {
241
341
  throw new Error('Notation LaTeX cannot use a trust-requiring extension.');
242
342
  }
343
+ assertTrustedMarkers(latex);
243
344
 
244
345
  const root: HastRoot = {
245
346
  type: 'root',
@@ -0,0 +1,160 @@
1
+ /**
2
+ * A single, site-wide motion profile for "jump to an in-page anchor":
3
+ * `DURATION_MS`, constant-magnitude acceleration — speeding up through the
4
+ * first half of the trip, slowing down through the second, symmetric about
5
+ * the midpoint (the standard `easeInOutQuad` curve, which is exactly a
6
+ * constant |acceleration| profile since position is piecewise quadratic in
7
+ * time).
8
+ *
9
+ * The browser's own fragment navigation (a plain `<a href="#id">` click, with
10
+ * no `scroll-behavior: smooth` set anywhere in this site's CSS) jumps
11
+ * instantly — and CSS's `scroll-behavior: smooth` can't be tuned to a given
12
+ * duration or easing anyway. Getting an exact, consistent curve everywhere
13
+ * means animating the scroll ourselves. `AnchorScrollEnhancer.astro` (the
14
+ * generic click case, covering every same-page link on every page) and the
15
+ * handful of enhancers with their own reasons to move the viewport
16
+ * (`HeadingAnchorEnhancer.astro`, `KeyedFloatEnhancer.astro`, footnote/memo
17
+ * expansion) all render through this module, so a click that touches two of
18
+ * them at once — e.g. a heading anchor click, animated here *and* re-scrolled
19
+ * by that enhancer's own `hashchange` listener — collapses to a single
20
+ * animation via `activeAnimation` below, rather than two fighting for the
21
+ * scroll position.
22
+ *
23
+ * An earlier version of `AnchorScrollEnhancer.astro` let the browser's own
24
+ * click-triggered jump run, then tried to catch it a frame later and replay
25
+ * it as an animation. The trouble: the HTML spec gives no guarantee about
26
+ * *when*, relative to our own script, that jump actually lands — under load
27
+ * it can take several frames — so a single-frame check could conclude
28
+ * "nothing moved, there's nothing to animate" and let the browser's own
29
+ * un-animated jump land moments later, unopposed (this was the original,
30
+ * confirmed bug reports came in about). Polling for more frames narrows that
31
+ * window but never closes it — there's always some delay past which it gives
32
+ * up. `animatedScrollIntoView` below removes the dependency on that timing
33
+ * entirely: it measures the destination itself, synchronously, before
34
+ * anything has had a chance to move, so there's nothing to race.
35
+ */
36
+
37
+ const DURATION_MS = 200;
38
+ function easeInOutQuad(t: number): number {
39
+ return t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2;
40
+ }
41
+
42
+ type SettleReason = 'complete' | 'interrupted';
43
+
44
+ let activeAnimation: {
45
+ readonly target: Element;
46
+ readonly cancel: (reason: SettleReason) => void;
47
+ } | null = null;
48
+
49
+ function prefersReducedMotion(): boolean {
50
+ return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
51
+ }
52
+
53
+ // Cancel on the reader's own scroll intent — a short ride is unlikely to
54
+ // outlast one, but fighting the reader's wheel/touch/keyboard input, even
55
+ // briefly, is exactly the kind of jank this exists to avoid.
56
+ const CANCEL_EVENTS = ['wheel', 'touchstart', 'keydown'] as const;
57
+
58
+ function runAnimation(
59
+ target: Element,
60
+ startX: number,
61
+ startY: number,
62
+ endX: number,
63
+ endY: number,
64
+ onSettle?: (reason: SettleReason) => void,
65
+ ): void {
66
+ if (activeAnimation?.target === target) return;
67
+ activeAnimation?.cancel('interrupted');
68
+
69
+ if (prefersReducedMotion() || (startX === endX && startY === endY)) {
70
+ window.scrollTo(endX, endY);
71
+ activeAnimation = null;
72
+ onSettle?.('complete');
73
+ return;
74
+ }
75
+
76
+ window.scrollTo(startX, startY);
77
+
78
+ let rafId = 0;
79
+ let cancelled = false;
80
+ const startTime = performance.now();
81
+
82
+ const onCancelEvent = () => cancel('interrupted');
83
+
84
+ const cleanup = () => {
85
+ for (const type of CANCEL_EVENTS) {
86
+ window.removeEventListener(type, onCancelEvent);
87
+ }
88
+ };
89
+
90
+ const finish = (reason: SettleReason) => {
91
+ cleanup();
92
+ if (activeAnimation?.target === target) activeAnimation = null;
93
+ onSettle?.(reason);
94
+ };
95
+
96
+ const cancel = (reason: SettleReason) => {
97
+ if (cancelled) return;
98
+ cancelled = true;
99
+ cancelAnimationFrame(rafId);
100
+ finish(reason);
101
+ };
102
+
103
+ const step = (now: number) => {
104
+ if (cancelled) return;
105
+ const t = Math.min(1, (now - startTime) / DURATION_MS);
106
+ const eased = easeInOutQuad(t);
107
+ window.scrollTo(
108
+ startX + (endX - startX) * eased,
109
+ startY + (endY - startY) * eased,
110
+ );
111
+ if (t < 1) {
112
+ rafId = requestAnimationFrame(step);
113
+ } else {
114
+ cancelled = true;
115
+ finish('complete');
116
+ }
117
+ };
118
+
119
+ for (const type of CANCEL_EVENTS) {
120
+ window.addEventListener(type, onCancelEvent, { passive: true });
121
+ }
122
+
123
+ activeAnimation = { target, cancel };
124
+ rafId = requestAnimationFrame(step);
125
+ }
126
+
127
+ /**
128
+ * Drop-in replacement for `target.scrollIntoView({ behavior: 'smooth', ... })`.
129
+ * Measures the destination itself — instantly, synchronously, before
130
+ * anything has had a chance to move — via `target.scrollIntoView({behavior:
131
+ * 'auto', ...})` (this respects `scroll-margin-*`, sticky headers, etc.
132
+ * exactly as the browser's own fragment navigation would), then jumps back
133
+ * and animates the trip.
134
+ *
135
+ * `onSettle`, when given, fires once: `'complete'` if the animation ran to
136
+ * the destination (or reduced motion meant an instant jump straight there),
137
+ * `'interrupted'` if the reader's own scroll/keyboard input cut it short, or
138
+ * — a rare, intentional no-op — never, if a newer call for the *same*
139
+ * target was already in flight (this one is simply dropped; the one already
140
+ * running owns the eventual settle).
141
+ */
142
+ export function animatedScrollIntoView(
143
+ target: Element,
144
+ options: ScrollIntoViewOptions = { block: 'start' },
145
+ onSettle?: (reason: SettleReason) => void,
146
+ ): void {
147
+ if (prefersReducedMotion()) {
148
+ target.scrollIntoView({ ...options, behavior: 'auto' });
149
+ onSettle?.('complete');
150
+ return;
151
+ }
152
+
153
+ const startX = window.scrollX;
154
+ const startY = window.scrollY;
155
+ target.scrollIntoView({ ...options, behavior: 'auto' });
156
+ const endX = window.scrollX;
157
+ const endY = window.scrollY;
158
+
159
+ runAnimation(target, startX, startY, endX, endY, onSettle);
160
+ }
@@ -12,6 +12,15 @@
12
12
  * It is a plain `<details>` disclosure: the browser gives keyboard toggle and
13
13
  * `aria-expanded` for free; the inline script only adds Escape-to-close and
14
14
  * outside-click-to-close.
15
+ *
16
+ * The panel also *publishes* the identity it displays: the `data-knowledge-*`
17
+ * and `data-engine-version` attributes carry the same values as the rendered
18
+ * text. A build check that wants to confirm the shipped page really carries the
19
+ * configured version would otherwise have to match the panel's prose and its
20
+ * `version · date` join, which pins a course's test to one version string and
21
+ * makes the check fail on every bump; a bare version substring is not even
22
+ * scoped to the panel, so the engine's own version elsewhere on the page can
23
+ * satisfy it. These attributes are exact, scoped, and stable across a bump.
15
24
  */
16
25
  import { loadManifest } from '../../compiler/manifest';
17
26
 
@@ -32,7 +41,13 @@ const knowledgeLine = knowledge.date
32
41
  : knowledge.version;
33
42
  ---
34
43
 
35
- <details class="about" data-about-panel>
44
+ <details
45
+ class="about"
46
+ data-about-panel
47
+ data-knowledge-version={knowledge.version}
48
+ data-knowledge-date={knowledge.date}
49
+ data-engine-version={engine.version}
50
+ >
36
51
  <summary
37
52
  class="about-toggle"
38
53
  aria-label={`About ${material.name}`}
@@ -47,7 +62,7 @@ const knowledgeLine = knowledge.date
47
62
  <dl class="about-facts">
48
63
  <div>
49
64
  <dt>Knowledge version</dt>
50
- <dd>{knowledgeLine}</dd>
65
+ <dd data-knowledge-line>{knowledgeLine}</dd>
51
66
  </div>
52
67
  <div>
53
68
  <dt>Engine</dt>
@@ -0,0 +1,102 @@
1
+ ---
2
+ /**
3
+ * Site-wide "jump to an in-page anchor" animation (Step F3 follow-up). Loads
4
+ * unconditionally, on every page — not just lessons — since it has to catch
5
+ * every same-page `<a href="#…">`: a heading, a keyed equation/table/figure
6
+ * number, a footnote/memo marker, and plain content links with no dedicated
7
+ * enhancer of their own at all (the notation glossary's term/anchor links,
8
+ * its "see also" list, a page's table of contents, …).
9
+ *
10
+ * This calls `preventDefault()` on a qualifying click — which looks like the
11
+ * one thing `HeadingAnchorEnhancer.astro` and `KeyedFloatEnhancer.astro` both
12
+ * carry a comment warning against, since `:target`-driven CSS (the
13
+ * heading/equation "flash") and the sticky-header `scroll-margin` offset only
14
+ * fire on a genuine same-document fragment navigation — never on a
15
+ * `history.pushState`. That warning is about *silently* replacing the
16
+ * navigation with `pushState` and never actually re-triggering it. This does
17
+ * something safer: it measures the destination and animates there itself
18
+ * (`animatedScrollIntoView`, `scroll-animation.ts` — the same call
19
+ * `HeadingAnchorEnhancer`/`KeyedFloatEnhancer` use for their own supplementary
20
+ * scrolls), and only once the viewport is actually *at* that destination does
21
+ * it assign `location.hash` — a real fragment navigation, not `pushState`,
22
+ * so `:target` matches and `hashchange` fires exactly as a plain click's
23
+ * default action would have. Because the assignment happens after arrival,
24
+ * the browser's own resulting "scroll to the fragment" pass finds nothing
25
+ * left to move and is an invisible no-op, whenever it happens to run.
26
+ *
27
+ * An earlier version instead let the native click run and tried to catch its
28
+ * jump a frame later — see the note atop `scroll-animation.ts` for why that
29
+ * was racy in a way no amount of polling fixed, and had to be replaced by
30
+ * this preventDefault-and-replay approach instead.
31
+ *
32
+ * If the reader scrolls away mid-animation (`'interrupted'` below), the URL
33
+ * still gets a plain `pushState` — reflecting the click without dragging
34
+ * them back to a destination they've already scrolled past, and without the
35
+ * `:target` flash firing for a section they're no longer looking at.
36
+ */
37
+ ---
38
+
39
+ <script>
40
+ import { animatedScrollIntoView } from '../scroll-animation';
41
+
42
+ function sameDocumentFragmentId(link: HTMLAnchorElement): string | null {
43
+ if (link.target && link.target !== '_self') return null;
44
+ let url: URL;
45
+ try {
46
+ url = new URL(link.href, location.href);
47
+ } catch {
48
+ return null;
49
+ }
50
+ if (
51
+ url.origin !== location.origin ||
52
+ url.pathname !== location.pathname ||
53
+ url.search !== location.search ||
54
+ !url.hash
55
+ ) {
56
+ return null;
57
+ }
58
+ return url.hash.slice(1);
59
+ }
60
+
61
+ document.addEventListener('click', (event) => {
62
+ if (
63
+ event.defaultPrevented ||
64
+ event.button !== 0 ||
65
+ event.metaKey ||
66
+ event.ctrlKey ||
67
+ event.shiftKey ||
68
+ event.altKey
69
+ ) {
70
+ return;
71
+ }
72
+
73
+ const origin = event.target instanceof Element ? event.target : null;
74
+ const link = origin?.closest('a[href]');
75
+ if (!(link instanceof HTMLAnchorElement)) return;
76
+
77
+ const rawId = sameDocumentFragmentId(link);
78
+ if (!rawId) return;
79
+
80
+ let id = rawId;
81
+ try {
82
+ id = decodeURIComponent(rawId);
83
+ } catch {
84
+ /* keep the raw fragment */
85
+ }
86
+ const target = document.getElementById(id);
87
+ if (!target) return; // no matching element: let the browser's own no-op stand
88
+
89
+ if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
90
+
91
+ event.preventDefault();
92
+ animatedScrollIntoView(target, { block: 'start' }, (reason) => {
93
+ const hash = `#${id}`;
94
+ if (location.hash === hash) return;
95
+ if (reason === 'complete') {
96
+ location.hash = id;
97
+ } else {
98
+ history.pushState(null, '', hash);
99
+ }
100
+ });
101
+ });
102
+ </script>