explico 0.2.0 → 0.3.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 +102 -0
  2. package/README.md +46 -5
  3. package/dist/compiler/cli.d.ts +16 -0
  4. package/dist/compiler/cli.js +41 -3
  5. package/dist/compiler/collections.js +4 -1
  6. package/dist/compiler/course-config.d.ts +52 -1
  7. package/dist/compiler/knowledge-version.d.ts +58 -0
  8. package/dist/compiler/knowledge-version.js +80 -0
  9. package/dist/compiler/manifest.d.ts +77 -2
  10. package/dist/compiler/manifest.js +115 -18
  11. package/dist/compiler/resolution-report.d.ts +4 -0
  12. package/dist/reference/base-operators.d.mts +38 -0
  13. package/dist/reference/base-operators.mjs +103 -0
  14. package/dist/reference/consistency.d.ts +7 -2
  15. package/dist/reference/consistency.js +51 -2
  16. package/dist/reference/diagram-layout.d.ts +63 -0
  17. package/dist/reference/diagram-layout.js +122 -0
  18. package/dist/reference/equations-validate.d.ts +5 -0
  19. package/dist/reference/equations-validate.js +1 -1
  20. package/dist/reference/equations.d.mts +42 -11
  21. package/dist/reference/equations.mjs +58 -17
  22. package/dist/reference/figures-validate.d.ts +21 -0
  23. package/dist/reference/figures-validate.js +13 -0
  24. package/dist/reference/keyed-float-validate.d.ts +67 -0
  25. package/dist/reference/keyed-float-validate.js +110 -0
  26. package/dist/reference/keyed-jsx-floats.d.mts +56 -0
  27. package/dist/reference/keyed-jsx-floats.mjs +167 -0
  28. package/dist/reference/math-glyphs.d.mts +18 -0
  29. package/dist/reference/math-glyphs.mjs +92 -0
  30. package/dist/reference/remark-notation.d.mts +12 -0
  31. package/dist/reference/remark-notation.mjs +199 -9
  32. package/dist/reference/tables-validate.d.ts +21 -0
  33. package/dist/reference/tables-validate.js +13 -0
  34. package/dist/reference/units-vocab.js +19 -1
  35. package/docs/VERSIONING.md +66 -0
  36. package/docs/authoring/README.md +96 -0
  37. package/docs/authoring/agents.md +63 -0
  38. package/docs/authoring/migrating.md +107 -0
  39. package/docs/authoring/notation.md +296 -0
  40. package/docs/authoring/versioning.md +116 -0
  41. package/docs/authoring/visualizations.md +195 -0
  42. package/package.json +20 -5
  43. package/src/compiler/cli.ts +49 -0
  44. package/src/compiler/collections.ts +12 -3
  45. package/src/compiler/course-config.ts +54 -0
  46. package/src/compiler/knowledge-version.ts +157 -0
  47. package/src/compiler/manifest.ts +239 -17
  48. package/src/components/CurriculumMap.astro +27 -0
  49. package/src/components/diagram/Diagram.astro +317 -0
  50. package/src/components/examples/CompactExamples.astro +1 -1
  51. package/src/components/figure/Figure.astro +104 -0
  52. package/src/components/notation/render-notation-math.ts +13 -2
  53. package/src/components/reference/FloatRefLayer.astro +671 -0
  54. package/src/components/starlight/AboutPanel.astro +255 -0
  55. package/src/components/starlight/HeadingAnchorEnhancer.astro +3 -3
  56. package/src/components/starlight/KeyedFloatEnhancer.astro +137 -0
  57. package/src/components/starlight/LayoutSocialIcons.astro +12 -0
  58. package/src/components/starlight/LessonFooter.astro +4 -2
  59. package/src/components/table/Table.astro +104 -0
  60. package/src/reference/base-operators.mjs +103 -0
  61. package/src/reference/consistency.ts +83 -2
  62. package/src/reference/diagram-layout.ts +246 -0
  63. package/src/reference/equations-validate.ts +6 -1
  64. package/src/reference/equations.mjs +58 -17
  65. package/src/reference/figures-validate.ts +40 -0
  66. package/src/reference/keyed-float-validate.ts +207 -0
  67. package/src/reference/keyed-jsx-floats.mjs +167 -0
  68. package/src/reference/math-glyphs.mjs +92 -0
  69. package/src/reference/remark-notation.mjs +199 -9
  70. package/src/reference/tables-validate.ts +40 -0
  71. package/src/reference/units-vocab.ts +18 -0
  72. package/src/styles/global.css +115 -7
  73. package/src/components/starlight/EquationEnhancer.astro +0 -87
