explico 0.1.0 → 0.2.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 (54) hide show
  1. package/dist/compiler/cli.d.ts +1 -0
  2. package/dist/compiler/cli.js +1 -1
  3. package/dist/compiler/collections.js +30 -3
  4. package/dist/compiler/manifest.d.ts +10 -1
  5. package/dist/compiler/manifest.js +28 -12
  6. package/dist/compiler/resolution-report.d.ts +1 -0
  7. package/dist/compiler/resolution-report.js +2 -2
  8. package/dist/reference/base-library.d.mts +17 -0
  9. package/dist/reference/citation-format.d.mts +55 -0
  10. package/dist/reference/consistency.d.ts +1 -1
  11. package/dist/reference/consistency.js +94 -3
  12. package/dist/reference/curriculum-alignment.js +1 -1
  13. package/dist/reference/equations.d.mts +84 -0
  14. package/dist/reference/equations.mjs +75 -39
  15. package/dist/reference/gate-math.d.ts +5 -4
  16. package/dist/reference/gate-math.js +73 -2
  17. package/dist/reference/gloss.d.ts +87 -0
  18. package/dist/reference/gloss.js +59 -0
  19. package/dist/reference/katex-options.d.mts +33 -0
  20. package/dist/reference/lab-math-scope.js +1 -1
  21. package/dist/reference/math-glyphs.d.mts +46 -0
  22. package/dist/reference/registry.js +26 -1
  23. package/dist/reference/rehype-fail-katex-errors.d.mts +6 -0
  24. package/dist/reference/rehype-heading-anchors.d.mts +1 -0
  25. package/dist/reference/rehype-heading-anchors.mjs +144 -0
  26. package/dist/reference/remark-citation.d.mts +6 -0
  27. package/dist/reference/remark-notation.d.mts +49 -0
  28. package/dist/reference/remark-notation.mjs +30 -24
  29. package/dist/reference/types.d.ts +25 -2
  30. package/dist/session/user-context.js +1 -1
  31. package/package.json +2 -2
  32. package/src/compiler/collections.ts +40 -0
  33. package/src/compiler/manifest.ts +31 -0
  34. package/src/components/HoverPanel.astro +42 -0
  35. package/src/components/citation/CitationLayer.astro +1 -0
  36. package/src/components/hover-panel.ts +71 -5
  37. package/src/components/notation/NotationFormula.astro +154 -0
  38. package/src/components/notation/NotationGlossary.astro +60 -0
  39. package/src/components/notation/NotationLayer.astro +248 -103
  40. package/src/components/notation/render-notation-math.ts +55 -0
  41. package/src/components/popup-stack.ts +345 -0
  42. package/src/components/starlight/EquationEnhancer.astro +20 -5
  43. package/src/components/starlight/HeadingAnchorEnhancer.astro +131 -0
  44. package/src/components/starlight/LessonFooter.astro +4 -0
  45. package/src/content-config.ts +38 -0
  46. package/src/reference/consistency.ts +154 -1
  47. package/src/reference/equations.mjs +75 -39
  48. package/src/reference/gate-math.ts +104 -7
  49. package/src/reference/gloss.ts +153 -0
  50. package/src/reference/registry.ts +43 -0
  51. package/src/reference/rehype-heading-anchors.mjs +144 -0
  52. package/src/reference/remark-notation.mjs +30 -24
  53. package/src/reference/types.ts +26 -1
  54. package/src/styles/global.css +84 -2
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Deep-linkable content headings (Step F3).
3
+ *
4
+ * The lesson pipeline uses a custom Markdown `processor` (see `astro.config.mjs`),
5
+ * which bypasses Starlight's own heading-link rehype pass. This plugin restores
6
+ * it with the *same* markup Starlight emits, so its bundled `anchor-links.css`
7
+ * (shipped globally because `markdown.headingLinks` defaults on) styles it for
8
+ * free — including the hover/focus reveal of an otherwise invisible anchor:
9
+ *
10
+ * <div class="sl-heading-wrapper level-h2">
11
+ * <h2 id="slug">Heading</h2>
12
+ * <a class="sl-anchor-link" href="#slug"> <icon> <sr-only label> </a>
13
+ * </div>
14
+ *
15
+ * The `:target` flash, sticky-header scroll offset, and the JS niceties
16
+ * (re-trigger on repeat activation, smooth scroll, `history.pushState`, brief
17
+ * section highlight) live in `global.css` + `HeadingAnchorEnhancer.astro`, the
18
+ * same split the keyed-equation feature uses.
19
+ *
20
+ * `rehypeHeadingIds` from `@astrojs/markdown-remark` normally runs *after* the
21
+ * user rehype plugins, so heading `id`s do not exist yet when this runs. It is
22
+ * idempotent (only assigns a missing `id`, and rebuilds `file.data.astro.headings`
23
+ * from an unchanged document order), so this plugin runs it first and Astro's
24
+ * later pass becomes a no-op for these headings.
25
+ */
26
+
27
+ import { rehypeHeadingIds } from '@astrojs/markdown-remark';
28
+
29
+ const HEADING_TAG = /^h([1-6])$/;
30
+
31
+ // Starlight's `link-alt` icon (`components-internals/Icons.ts`), the same glyph
32
+ // its own `rehypeAutolinkHeadings` uses.
33
+ const LINK_ICON_PATH =
34
+ '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';
35
+
36
+ function headingText(node) {
37
+ let text = '';
38
+ const walk = (current) => {
39
+ if (!current || typeof current !== 'object') return;
40
+ if (current.type === 'text' || current.type === 'raw') {
41
+ text += String(current.value ?? '');
42
+ return;
43
+ }
44
+ if (Array.isArray(current.children)) current.children.forEach(walk);
45
+ };
46
+ walk(node);
47
+ return text.replace(/\s+/g, ' ').trim();
48
+ }
49
+
50
+ function isHeadingWrapper(node) {
51
+ const className = node?.properties?.className;
52
+ return (
53
+ node?.type === 'element' &&
54
+ Array.isArray(className) &&
55
+ className.includes('sl-heading-wrapper')
56
+ );
57
+ }
58
+
59
+ function anchorLink(id, label) {
60
+ return {
61
+ type: 'element',
62
+ tagName: 'a',
63
+ properties: { className: ['sl-anchor-link'], href: `#${id}` },
64
+ children: [
65
+ {
66
+ type: 'element',
67
+ tagName: 'span',
68
+ properties: { ariaHidden: 'true', className: ['sl-anchor-icon'] },
69
+ children: [
70
+ {
71
+ type: 'element',
72
+ tagName: 'svg',
73
+ properties: {
74
+ width: 16,
75
+ height: 16,
76
+ viewBox: '0 0 24 24',
77
+ fill: 'currentColor',
78
+ },
79
+ children: [
80
+ {
81
+ type: 'element',
82
+ tagName: 'path',
83
+ properties: { d: LINK_ICON_PATH },
84
+ children: [],
85
+ },
86
+ ],
87
+ },
88
+ ],
89
+ },
90
+ {
91
+ type: 'element',
92
+ tagName: 'span',
93
+ properties: { className: ['sr-only'], 'data-pagefind-ignore': 'true' },
94
+ children: [{ type: 'text', value: label }],
95
+ },
96
+ ],
97
+ };
98
+ }
99
+
100
+ export default function rehypeHeadingAnchors() {
101
+ const assignHeadingIds = rehypeHeadingIds();
102
+
103
+ return function transform(tree, file) {
104
+ assignHeadingIds(tree, file);
105
+
106
+ const walk = (node) => {
107
+ if (!node || typeof node !== 'object' || !Array.isArray(node.children)) {
108
+ return;
109
+ }
110
+ if (isHeadingWrapper(node)) return; // already processed
111
+
112
+ for (let index = 0; index < node.children.length; index++) {
113
+ const child = node.children[index];
114
+ const tag =
115
+ child?.type === 'element' && typeof child.tagName === 'string'
116
+ ? HEADING_TAG.exec(child.tagName)
117
+ : null;
118
+
119
+ if (
120
+ tag &&
121
+ tag[1] !== '1' &&
122
+ typeof child.properties?.id === 'string' &&
123
+ child.properties.id.length > 0
124
+ ) {
125
+ const id = child.properties.id;
126
+ const label = `Section titled “${headingText(child)}”`;
127
+ node.children[index] = {
128
+ type: 'element',
129
+ tagName: 'div',
130
+ properties: {
131
+ className: ['sl-heading-wrapper', `level-${child.tagName}`],
132
+ },
133
+ children: [child, anchorLink(id, label)],
134
+ };
135
+ continue; // the heading is a leaf for our purposes
136
+ }
137
+
138
+ walk(child);
139
+ }
140
+ };
141
+
142
+ walk(tree);
143
+ };
144
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * @param {{ sources?: Map<string, object> | readonly object[] | (() => Map<string, object> | readonly object[]) }} [options]
3
+ */
4
+ export default function remarkCitation(options?: {
5
+ sources?: Map<string, object> | readonly object[] | (() => Map<string, object> | readonly object[]);
6
+ }): (tree: any, file: any) => void;
@@ -0,0 +1,49 @@
1
+ /**
2
+ * @param {{
3
+ * definitions?: Map<string, object> | readonly object[] | Record<string, object> | (() => Map<string, object> | readonly object[] | Record<string, object>),
4
+ * resolve?: (key: string, context: object) => object | undefined,
5
+ * unknown?: 'error' | 'warn' | 'ignore',
6
+ * base?: string,
7
+ * equations?: Record<string, Record<string, string>>,
8
+ * }} [options]
9
+ */
10
+ export default function remarkNotation(options?: {
11
+ definitions?: Map<string, object> | readonly object[] | Record<string, object> | (() => Map<string, object> | readonly object[] | Record<string, object>);
12
+ resolve?: (key: string, context: object) => object | undefined;
13
+ unknown?: "error" | "warn" | "ignore";
14
+ base?: string;
15
+ equations?: Record<string, Record<string, string>>;
16
+ }): (tree: any, file: any) => void;
17
+ /**
18
+ * Remark adapter for semantic notation references.
19
+ *
20
+ * Author syntax:
21
+ * - Prose introduces a meaning: `[[rates.discount-factor]]`
22
+ * - Later math uses ordinary LaTeX: `D(0,t)`
23
+ * - `\\explain{rates.discount-factor}{D(0,t)}` remains available inside math
24
+ * when lexical scope cannot select one meaning unambiguously.
25
+ *
26
+ * Prose references become ordinary links. Lesson math is resolved against the
27
+ * page glyph table — `notation.local` plus the shared entries the page names
28
+ * with `[[key]]` (or `\\explain{key}{…}`) — and the base library; the
29
+ * adapter then injects the narrowly trusted `\\explain` marker before
30
+ * build-time KaTeX rendering. `notation.uses` is retired: the use is the
31
+ * declaration (D1 decision, 2026-09-02).
32
+ *
33
+ * Definitions can be supplied as a Map, an array, an object keyed by the
34
+ * semantic key, or a loader returning one of those forms. Loaders are
35
+ * evaluated for every document transform so a long-running development
36
+ * server can discover definitions created after startup. A resolver can
37
+ * provide page-local lexical scoping:
38
+ *
39
+ * remarkNotation({
40
+ * definitions: sharedDefinitions,
41
+ * resolve(key, { file, kind, localDefinitions, sharedDefinitions }) {
42
+ * return localDefinitions.get(key) ?? sharedDefinitions.get(key);
43
+ * },
44
+ * })
45
+ *
46
+ * A definition may be either its data object or an Astro collection entry
47
+ * with the data object under `.data`.
48
+ */
49
+ export const NOTATION_KEY_PATTERN: RegExp;
@@ -36,7 +36,9 @@ export const NOTATION_KEY_PATTERN = /^[a-z0-9]+(?:[.-][a-z0-9]+)*$/;
36
36
  import { GlyphResolutionError, resolveMathGlyphs } from './math-glyphs.mjs';
37
37
  import {
38
38
  EQ_LABEL_PATTERN,
39
+ collectDisplayEquations,
39
40
  collectEquationLabels,
41
+ equationAnchorId,
40
42
  parseEquationRef,
41
43
  stripEquationLabels,
42
44
  } from './equations.mjs';
@@ -307,28 +309,31 @@ function defaultHref(definition, key, base = '') {
307
309
  }
308
310
 
309
311
  /**
310
- * Wrap a keyed `$$…$$` `math` node so the container survives `rehype-katex`
311
- * (which splices the inner `math-display` element out). The visible number is
312
- * a focusable `<a>` sibling — never authored, never inside KaTeX — so it
313
- * survives print and screen readers name it "Equation 2.4".
312
+ * Wrap a display `$$…$$` `math` node so the container survives `rehype-katex`
313
+ * (which splices the inner `math-display` element out). Every display equation
314
+ * in a lesson is numbered; a keyed one anchors at `#eq-<key>`, an unkeyed one
315
+ * at its positional `#eq-<section>-<index>`. The visible number is a focusable
316
+ * `<a>` sibling — never authored, never inside KaTeX — so it survives print and
317
+ * screen readers name it "Equation 2.4".
314
318
  */
315
- function keyedEquationNode(mathNode, key, number) {
319
+ function equationNode(mathNode, { key, number }) {
320
+ const id = equationAnchorId({ key, number });
321
+ const hProperties = {
322
+ id,
323
+ className: ['keyed-equation'],
324
+ 'data-eq-number': number,
325
+ };
326
+ if (key) hProperties['data-eq-key'] = key;
327
+ else hProperties['data-eq-auto'] = '';
328
+
316
329
  return {
317
330
  type: 'paragraph',
318
- data: {
319
- hName: 'div',
320
- hProperties: {
321
- id: `eq-${key}`,
322
- className: ['keyed-equation'],
323
- 'data-eq-key': key,
324
- 'data-eq-number': number,
325
- },
326
- },
331
+ data: { hName: 'div', hProperties },
327
332
  children: [
328
333
  mathNode,
329
334
  {
330
335
  type: 'link',
331
- url: `#eq-${key}`,
336
+ url: `#${id}`,
332
337
  children: [{ type: 'text', value: `(${number})` }],
333
338
  data: {
334
339
  hProperties: {
@@ -598,6 +603,14 @@ export default function remarkNotation(options = {}) {
598
603
  crossPage: options.equations ?? {},
599
604
  };
600
605
 
606
+ // Number and anchor every display equation on a lesson page, keyed or
607
+ // not (F3). Non-lesson pages (notation registry, stray docs) keep the
608
+ // keyed-only behaviour so their body math is left untouched.
609
+ const displayEquations = lessonId ? collectDisplayEquations(tree) : [];
610
+ const equationByNode = new Map(
611
+ displayEquations.map((equation) => [equation.node, equation]),
612
+ );
613
+
601
614
  const resolve = (key, kind, node) => {
602
615
  const context = {
603
616
  file,
@@ -647,7 +660,6 @@ export default function remarkNotation(options = {}) {
647
660
  node,
648
661
  );
649
662
  }
650
- node.__eqKey = labelMatch[1];
651
663
  value = stripEquationLabels(value);
652
664
  replaceMathSource(node, value);
653
665
  }
@@ -725,14 +737,8 @@ export default function remarkNotation(options = {}) {
725
737
  continue;
726
738
  }
727
739
  visit(child, false);
728
- if (child && child.__eqKey) {
729
- const key = child.__eqKey;
730
- delete child.__eqKey;
731
- node.children[index] = keyedEquationNode(
732
- child,
733
- key,
734
- pageLabels.get(key),
735
- );
740
+ if (child && child.type === 'math' && equationByNode.has(child)) {
741
+ node.children[index] = equationNode(child, equationByNode.get(child));
736
742
  }
737
743
  }
738
744
  };
@@ -15,6 +15,19 @@ type NotationAlignment = {
15
15
  readonly rationale: string;
16
16
  };
17
17
  type NotationReferenceKind = 'prose' | 'math' | 'definition';
18
+ /**
19
+ * A gloss resolved by the loader (D15). Declared on a card, keyed
20
+ * `<card key>.<slug(name)>`, and deliberately absent from the registry: a
21
+ * gloss is never a definition record, never a reference target, and never a
22
+ * curriculum node. See `reference/gloss.ts`.
23
+ */
24
+ interface NotationGlossEntry {
25
+ readonly key: string;
26
+ readonly notation: string;
27
+ readonly label: string;
28
+ readonly units?: string;
29
+ readonly ownerKey: string;
30
+ }
18
31
  interface NotationReferenceInput {
19
32
  readonly key: string;
20
33
  readonly kind: NotationReferenceKind;
@@ -37,6 +50,8 @@ interface SharedNotationDefinitionInput {
37
50
  readonly body: string;
38
51
  /** References harvested from the Markdown body. */
39
52
  readonly references: readonly NotationReferenceInput[];
53
+ /** Symbols this entry's `formula` names but does not make cards of. */
54
+ readonly glosses: readonly NotationGlossEntry[];
40
55
  readonly source: SourceSpan;
41
56
  }
42
57
  interface LocalNotationDefinitionInput {
@@ -51,6 +66,8 @@ interface LocalNotationDefinitionInput {
51
66
  readonly alignment: NotationAlignment;
52
67
  /** References harvested from summary content. */
53
68
  readonly references: readonly NotationReferenceInput[];
69
+ /** Symbols this entry's `formula` names but does not make cards of. */
70
+ readonly glosses: readonly NotationGlossEntry[];
54
71
  readonly source: SourceSpan;
55
72
  }
56
73
  interface NotationLessonInput {
@@ -67,7 +84,7 @@ interface NotationRegistryInput {
67
84
  readonly sharedDefinitions: readonly SharedNotationDefinitionInput[];
68
85
  readonly lessons: readonly NotationLessonInput[];
69
86
  }
70
- type NotationDiagnosticCode = 'invalid-key' | 'duplicate-page' | 'duplicate-definition' | 'conflicting-definition' | 'undefined-reference' | 'reference-cycle' | 'unused-definition' | 'alignment-unknown-competency' | 'alignment-unknown-lesson' | 'alignment-introduction' | 'alignment-unavailable' | 'alignment-review-state';
87
+ type NotationDiagnosticCode = 'invalid-key' | 'duplicate-page' | 'duplicate-definition' | 'conflicting-definition' | 'undefined-reference' | 'reference-cycle' | 'unused-definition' | 'alignment-unknown-competency' | 'alignment-unknown-lesson' | 'alignment-introduction' | 'alignment-unavailable' | 'alignment-review-state' | 'gloss-collides-with-card';
71
88
  type DiagnosticSeverity = 'error' | 'warning';
72
89
  interface NotationDiagnostic {
73
90
  readonly code: NotationDiagnosticCode;
@@ -95,6 +112,12 @@ interface ResolvedNotationReference {
95
112
  interface NotationDefinitionRecordBase {
96
113
  readonly id: string;
97
114
  readonly key: string;
115
+ /**
116
+ * Symbols this entry's `formula` names but does not make cards of (D15).
117
+ * Carried on the record so the manifest can build the entry-scoped formula
118
+ * table; deliberately **not** a definition, a reference target, or an edge.
119
+ */
120
+ readonly glosses: readonly NotationGlossEntry[];
98
121
  readonly scope: NotationDefinitionScope;
99
122
  readonly notation: string;
100
123
  readonly label: string;
@@ -148,4 +171,4 @@ interface NotationRegistry {
148
171
  readonly diagnostics: readonly NotationDiagnostic[];
149
172
  }
150
173
 
151
- export type { DiagnosticSeverity, EditorialStatus, LocalNotationDefinitionInput, LocalNotationDefinitionRecord, NotationAlignment, NotationBacklink, NotationBinding, NotationDefinitionRecord, NotationDefinitionScope, NotationDiagnostic, NotationDiagnosticCode, NotationLessonInput, NotationPageBundle, NotationReferenceInput, NotationReferenceKind, NotationRegistry, NotationRegistryInput, ResolvedNotationReference, SharedNotationDefinitionInput, SharedNotationDefinitionRecord, SourceSpan };
174
+ export type { DiagnosticSeverity, EditorialStatus, LocalNotationDefinitionInput, LocalNotationDefinitionRecord, NotationAlignment, NotationBacklink, NotationBinding, NotationDefinitionRecord, NotationDefinitionScope, NotationDiagnostic, NotationDiagnosticCode, NotationGlossEntry, NotationLessonInput, NotationPageBundle, NotationReferenceInput, NotationReferenceKind, NotationRegistry, NotationRegistryInput, ResolvedNotationReference, SharedNotationDefinitionInput, SharedNotationDefinitionRecord, SourceSpan };
@@ -1,5 +1,5 @@
1
1
  import { createContext, useContext } from "react";
2
- import { anonymousUser } from "./user";
2
+ import { anonymousUser } from "./user.js";
3
3
  const SessionUserContext = createContext(anonymousUser);
4
4
  function useSessionUser() {
5
5
  return useContext(SessionUserContext);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "explico",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Build-time engine for prerequisite-aware, notation-rigorous course content: one validated content model, a semantic notation/citation/equation system with a completeness gate, and a deterministic JSON manifest.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -48,6 +48,6 @@
48
48
  "typescript": "6.0.2"
49
49
  },
50
50
  "scripts": {
51
- "build": "tsup && node scripts/copy-assets.mjs"
51
+ "build": "tsup && node scripts/copy-assets.mjs && node scripts/fix-esm-extensions.mjs && tsc -p tsconfig.dts-mjs.json"
52
52
  }
53
53
  }
@@ -32,10 +32,12 @@ import type {
32
32
  NotationRegistryInput,
33
33
  SharedNotationDefinitionInput,
34
34
  SourceSpan,
35
+ NotationGlossEntry,
35
36
  } from '../reference/types';
36
37
  import { isSubstantiveMeaning } from '../reference/prose';
37
38
  import { parseEquationRef } from '../reference/equations.mjs';
38
39
  import { resolveLabel } from '../reference/label';
40
+ import { resolveGlosses } from '../reference/gloss';
39
41
  import {
40
42
  deriveRequires,
41
43
  deriveSources,
@@ -322,6 +324,42 @@ export async function loadCurriculumCatalog(): Promise<CurriculumCatalog> {
322
324
 
323
325
  // --- notation registry input -------------------------------------------
324
326
 
327
+ /**
328
+ * A card's authored `glosses:` list (D15). Keys are derived from the names, so
329
+ * a gloss is entry-scoped and promoting one to a card needs no reference
330
+ * rewrite anywhere in the corpus — see `reference/gloss.ts`.
331
+ */
332
+ function glossEntries(
333
+ raw: unknown,
334
+ ownerKey: string,
335
+ at: string,
336
+ ): NotationGlossEntry[] {
337
+ if (raw === undefined) return [];
338
+ if (!Array.isArray(raw)) throw new Error(`${at} must be an array`);
339
+
340
+ const authored = raw.map((value, index) => {
341
+ const data = record(value, `${at}[${index}]`);
342
+ const units = optionalString(data.units, `${at}[${index}].units`);
343
+ return {
344
+ latex: string(data.latex, `${at}[${index}].latex`),
345
+ name: string(data.name, `${at}[${index}].name`),
346
+ ...(units === undefined ? {} : { units }),
347
+ };
348
+ });
349
+
350
+ const glosses = resolveGlosses(ownerKey, authored);
351
+ const seen = new Set<string>();
352
+ for (const gloss of glosses) {
353
+ if (seen.has(gloss.key)) {
354
+ throw new Error(
355
+ `${at} declares two glosses named ${JSON.stringify(gloss.label)}`,
356
+ );
357
+ }
358
+ seen.add(gloss.key);
359
+ }
360
+ return glosses;
361
+ }
362
+
325
363
  function localDefinition(
326
364
  raw: unknown,
327
365
  file: string,
@@ -350,6 +388,7 @@ function localDefinition(
350
388
  ? { kind: 'general', rationale: 'Lesson-local symbol.' }
351
389
  : alignment(data.alignment, `${at}.alignment`),
352
390
  references: extractNotationReferences(definitionText, file, 'definition'),
391
+ glosses: glossEntries(data.glosses, key, `${at}.glosses`),
353
392
  source: { file },
354
393
  ...(formula === undefined ? {} : { formula }),
355
394
  ...(units === undefined ? {} : { units }),
@@ -382,6 +421,7 @@ function sharedNotationEntries(): SharedNotationDefinitionInput[] {
382
421
  aiAssisted: boolean(data.aiAssisted, `${file} aiAssisted`),
383
422
  body,
384
423
  references: extractNotationReferences(body, file, undefined),
424
+ glosses: glossEntries(data.glosses, key, `${file} glosses`),
385
425
  source: { file },
386
426
  ...(formula === undefined ? {} : { formula }),
387
427
  ...(units === undefined ? {} : { units }),
@@ -57,7 +57,10 @@ import {
57
57
  } from '../reference/gate-math';
58
58
  import { loadLintIgnore } from '../reference/lint-ignore';
59
59
  import { buildNotationRegistry } from '../reference/registry';
60
+ import { formulaGlyphScope } from '../reference/gloss';
61
+ import type { GlyphScopeEntry } from '../reference/gloss';
60
62
  import { resolveReferenceId } from '../reference/references';
63
+ import type { NotationGlossEntry } from '../reference/types';
61
64
  import type {
62
65
  EditorialStatus,
63
66
  NotationBacklink,
@@ -137,6 +140,14 @@ export interface ManifestLessonNotation {
137
140
  readonly seeAlso: readonly string[];
138
141
  readonly units?: string;
139
142
  readonly formula?: string;
143
+ /** Symbols the formula names that have no card of their own (D15). */
144
+ readonly glosses: readonly NotationGlossEntry[];
145
+ /**
146
+ * Entry-scoped glyph table for `formula` — the entry, its glosses, and any
147
+ * card the formula names. Precomputed here because the whole card set lives
148
+ * in the compiler, not in the page component.
149
+ */
150
+ readonly formulaScope: readonly GlyphScopeEntry[];
140
151
  }
141
152
 
142
153
  export interface ManifestLesson {
@@ -306,6 +317,7 @@ function lessonNotationBundle(
306
317
  bundle: NotationPageBundle | undefined,
307
318
  definitionsById: ReadonlyMap<string, NotationDefinitionRecord>,
308
319
  resolution: readonly ManifestNotationResolution[],
320
+ cardsByKey: ReadonlyMap<string, GlyphScopeEntry>,
309
321
  ): ManifestLesson['notation'] {
310
322
  const definitions: ManifestLessonNotation[] = [];
311
323
  for (const definitionId of bundle?.definitionIds ?? []) {
@@ -320,6 +332,16 @@ function lessonNotationBundle(
320
332
  summary: definition.summary,
321
333
  editorialStatus: definition.status,
322
334
  seeAlso: [...definition.seeAlso],
335
+ glosses: [...definition.glosses],
336
+ formulaScope: formulaGlyphScope({
337
+ key: definition.key,
338
+ notation: definition.notation,
339
+ glosses: definition.glosses,
340
+ cardsByKey,
341
+ ...(definition.formula === undefined
342
+ ? {}
343
+ : { formula: definition.formula }),
344
+ }),
323
345
  ...(definition.units === undefined ? {} : { units: definition.units }),
324
346
  ...(definition.formula === undefined
325
347
  ? {}
@@ -379,6 +401,14 @@ export async function compileManifest(
379
401
  ]);
380
402
 
381
403
  const registry = buildNotationRegistry(notationInput);
404
+ // Cards a `formula` may name with `\explain{key}` (D15). Shared entries
405
+ // only: a page-local symbol is not addressable from another entry.
406
+ const cardsByKey = new Map<string, GlyphScopeEntry>(
407
+ notationInput.sharedDefinitions.map((definition) => [
408
+ definition.key,
409
+ { key: definition.key, notation: definition.notation },
410
+ ]),
411
+ );
382
412
  const definitionsById = new Map(
383
413
  registry.definitions.map((definition) => [definition.id, definition]),
384
414
  );
@@ -476,6 +506,7 @@ export async function compileManifest(
476
506
  definitionsById,
477
507
  availableDefinitionIds,
478
508
  ),
509
+ cardsByKey,
479
510
  ),
480
511
  });
481
512
  }
@@ -11,6 +11,14 @@
11
11
  * Extra attributes (e.g. `data-notation-panel`, `data-citation-panel`) are
12
12
  * forwarded to the `<aside>` so each layer keeps the hook its tests and pin
13
13
  * controls already reference.
14
+ *
15
+ * `tabindex="-1"`: never in the tab order, but a valid target for
16
+ * `panel.focus()`. A layer that lets the reader click deeper into the panel's
17
+ * own content (D15's in-panel navigation) needs somewhere stable to move
18
+ * focus to before it replaces whatever the reader just clicked — otherwise
19
+ * removing the focused node drops focus to nothing, and the shared
20
+ * `focusout` handler in `./hover-panel.ts` reads that as "the reader left"
21
+ * and closes the panel it was just asked to update.
14
22
  */
15
23
  export interface Props {
16
24
  /** Element id — pin controls point `aria-controls` at it. */
@@ -31,8 +39,17 @@ const { id, label, class: className, ...rest } = Astro.props;
31
39
  aria-live="polite"
32
40
  aria-atomic="true"
33
41
  hidden
42
+ tabindex="-1"
34
43
  {...rest}
35
44
  >
45
+ <button
46
+ class="hover-panel-close"
47
+ type="button"
48
+ data-hover-panel-close
49
+ aria-label="Close"
50
+ >
51
+ <span aria-hidden="true">&times;</span>
52
+ </button>
36
53
  <slot />
37
54
  </aside>
38
55
 
@@ -48,6 +65,7 @@ const { id, label, class: className, ...rest } = Astro.props;
48
65
  width: min(23rem, calc(100vw - 1.5rem));
49
66
  overflow: auto;
50
67
  padding: 1rem;
68
+ padding-inline-end: 2.4rem;
51
69
  border: 1px solid var(--sl-color-gray-5);
52
70
  border-top: 0.25rem solid var(--sl-color-accent);
53
71
  border-radius: 0.65rem;
@@ -57,6 +75,30 @@ const { id, label, class: className, ...rest } = Astro.props;
57
75
  font-size: var(--sl-text-sm);
58
76
  }
59
77
 
78
+ .hover-panel-close {
79
+ position: absolute;
80
+ top: 0.5rem;
81
+ right: 0.5rem;
82
+ width: 1.6rem;
83
+ height: 1.6rem;
84
+ display: flex;
85
+ align-items: center;
86
+ justify-content: center;
87
+ padding: 0;
88
+ border: 0;
89
+ border-radius: 0.35rem;
90
+ color: var(--sl-color-gray-3);
91
+ background: transparent;
92
+ font-size: 1.15rem;
93
+ line-height: 1;
94
+ cursor: pointer;
95
+ }
96
+
97
+ .hover-panel-close:hover {
98
+ color: var(--sl-color-text);
99
+ background: var(--sl-color-gray-6);
100
+ }
101
+
60
102
  /* Slotted fields carry the owning layer's scope, not this component's, so
61
103
  the descendant match must be global. */
62
104
  .hover-panel :global(:focus-visible) {
@@ -108,6 +108,7 @@ import HoverPanel from '../HoverPanel.astro';
108
108
 
109
109
  createHoverPanel({
110
110
  panel,
111
+ groupId: 'citation',
111
112
  triggerFrom: markerFrom,
112
113
  keyFor: (element) => element.dataset.citationNumber ?? null,
113
114
  render,