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
@@ -21,6 +21,8 @@ export interface HoverPanelState {
21
21
  export interface HoverPanelConfig {
22
22
  /** The `<aside data-hover-panel>` element. */
23
23
  panel: HTMLElement;
24
+ /** Exclusivity group (see the `activeSurface` note below `HoverPanelState`). */
25
+ groupId: string;
24
26
  /**
25
27
  * Element used only to locate the enclosing article column for the
26
28
  * horizontal clamp. Defaults to the panel itself.
@@ -49,6 +51,36 @@ function closestElement(node: EventTarget | null, selector: string) {
49
51
  return node instanceof Element ? node.closest<HTMLElement>(selector) : null;
50
52
  }
51
53
 
54
+ /**
55
+ * The notation layer and the citation layer each own their explanations —
56
+ * the notation layer through its own popup stack (`./popup-stack.ts`), the
57
+ * citation layer through this module's `createHoverPanel`. Neither knows the
58
+ * other exists, so a pinned citation and an open notation popup — or the
59
+ * reverse — can each be individually correct and still land on top of each
60
+ * other, or on top of the equation the other one is explaining. Rather than
61
+ * teach every positioning calculation about every other surface that might
62
+ * exist, the two are mutually exclusive *groups*: opening the first popup in
63
+ * one group closes whatever the other group had open. Within a group,
64
+ * surfaces manage their own overlap (the citation panel is a singleton; the
65
+ * notation stack positions its own popups against each other). A pin is not
66
+ * exempt — the reader's attention already moved to the other group.
67
+ */
68
+ let activeSurface: {
69
+ readonly groupId: string;
70
+ readonly close: () => void;
71
+ } | null = null;
72
+
73
+ export function claimActiveSurface(groupId: string, close: () => void): void {
74
+ if (activeSurface && activeSurface.groupId !== groupId) {
75
+ activeSurface.close();
76
+ }
77
+ activeSurface = { groupId, close };
78
+ }
79
+
80
+ export function releaseActiveSurface(groupId: string): void {
81
+ if (activeSurface?.groupId === groupId) activeSurface = null;
82
+ }
83
+
52
84
  export function createHoverPanel(config: HoverPanelConfig) {
53
85
  const { panel } = config;
54
86
  if (panel.dataset.hoverPanelReady === 'true') return;
@@ -145,6 +177,8 @@ export function createHoverPanel(config: HoverPanelConfig) {
145
177
  if (!key) return;
146
178
  if (config.render(anchor, key) === false) return;
147
179
 
180
+ claimActiveSurface(config.groupId, forceClose);
181
+
148
182
  activeKey = key;
149
183
  activeAnchor = anchor;
150
184
  config.onShow?.(key, anchor);
@@ -161,9 +195,23 @@ export function createHoverPanel(config: HoverPanelConfig) {
161
195
  activeAnchor = null;
162
196
  panel.hidden = true;
163
197
  config.onClose?.();
198
+ releaseActiveSurface(config.groupId);
164
199
  sync();
165
200
  };
166
201
 
202
+ // A plain `close()` deliberately leaves `pinned` set — a stray pointerout or
203
+ // focusout elsewhere on the page must not kill a pin the reader placed on
204
+ // purpose, and `restorePinnedOrClose` below relies on `pinned` surviving a
205
+ // transient close to bring the panel back. Being displaced by a DIFFERENT
206
+ // panel taking over (`claimActivePanel`) is not transient, though: without
207
+ // clearing the pin here, the very next stray blur anywhere on the page finds
208
+ // `pinned` still truthy and restores this panel — undoing the takeover a
209
+ // frame later and fighting the other panel for the screen.
210
+ const forceClose = () => {
211
+ pinned = null;
212
+ close();
213
+ };
214
+
167
215
  const restorePinnedOrClose = () => {
168
216
  window.requestAnimationFrame(() => {
169
217
  const focused = config.triggerFrom(document.activeElement);
@@ -235,11 +283,29 @@ export function createHoverPanel(config: HoverPanelConfig) {
235
283
  }
236
284
 
237
285
  const trigger = config.triggerFrom(event.target);
238
- if (!trigger || trigger.closest('a')) return;
239
- const key = config.keyFor(trigger);
240
- if (!key) return;
241
- event.preventDefault();
242
- togglePin(key, trigger);
286
+ if (trigger && !trigger.closest('a')) {
287
+ const key = config.keyFor(trigger);
288
+ if (key) {
289
+ event.preventDefault();
290
+ togglePin(key, trigger);
291
+ return;
292
+ }
293
+ }
294
+ if (trigger) return; // a real link trigger: let it navigate.
295
+
296
+ // Anywhere else inside the panel collapses it, same as the corner "×" —
297
+ // its own body is never a trigger, so a click there can only mean "I'm
298
+ // done with this." A real link (e.g. "Open source link") is excluded so
299
+ // it still navigates instead of being swallowed.
300
+ if (
301
+ !panel.hidden &&
302
+ event.target instanceof Node &&
303
+ panel.contains(event.target) &&
304
+ !closestElement(event.target, 'a')
305
+ ) {
306
+ pinned = null;
307
+ close();
308
+ }
243
309
  });
244
310
 
245
311
  document.addEventListener('pointerdown', (event) => {
@@ -0,0 +1,154 @@
1
+ ---
2
+ /**
3
+ * A notation entry's rigorous `formula`, rendered with every glyph bound to a
4
+ * key so the reader can point at any symbol in it and get an explanation
5
+ * (D15).
6
+ *
7
+ * The formula resolves against the **entry-scoped** table from
8
+ * `reference/gloss.ts` — the entry, its `glosses:`, and any card the formula
9
+ * names with `\explain{key}{latex}` — not the registry-wide table a card's
10
+ * body math uses.
11
+ *
12
+ * The gloss list under the formula is not decoration: a gloss has no card of
13
+ * its own, so this list is where its name lives with JavaScript off. The hover
14
+ * panel is enhancement layered over content that already reads.
15
+ */
16
+ import NotationMath from './NotationMath.astro';
17
+ import SafeMathNode from './SafeMathNode.astro';
18
+ import { renderNotationFormula } from './render-notation-math';
19
+ import type { GlyphScopeEntry, ResolvedGloss } from '../../reference/gloss';
20
+
21
+ export interface Props {
22
+ formula: string;
23
+ scope: readonly GlyphScopeEntry[];
24
+ glosses?: readonly ResolvedGloss[];
25
+ /** Heading for the gloss list; omitted renders the list unlabelled. */
26
+ glossLabel?: string;
27
+ /** The owning entry's display label, used to name the scrollable region. */
28
+ entryLabel?: string;
29
+ }
30
+
31
+ const {
32
+ formula,
33
+ scope,
34
+ glosses = [],
35
+ glossLabel = 'Symbols',
36
+ entryLabel,
37
+ } = Astro.props;
38
+ const { nodes } = renderNotationFormula(formula, scope);
39
+ ---
40
+
41
+ <div class="notation-formula-block">
42
+ <div
43
+ class="notation-formula-math"
44
+ data-notation-formula
45
+ role="group"
46
+ aria-label={entryLabel ? `${entryLabel} formula` : 'Formula'}
47
+ tabindex="0"
48
+ >
49
+ <span class="notation-math">
50
+ {nodes.map((node) => <SafeMathNode node={node} />)}
51
+ </span>
52
+ </div>
53
+
54
+ {
55
+ glosses.length > 0 && (
56
+ <dl class="notation-gloss-list" data-notation-gloss-list>
57
+ {glossLabel && <dt class="notation-gloss-label">{glossLabel}</dt>}
58
+ {glosses.map((gloss) => (
59
+ <dd class="notation-gloss" data-notation-gloss={gloss.key}>
60
+ <span
61
+ class="notation-gloss-symbol"
62
+ data-notation-symbol-trigger
63
+ data-notation-key={gloss.key}
64
+ >
65
+ <NotationMath class="notation-symbol" latex={gloss.notation} />
66
+ </span>
67
+ <span class="notation-gloss-name">{gloss.label}</span>
68
+ {gloss.units && (
69
+ <span class="notation-gloss-units">({gloss.units})</span>
70
+ )}
71
+ </dd>
72
+ ))}
73
+ </dl>
74
+ )
75
+ }
76
+ </div>
77
+
78
+ <style>
79
+ @layer starlight.components {
80
+ .notation-formula-block {
81
+ display: grid;
82
+ gap: 0.5rem;
83
+ }
84
+
85
+ /* A wide equation scrolls inside its own box, which makes the box a
86
+ scrollable region — and a scrollable region a keyboard user cannot reach
87
+ is unreachable content, not just an awkward one. `tabindex` on the
88
+ container is the fix; this is its focus ring. */
89
+ .notation-formula-math {
90
+ overflow-x: auto;
91
+ padding-block: 0.15rem;
92
+ color: var(--sl-color-text);
93
+ }
94
+
95
+ .notation-formula-math:focus-visible {
96
+ outline: 3px solid var(--sl-color-accent);
97
+ outline-offset: 2px;
98
+ }
99
+
100
+ .notation-gloss-list {
101
+ display: flex;
102
+ flex-wrap: wrap;
103
+ align-items: baseline;
104
+ gap: 0.25rem 1rem;
105
+ margin: 0;
106
+ color: var(--sl-color-gray-2);
107
+ font-size: var(--sl-text-sm);
108
+ }
109
+
110
+ .notation-gloss-label {
111
+ margin: 0;
112
+ color: var(--sl-color-gray-3);
113
+ font-weight: 600;
114
+ }
115
+
116
+ .notation-gloss {
117
+ display: flex;
118
+ align-items: baseline;
119
+ gap: 0.35rem;
120
+ margin: 0;
121
+ }
122
+
123
+ .notation-gloss-symbol {
124
+ border-radius: 0.16em;
125
+ color: var(--sl-color-accent-high);
126
+ cursor: help;
127
+ }
128
+
129
+ .notation-gloss-symbol:hover,
130
+ .notation-gloss-symbol:global(.is-notation-active) {
131
+ background: var(--sl-color-accent-low);
132
+ box-shadow: 0 0 0 0.12em var(--sl-color-accent-low);
133
+ }
134
+
135
+ .notation-gloss-name::before {
136
+ content: '— ';
137
+ color: var(--sl-color-gray-3);
138
+ }
139
+
140
+ .notation-gloss-units {
141
+ color: var(--sl-color-gray-3);
142
+ }
143
+
144
+ @media (forced-colors: active) {
145
+ .notation-gloss-symbol:hover,
146
+ .notation-gloss-symbol:global(.is-notation-active) {
147
+ outline: 2px solid Highlight;
148
+ color: HighlightText;
149
+ background: Highlight;
150
+ box-shadow: none;
151
+ }
152
+ }
153
+ }
154
+ </style>
@@ -1,10 +1,13 @@
1
1
  ---
2
2
  import { getCollection, render } from 'astro:content';
3
3
  import NotationMath from './NotationMath.astro';
4
+ import NotationFormula from './NotationFormula.astro';
4
5
  import NotationLayer, {
5
6
  type NotationLayerDefinition,
6
7
  } from './NotationLayer.astro';
7
8
  import { resolveLabel } from '../../reference/label';
9
+ import { formulaGlyphScope, resolveGlosses } from '../../reference/gloss';
10
+ import type { GlyphScopeEntry, ResolvedGloss } from '../../reference/gloss';
8
11
  import {
9
12
  isLessonSlug,
10
13
  lessonIdFromSlug,
@@ -38,6 +41,35 @@ for (const backlink of manifest.notation.backlinks) {
38
41
  lessonsByNotationKey.set(backlink.key, current);
39
42
  }
40
43
 
44
+ // The two-tier vocabulary (D15). Every card is a node here; its glosses are
45
+ // not — they are symbol-and-name entries owned by the card whose `formula`
46
+ // needs them, kept out of the registry so a definitional letter never becomes
47
+ // a curriculum edge.
48
+ const cardsByKey = new Map<string, GlyphScopeEntry>(
49
+ notationEntries.map((entry) => [
50
+ entry.data.key,
51
+ { key: entry.data.key, notation: entry.data.latex },
52
+ ]),
53
+ );
54
+ const glossesByKey = new Map<string, ResolvedGloss[]>(
55
+ notationEntries.map((entry) => [
56
+ entry.data.key,
57
+ resolveGlosses(entry.data.key, entry.data.glosses),
58
+ ]),
59
+ );
60
+ const formulaScopeByKey = new Map<string, GlyphScopeEntry[]>(
61
+ notationEntries.map((entry) => [
62
+ entry.data.key,
63
+ formulaGlyphScope({
64
+ key: entry.data.key,
65
+ notation: entry.data.latex,
66
+ formula: entry.data.formula,
67
+ glosses: glossesByKey.get(entry.data.key) ?? [],
68
+ cardsByKey,
69
+ }),
70
+ ]),
71
+ );
72
+
41
73
  const renderedEntries = await Promise.all(
42
74
  notationEntries
43
75
  .sort(
@@ -60,6 +92,9 @@ const panelDefinitions: NotationLayerDefinition[] = notationEntries.map(
60
92
  notation: entry.data.latex,
61
93
  label: labelOf(entry.data),
62
94
  summary: entry.data.meaning,
95
+ formula: entry.data.formula,
96
+ formulaScope: formulaScopeByKey.get(entry.data.key),
97
+ glosses: glossesByKey.get(entry.data.key),
63
98
  units: entry.data.units,
64
99
  seeAlso: entry.data.seeAlso,
65
100
  editorialStatus: entry.data.editorialStatus,
@@ -149,6 +184,19 @@ const panelDefinitions: NotationLayerDefinition[] = notationEntries.map(
149
184
  )}
150
185
  </dl>
151
186
 
187
+ <p class="glossary-meaning">{entry.data.meaning}</p>
188
+
189
+ {entry.data.formula && (
190
+ <div class="glossary-formula">
191
+ <NotationFormula
192
+ formula={entry.data.formula}
193
+ scope={formulaScopeByKey.get(entry.data.key) ?? []}
194
+ glosses={glossesByKey.get(entry.data.key)}
195
+ entryLabel={labelOf(entry.data)}
196
+ />
197
+ </div>
198
+ )}
199
+
152
200
  <div class="glossary-definition">
153
201
  <Content />
154
202
  </div>
@@ -364,6 +412,18 @@ const panelDefinitions: NotationLayerDefinition[] = notationEntries.map(
364
412
  margin: 0;
365
413
  }
366
414
 
415
+ .glossary-meaning {
416
+ margin-block: 0 1rem;
417
+ }
418
+
419
+ .glossary-formula {
420
+ margin-block: 1rem;
421
+ padding: 0.85rem 1rem;
422
+ border: 1px solid var(--sl-color-gray-5);
423
+ border-radius: 0.5rem;
424
+ background: var(--sl-color-bg);
425
+ }
426
+
367
427
  .see-also {
368
428
  display: flex;
369
429
  flex-wrap: wrap;