@@ -42,6 +42,11 @@ import {
42
42
  parseEquationRef,
43
43
  stripEquationLabels,
44
44
  } from './equations.mjs';
45
+ import {
46
+ collectKeyedJsxFloats,
47
+ keyedFloatAnchorId,
48
+ parseKeyedFloatRef,
49
+ } from './keyed-jsx-floats.mjs';
45
50
 
46
51
  const PROSE_SKIP_TYPES = new Set([
47
52
  'code',
@@ -340,6 +345,8 @@ function equationNode(mathNode, { key, number }) {
340
345
  className: ['keyed-equation__number'],
341
346
  'data-eq-number': number,
342
347
  'aria-label': `Equation ${number}`,
348
+ // Plain hover tooltip — the number is a permalink to this equation.
349
+ title: 'Anchor to this equation',
343
350
  },
344
351
  },
345
352
  },
@@ -347,6 +354,32 @@ function equationNode(mathNode, { key, number }) {
347
354
  };
348
355
  }
349
356
 
357
+ /**
358
+ * Give a `<Table>`/`<Figure>` `mdxJsxFlowElement` its computed `number` and
359
+ * `anchorId` as real JSX attributes, so the actual Astro component (which
360
+ * renders the numbered anchor, unlike `equationNode`'s synthesized hast div)
361
+ * receives them as props. The compiler is the single source of truth for
362
+ * both — the component only renders what it's given.
363
+ */
364
+ function injectFloatNumberAttributes(node, float, prefix) {
365
+ const anchorId = keyedFloatAnchorId({
366
+ key: float.key,
367
+ number: float.number,
368
+ prefix,
369
+ });
370
+ node.attributes = (node.attributes ?? []).filter(
371
+ (attribute) =>
372
+ !(
373
+ attribute?.type === 'mdxJsxAttribute' &&
374
+ (attribute.name === 'number' || attribute.name === 'anchorId')
375
+ ),
376
+ );
377
+ node.attributes.push(
378
+ { type: 'mdxJsxAttribute', name: 'number', value: float.number },
379
+ { type: 'mdxJsxAttribute', name: 'anchorId', value: anchorId },
380
+ );
381
+ }
382
+
350
383
  /** Resolve a `[[eq-key]]` / `[[slug#eq-key]]` reference to `{ number, href }`. */
351
384
  function resolveEquationRef(eqRef, eqCtx, file, node) {
352
385
  if (eqRef.lesson) {
@@ -374,7 +407,47 @@ function resolveEquationRef(eqRef, eqCtx, file, node) {
374
407
  return { number, href: `#eq-${eqRef.key}` };
375
408
  }
376
409
 
377
- function proseNodes(value, resolve, file, node, references, eqCtx) {
410
+ /**
411
+ * Resolve a `[[<prefix>-key]]` / `[[slug#<prefix>-key]]` reference (a
412
+ * `<Table>`/`<Figure>` float) to `{ number, href }` — the table/figure
413
+ * equivalent of `resolveEquationRef`.
414
+ */
415
+ function resolveKeyedFloatRef(floatRef, ctx, { prefix, tagName }, file, node) {
416
+ if (floatRef.lesson) {
417
+ const number = ctx.crossPage?.[floatRef.lesson]?.[floatRef.key];
418
+ if (number === undefined) {
419
+ fail(
420
+ file,
421
+ `Unknown ${tagName} reference [[${floatRef.lesson}#${prefix}-${floatRef.key}]]: lesson ${floatRef.lesson} has no <${tagName}> with id="${floatRef.key}".`,
422
+ node,
423
+ );
424
+ }
425
+ return {
426
+ number,
427
+ href: `${ctx.base.replace(/\/$/, '')}/${floatRef.lesson}/#${prefix}-${floatRef.key}`,
428
+ };
429
+ }
430
+ const number = ctx.pageLabels.get(floatRef.key);
431
+ if (number === undefined) {
432
+ fail(
433
+ file,
434
+ `Unknown ${tagName} reference [[${prefix}-${floatRef.key}]]: no <${tagName}> on this page carries id="${floatRef.key}".`,
435
+ node,
436
+ );
437
+ }
438
+ return { number, href: `#${prefix}-${floatRef.key}` };
439
+ }
440
+
441
+ function proseNodes(
442
+ value,
443
+ resolve,
444
+ file,
445
+ node,
446
+ references,
447
+ eqCtx,
448
+ tableCtx,
449
+ figureCtx,
450
+ ) {
378
451
  const base = eqCtx?.base ?? '';
379
452
  const strayExplain = firstUnescaped(value, EXPLAIN);
380
453
  if (strayExplain !== -1) {
@@ -395,7 +468,18 @@ function proseNodes(value, resolve, file, node, references, eqCtx) {
395
468
 
396
469
  const key = match[1].trim();
397
470
  const eqRef = eqCtx ? parseEquationRef(key) : undefined;
398
- if (!eqRef && !NOTATION_KEY_PATTERN.test(key)) {
471
+ const tableRef =
472
+ !eqRef && tableCtx ? parseKeyedFloatRef(key, { prefix: 'tab' }) : undefined;
473
+ const figureRef =
474
+ !eqRef && !tableRef && figureCtx
475
+ ? parseKeyedFloatRef(key, { prefix: 'fig' })
476
+ : undefined;
477
+ if (
478
+ !eqRef &&
479
+ !tableRef &&
480
+ !figureRef &&
481
+ !NOTATION_KEY_PATTERN.test(key)
482
+ ) {
399
483
  fail(file, `Invalid notation key "${key}" in [[...]].`, node);
400
484
  }
401
485
 
@@ -426,9 +510,46 @@ function proseNodes(value, resolve, file, node, references, eqCtx) {
426
510
  continue;
427
511
  }
428
512
 
513
+ const floatRef = tableRef ?? figureRef;
514
+ if (floatRef) {
515
+ const kind = tableRef ? 'table' : 'figure';
516
+ const prefix = tableRef ? 'tab' : 'fig';
517
+ const tagName = tableRef ? 'Table' : 'Figure';
518
+ const { number, href } = resolveKeyedFloatRef(
519
+ floatRef,
520
+ tableRef ? tableCtx : figureCtx,
521
+ { prefix, tagName },
522
+ file,
523
+ node,
524
+ );
525
+ output.push({
526
+ type: 'link',
527
+ url: href,
528
+ children: [{ type: 'text', value: `${tagName} ${number}` }],
529
+ data: {
530
+ hProperties: {
531
+ className: [`${kind}-ref`],
532
+ [`data-${prefix}-ref`]: floatRef.key,
533
+ ...(floatRef.lesson ? { [`data-${prefix}-lesson`]: floatRef.lesson } : {}),
534
+ },
535
+ },
536
+ });
537
+ references.push({
538
+ key: floatRef.key,
539
+ kind,
540
+ ...(floatRef.lesson ? { lesson: floatRef.lesson } : {}),
541
+ });
542
+ cursor = match.index + match[0].length;
543
+ continue;
544
+ }
545
+
429
546
  const definition = resolve(key, 'prose', node);
430
- const title = definition?.title ?? key;
431
- const summary = definition?.summary;
547
+ // `title`/`summary` are the pre-C1b field names, kept as the first
548
+ // fallback for a caller still supplying them; real content authors
549
+ // `label`/`meaning` (see `content-config.ts`), which raw frontmatter
550
+ // (`manifest.notation.raw`, a page's own `notation.local`) carries as-is.
551
+ const title = definition?.title ?? definition?.label ?? key;
552
+ const summary = definition?.summary ?? definition?.meaning;
432
553
  const href = defaultHref(definition, key, base);
433
554
 
434
555
  const properties = {
@@ -557,7 +678,16 @@ function inspectExplainCalls(value, resolve, file, node, references) {
557
678
  * unknown?: 'error' | 'warn' | 'ignore',
558
679
  * base?: string,
559
680
  * equations?: Record<string, Record<string, string>>,
681
+ * tables?: Record<string, Record<string, string>>,
682
+ * figures?: Record<string, Record<string, string>>,
683
+ * chapters?: Record<string, number>,
560
684
  * }} [options]
685
+ *
686
+ * `chapters` maps a lesson slug to its 1-based course ordinal. When the
687
+ * current page has one, every display equation / `<Table>` / `<Figure>` is
688
+ * numbered `‹chapter›.‹n›` (`##` headings ignored); with no entry the legacy
689
+ * `##`-section scheme applies. It must agree with `manifest.lessons[].chapter`,
690
+ * which is what feeds `equations`/`tables`/`figures` above.
561
691
  */
562
692
  export default function remarkNotation(options = {}) {
563
693
  const unknown = options.unknown ?? 'error';
@@ -573,6 +703,10 @@ export default function remarkNotation(options = {}) {
573
703
  );
574
704
  const localDefinitions = localDefinitionsFromFile(file);
575
705
  const lessonId = lessonIdFrom(file);
706
+ // The lesson's course ordinal (see the `chapters` option). Falsy / missing
707
+ // ⇒ the legacy `##`-section numbering, so `0` and `undefined` behave alike.
708
+ const chapter =
709
+ (lessonId && options.chapters?.[lessonId.replace(/\./g, '/')]) || undefined;
576
710
  const registryFile = isNotationRegistryFile(file);
577
711
  const mathScope = pageGlyphScope(
578
712
  localDefinitions,
@@ -582,10 +716,10 @@ export default function remarkNotation(options = {}) {
582
716
  const references = [];
583
717
  const resolved = new Map();
584
718
 
585
- // Equation identity (D7): assign every `\label{eq:…}` its section-scoped
586
- // number before any math node is rewritten, so a `[[eq-key]]` earlier in
587
- // the page still resolves.
588
- const equationLabels = collectEquationLabels(tree);
719
+ // Equation identity (D7): assign every `\label{eq:…}` its number before any
720
+ // math node is rewritten, so a `[[eq-key]]` earlier in the page still
721
+ // resolves. `chapter` picks the `‹chapter›.‹n›` vs `##`-section scheme.
722
+ const equationLabels = collectEquationLabels(tree, chapter);
589
723
  const pageLabels = new Map();
590
724
  for (const label of equationLabels) {
591
725
  if (pageLabels.has(label.key)) {
@@ -606,11 +740,50 @@ export default function remarkNotation(options = {}) {
606
740
  // Number and anchor every display equation on a lesson page, keyed or
607
741
  // not (F3). Non-lesson pages (notation registry, stray docs) keep the
608
742
  // keyed-only behaviour so their body math is left untouched.
609
- const displayEquations = lessonId ? collectDisplayEquations(tree) : [];
743
+ const displayEquations = lessonId
744
+ ? collectDisplayEquations(tree, chapter)
745
+ : [];
610
746
  const equationByNode = new Map(
611
747
  displayEquations.map((equation) => [equation.node, equation]),
612
748
  );
613
749
 
750
+ // Keyed floats (F-tables-figures): every `<Table>`/`<Figure>` on a lesson
751
+ // page is numbered and anchored, keyed or not — same rule as equations,
752
+ // via the shared engine in `keyed-jsx-floats.mjs`.
753
+ const makeFloatCtx = (tagName, crossPage) => {
754
+ const floats = lessonId
755
+ ? collectKeyedJsxFloats(tree, { tagName, chapter })
756
+ : [];
757
+ const pageFloatLabels = new Map();
758
+ for (const float of floats) {
759
+ if (float.key == null) continue;
760
+ if (pageFloatLabels.has(float.key)) {
761
+ fail(
762
+ file,
763
+ `Duplicate <${tagName} id="${float.key}"> on this page; an id is unique per lesson.`,
764
+ float.node,
765
+ );
766
+ }
767
+ pageFloatLabels.set(float.key, float.number);
768
+ }
769
+ return {
770
+ ctx: {
771
+ base: options.base ?? '',
772
+ pageLabels: pageFloatLabels,
773
+ crossPage: crossPage ?? {},
774
+ },
775
+ byNode: new Map(floats.map((float) => [float.node, float])),
776
+ };
777
+ };
778
+ const { ctx: tableCtx, byNode: tableByNode } = makeFloatCtx(
779
+ 'Table',
780
+ options.tables,
781
+ );
782
+ const { ctx: figureCtx, byNode: figureByNode } = makeFloatCtx(
783
+ 'Figure',
784
+ options.figures,
785
+ );
786
+
614
787
  const resolve = (key, kind, node) => {
615
788
  const context = {
616
789
  file,
@@ -729,6 +902,8 @@ export default function remarkNotation(options = {}) {
729
902
  child,
730
903
  references,
731
904
  eqCtx,
905
+ tableCtx,
906
+ figureCtx,
732
907
  );
733
908
  if (replacement) {
734
909
  node.children.splice(index, 1, ...replacement);
@@ -739,6 +914,10 @@ export default function remarkNotation(options = {}) {
739
914
  visit(child, false);
740
915
  if (child && child.type === 'math' && equationByNode.has(child)) {
741
916
  node.children[index] = equationNode(child, equationByNode.get(child));
917
+ } else if (child?.type === 'mdxJsxFlowElement' && tableByNode.has(child)) {
918
+ injectFloatNumberAttributes(child, tableByNode.get(child), 'tab');
919
+ } else if (child?.type === 'mdxJsxFlowElement' && figureByNode.has(child)) {
920
+ injectFloatNumberAttributes(child, figureByNode.get(child), 'fig');
742
921
  }
743
922
  }
744
923
  };
@@ -755,5 +934,16 @@ export default function remarkNotation(options = {}) {
755
934
  indexInSection,
756
935
  }),
757
936
  );
937
+ const floatLabelData = (byNode) =>
938
+ [...byNode.values()]
939
+ .filter((float) => float.key != null)
940
+ .map(({ key, number, section, indexInSection }) => ({
941
+ key,
942
+ number,
943
+ section,
944
+ indexInSection,
945
+ }));
946
+ file.data.tableLabels = floatLabelData(tableByNode);
947
+ file.data.figureLabels = floatLabelData(figureByNode);
758
948
  };
759
949
  }
@@ -0,0 +1,40 @@
1
+ /** `validateKeyedFloats` for `<Table>` — see `keyed-float-validate.ts`. */
2
+
3
+ import {
4
+ validateKeyedFloats,
5
+ type KeyedFloatLabelRecord,
6
+ type KeyedFloatLessonInput,
7
+ type KeyedFloatValidation,
8
+ } from './keyed-float-validate';
9
+
10
+ export type TableDiagnosticCode =
11
+ | 'tab-invalid-key'
12
+ | 'tab-duplicate-key'
13
+ | 'tab-ref-resolves'
14
+ | 'tab-key-unused';
15
+
16
+ export interface TableDiagnostic {
17
+ readonly code: TableDiagnosticCode;
18
+ readonly severity: 'error' | 'warning';
19
+ readonly message: string;
20
+ readonly file?: string;
21
+ readonly lessonId?: string;
22
+ readonly key?: string;
23
+ }
24
+
25
+ export type TableLabelRecord = KeyedFloatLabelRecord;
26
+ export type TableLessonInput = KeyedFloatLessonInput;
27
+
28
+ export interface TableValidation extends KeyedFloatValidation {
29
+ readonly diagnostics: readonly TableDiagnostic[];
30
+ }
31
+
32
+ export function validateTables(
33
+ lessons: readonly TableLessonInput[],
34
+ ): TableValidation {
35
+ return validateKeyedFloats(lessons, {
36
+ tagName: 'Table',
37
+ prefix: 'tab',
38
+ noun: 'table',
39
+ }) as TableValidation;
40
+ }
@@ -118,6 +118,24 @@ export const UNITS_VOCAB: ReadonlySet<string> = new Set([
118
118
  'quoted',
119
119
  'explicitly',
120
120
  'calculations',
121
+ // motion / physical quantities (playground corpus)
122
+ 'meter',
123
+ 'meters',
124
+ 'second',
125
+ 'seconds',
126
+ 'speed',
127
+ 'velocity',
128
+ 'acceleration',
129
+ 'gravitational',
130
+ 'launch',
131
+ 'angle',
132
+ 'degrees',
133
+ 'horizontal',
134
+ 'vertical',
135
+ 'level',
136
+ 'ground',
137
+ 'squared',
138
+ 'above',
121
139
  ]);
122
140
 
123
141
  /** Lowercase word tokens of a `units` string (split on whitespace and hyphens). */
@@ -668,7 +668,7 @@
668
668
  `.keyed-equation` with an `id="eq-<key>"`, a `data-eq-number`, and a
669
669
  focusable number link. Prose refers to it with `[[eq:key]]` →
670
670
  `a.equation-ref`. The deep-link highlight is a pure-CSS `:target` animation
671
- with a reduced-motion fallback; `EquationEnhancer.astro` only re-triggers it
671
+ with a reduced-motion fallback; `KeyedFloatEnhancer.astro` only re-triggers it
672
672
  on a repeated activation and moves focus. */
673
673
  .sl-markdown-content .keyed-equation {
674
674
  position: relative;
@@ -690,12 +690,11 @@
690
690
  font-size: var(--sl-text-sm);
691
691
  color: var(--sl-color-text-accent);
692
692
  text-decoration: none;
693
- /* Hardly visible until you look for it (F3): transparent by default, so it
694
- never competes with the equation, and — being `opacity:0`, not `hidden` —
695
- still announced to assistive tech and revealed on hover/focus/deep-link.
696
- A partial opacity would fail the WCAG contrast check, so it is all or
697
- nothing, mirroring Starlight's own heading anchor links. */
698
- opacity: 0;
693
+ /* Present but quiet by default (F4): partially visible so a reader
694
+ scanning the page can always find the number without hunting, but it
695
+ still doesn't compete with the equation itself. Hover, focus, and a
696
+ deep-link (`:target`) push it to full opacity, same as before. */
697
+ opacity: 0.45;
699
698
  transition: opacity 120ms ease-out;
700
699
  }
701
700
 
@@ -726,6 +725,41 @@
726
725
  }
727
726
  }
728
727
 
728
+ /* Once a reader has actually arrived at (or clicked through to) an equation —
729
+ a deep-link on load, an in-page `[[eq:key]]` navigation, or pinning its
730
+ preview card (`FloatRefLayer.astro`) — its number stays fully visible
731
+ for the rest of the page's life, not just while `:target` or `:hover`
732
+ holds. `KeyedFloatEnhancer.astro` and `FloatRefLayer.astro` add this class
733
+ in JS; it is never present in the static markup. One extra class over the
734
+ base selector's specificity, so this always wins regardless of rule order. */
735
+ .sl-markdown-content a.keyed-equation__number.eq-number-revealed {
736
+ opacity: 1;
737
+ }
738
+
739
+ /* A referencing `[[eq:key]]` link elsewhere in the prose was hovered/focused
740
+ and the equation it points to is already fully on screen: highlight it in
741
+ place instead of opening a preview card (`FloatRefLayer.astro` decides
742
+ which). Mirrors the reduced-motion `:target` treatment below — steady, no
743
+ keyframe — since this fires on every hover, not just a rare deep-link. */
744
+ .sl-markdown-content .keyed-equation.eq-ref-highlight {
745
+ background: var(--sl-color-accent-low);
746
+ outline: 2px solid var(--sl-color-accent);
747
+ outline-offset: 0.2rem;
748
+ transition:
749
+ background 120ms ease-out,
750
+ outline-color 120ms ease-out;
751
+ }
752
+
753
+ .sl-markdown-content .keyed-equation.eq-ref-highlight a.keyed-equation__number {
754
+ opacity: 1;
755
+ }
756
+
757
+ @media (forced-colors: active) {
758
+ .sl-markdown-content .keyed-equation.eq-ref-highlight {
759
+ outline-color: Highlight;
760
+ }
761
+ }
762
+
729
763
  .sl-markdown-content a.equation-ref {
730
764
  font-variant-numeric: tabular-nums;
731
765
  font-weight: 600;
@@ -785,12 +819,86 @@
785
819
  transform: none;
786
820
  text-align: end;
787
821
  color: inherit;
822
+ opacity: 1;
788
823
  }
789
824
  .sl-markdown-content .keyed-equation {
790
825
  padding-inline-end: 0;
791
826
  }
792
827
  }
793
828
 
829
+ /* `<Table>`/`<Figure>` keyed floats (F-tables-figures) — the same deep-link
830
+ and hover-highlight treatment as equations above, via `KeyedFloatEnhancer`
831
+ and `FloatRefLayer.astro`. Unlike an equation's number (quiet until
832
+ hovered, F4), a table/figure number lives in its always-visible caption,
833
+ so there is no opacity dance to mirror here — only the `:target` flash
834
+ (reusing the `eq-flash` keyframe above) and the ref-highlight. */
835
+ .sl-markdown-content a.table-ref,
836
+ .sl-markdown-content a.figure-ref {
837
+ font-variant-numeric: tabular-nums;
838
+ font-weight: 600;
839
+ text-decoration: underline;
840
+ text-underline-offset: 0.15em;
841
+ padding-inline: 0.1em;
842
+ border-radius: 0.2em;
843
+ color: var(--sl-color-text-accent);
844
+ white-space: nowrap;
845
+ }
846
+
847
+ .sl-markdown-content a.table-ref:hover,
848
+ .sl-markdown-content a.table-ref:focus-visible,
849
+ .sl-markdown-content a.figure-ref:hover,
850
+ .sl-markdown-content a.figure-ref:focus-visible {
851
+ background: var(--sl-color-accent-low);
852
+ }
853
+
854
+ .sl-markdown-content .keyed-table:target,
855
+ .sl-markdown-content .keyed-figure:target {
856
+ animation: eq-flash 5s ease-out both;
857
+ border-radius: 0.3rem;
858
+ }
859
+
860
+ @media (prefers-reduced-motion: reduce) {
861
+ .sl-markdown-content .keyed-table:target,
862
+ .sl-markdown-content .keyed-figure:target {
863
+ animation: none;
864
+ background: var(--sl-color-accent-low);
865
+ outline: 2px solid var(--sl-color-accent);
866
+ outline-offset: 0.2rem;
867
+ }
868
+ }
869
+
870
+ /* A referencing `[[tab-key]]`/`[[fig-key]]` link elsewhere in the prose was
871
+ hovered/focused and the float it points to is already fully on screen:
872
+ highlight it in place instead of opening a preview card
873
+ (`FloatRefLayer.astro` decides which) — mirrors `.eq-ref-highlight`. */
874
+ .sl-markdown-content .keyed-table.tab-ref-highlight,
875
+ .sl-markdown-content .keyed-figure.fig-ref-highlight {
876
+ background: var(--sl-color-accent-low);
877
+ outline: 2px solid var(--sl-color-accent);
878
+ outline-offset: 0.2rem;
879
+ border-radius: 0.3rem;
880
+ transition:
881
+ background 120ms ease-out,
882
+ outline-color 120ms ease-out;
883
+ }
884
+
885
+ @media (forced-colors: active) {
886
+ .sl-markdown-content .keyed-table:target,
887
+ .sl-markdown-content .keyed-figure:target,
888
+ .sl-markdown-content .keyed-table.tab-ref-highlight,
889
+ .sl-markdown-content .keyed-figure.fig-ref-highlight {
890
+ animation: none;
891
+ outline: 2px solid Highlight;
892
+ outline-offset: 0.2rem;
893
+ }
894
+ .sl-markdown-content a.table-ref:hover,
895
+ .sl-markdown-content a.table-ref:focus-visible,
896
+ .sl-markdown-content a.figure-ref:hover,
897
+ .sl-markdown-content a.figure-ref:focus-visible {
898
+ outline: 2px solid Highlight;
899
+ }
900
+ }
901
+
794
902
  /* Deep-linkable content headings (F3). The wrapper + anchor-link markup and its
795
903
  hover/focus reveal come from Starlight's bundled `anchor-links.css`; these
796
904
  rules add the sticky-header scroll offset and the `:target` flash, mirroring
@@ -1,87 +0,0 @@
1
- ---
2
- /**
3
- * Progressive enhancement for keyed equations (Step D7).
4
- *
5
- * The no-JS baseline is complete: `remark-notation` emits a real `id="eq-<key>"`
6
- * container, a focusable number `<a>`, and `[[eq:key]]` renders a plain anchor;
7
- * `global.css` does the `:target` deep-link highlight (with a
8
- * `prefers-reduced-motion` fallback) and the sticky-header scroll offset via
9
- * `scroll-margin`.
10
- *
11
- * This script only adds what CSS and anchors cannot:
12
- * - a smooth, header-aware scroll on an in-page jump;
13
- * - re-triggering the highlight when the same `#eq-…` is activated again —
14
- * the one case a plain anchor click does nothing for, since the browser
15
- * won't navigate to a fragment that's already current;
16
- * - moving keyboard focus onto the equation so screen-reader users land on it.
17
- *
18
- * It deliberately does *not* intercept an ordinary click that changes the
19
- * hash. `:target` is set only by a genuine same-document fragment
20
- * navigation — never by `history.pushState` (a common trap: pushState
21
- * updates the URL but leaves `:target`, and thus `eq-flash`, dark). Letting
22
- * the click through and reacting to the `hashchange` it produces is what
23
- * actually plays the highlight.
24
- *
25
- * Everything here is gated on `prefers-reduced-motion` and degrades to the
26
- * native anchor behaviour if it throws.
27
- */
28
- ---
29
-
30
- <script>
31
- (() => {
32
- const root = document.querySelector('.sl-markdown-content');
33
- if (!root) return;
34
-
35
- const reduce = window.matchMedia('(prefers-reduced-motion: reduce)');
36
-
37
- const activate = (target: HTMLElement | null, doScroll: boolean) => {
38
- if (!target) return;
39
- if (!reduce.matches) {
40
- // Restart the CSS `:target` animation even when the hash is unchanged.
41
- target.style.animation = 'none';
42
- void target.offsetWidth;
43
- target.style.removeProperty('animation');
44
- }
45
- if (doScroll) {
46
- target.scrollIntoView({
47
- behavior: reduce.matches ? 'auto' : 'smooth',
48
- block: 'start',
49
- });
50
- }
51
- target.setAttribute('tabindex', '-1');
52
- try {
53
- target.focus({ preventScroll: true });
54
- } catch {
55
- target.focus();
56
- }
57
- };
58
-
59
- root.addEventListener('click', (event) => {
60
- const origin = event.target instanceof Element ? event.target : null;
61
- const link = origin?.closest('a.equation-ref, a.keyed-equation__number');
62
- if (!link) return;
63
- const href = link.getAttribute('href') || '';
64
- if (!href.startsWith('#eq-')) return; // cross-page: let the browser navigate
65
- const target = document.getElementById(href.slice(1));
66
- if (!target) return;
67
- // Only the already-on-this-anchor click needs handling here — the
68
- // browser has nothing new to navigate to, so it won't fire `hashchange`
69
- // and nothing else will re-trigger the flash. Every other click is left
70
- // alone: its native navigation sets `:target` and fires `hashchange`,
71
- // which the listener below turns into the same `activate()`.
72
- if (location.hash === href) {
73
- event.preventDefault();
74
- activate(target, true);
75
- }
76
- });
77
-
78
- window.addEventListener('hashchange', () => {
79
- if (!location.hash.startsWith('#eq-')) return;
80
- activate(document.getElementById(location.hash.slice(1)), true);
81
- });
82
-
83
- if (location.hash.startsWith('#eq-')) {
84
- activate(document.getElementById(location.hash.slice(1)), true);
85
- }
86
- })();
87
- </script>