explico 0.1.1 → 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 (42) hide show
  1. package/dist/compiler/cli.d.ts +1 -0
  2. package/dist/compiler/collections.js +27 -0
  3. package/dist/compiler/manifest.d.ts +10 -1
  4. package/dist/compiler/manifest.js +18 -2
  5. package/dist/compiler/resolution-report.d.ts +1 -0
  6. package/dist/reference/consistency.d.ts +1 -1
  7. package/dist/reference/consistency.js +93 -2
  8. package/dist/reference/equations.d.mts +43 -16
  9. package/dist/reference/equations.mjs +75 -39
  10. package/dist/reference/gate-math.d.ts +5 -4
  11. package/dist/reference/gate-math.js +73 -2
  12. package/dist/reference/gloss.d.ts +87 -0
  13. package/dist/reference/gloss.js +59 -0
  14. package/dist/reference/registry.js +25 -0
  15. package/dist/reference/rehype-heading-anchors.d.mts +1 -0
  16. package/dist/reference/rehype-heading-anchors.mjs +144 -0
  17. package/dist/reference/remark-notation.mjs +30 -24
  18. package/dist/reference/types.d.ts +25 -2
  19. package/package.json +1 -1
  20. package/src/compiler/collections.ts +40 -0
  21. package/src/compiler/manifest.ts +31 -0
  22. package/src/components/HoverPanel.astro +42 -0
  23. package/src/components/citation/CitationLayer.astro +1 -0
  24. package/src/components/hover-panel.ts +71 -5
  25. package/src/components/notation/NotationFormula.astro +154 -0
  26. package/src/components/notation/NotationGlossary.astro +60 -0
  27. package/src/components/notation/NotationLayer.astro +248 -103
  28. package/src/components/notation/render-notation-math.ts +55 -0
  29. package/src/components/popup-stack.ts +345 -0
  30. package/src/components/starlight/EquationEnhancer.astro +20 -5
  31. package/src/components/starlight/HeadingAnchorEnhancer.astro +131 -0
  32. package/src/components/starlight/LessonFooter.astro +4 -0
  33. package/src/content-config.ts +38 -0
  34. package/src/reference/consistency.ts +154 -1
  35. package/src/reference/equations.mjs +75 -39
  36. package/src/reference/gate-math.ts +104 -7
  37. package/src/reference/gloss.ts +153 -0
  38. package/src/reference/registry.ts +43 -0
  39. package/src/reference/rehype-heading-anchors.mjs +144 -0
  40. package/src/reference/remark-notation.mjs +30 -24
  41. package/src/reference/types.ts +26 -1
  42. package/src/styles/global.css +84 -2
@@ -0,0 +1,345 @@
1
+ /**
2
+ * Multi-popup engine for the notation layer (see `NotationLayer.astro`).
3
+ *
4
+ * Unlike the single shared hover panel (`./hover-panel.ts`, used by the
5
+ * citation layer, which never nests), notation cards nest: a card's formula
6
+ * can itself carry live symbols (D15), and pointing at one is the reader
7
+ * exploring outward, not navigating away — replacing the card they were just
8
+ * reading with a different one is the wrong response to that. So this module
9
+ * keeps a small stack of independent popup elements instead of one shared
10
+ * panel: each key gets at most one open popup, opening a symbol found inside
11
+ * an existing popup adds another popup beside it, closing a popup also
12
+ * closes whatever was opened from inside it (nothing is left pointing at a
13
+ * symbol that no longer exists), and every popup positions itself clear of
14
+ * its trigger and of every other currently open popup.
15
+ *
16
+ * Because each key's popup is a real, persistent DOM node — never replaced
17
+ * in place — this sidesteps the reentrancy problem an earlier "replace the
18
+ * panel's content" design ran into: a `:hover` check made once the dust
19
+ * settles is always accurate, there is no synthetic event fired by a node
20
+ * removing itself out from under the pointer.
21
+ */
22
+ import { claimActiveSurface, releaseActiveSurface } from './hover-panel';
23
+
24
+ export interface PopupStackConfig {
25
+ /** Where popup elements are appended. They are `position: fixed`, so the
26
+ * parent's place in the document doesn't matter. */
27
+ container: HTMLElement;
28
+ /** Exclusivity group against other surfaces (e.g. the citation panel). */
29
+ groupId: string;
30
+ /**
31
+ * Nearest interactive trigger for an event target — must search the live
32
+ * document *and* the content of every currently open popup.
33
+ */
34
+ triggerFrom: (node: EventTarget | null) => HTMLElement | null;
35
+ /** Stable key identifying what a trigger points at. */
36
+ keyFor: (trigger: HTMLElement) => string | null;
37
+ /** A pin toggle outside the popups themselves (e.g. a list button). */
38
+ pinControlFrom?: (node: EventTarget | null) => HTMLElement | null;
39
+ /**
40
+ * Build a fresh, unattached popup element for `key`, opened from `trigger`.
41
+ * Return `null` to abort the open (unknown key).
42
+ */
43
+ createPopup: (key: string, trigger: HTMLElement) => HTMLElement | null;
44
+ /** Called after any popup opens, closes, or changes pin state. */
45
+ onChange?: (open: ReadonlySet<string>, pinned: ReadonlySet<string>) => void;
46
+ }
47
+
48
+ interface PopupRecord {
49
+ key: string;
50
+ el: HTMLElement;
51
+ trigger: HTMLElement;
52
+ parentKey: string | null;
53
+ pinned: boolean;
54
+ leaveTimer: number | null;
55
+ }
56
+
57
+ function closestElement(node: EventTarget | null, selector: string) {
58
+ return node instanceof Element ? node.closest<HTMLElement>(selector) : null;
59
+ }
60
+
61
+ export function createPopupStack(config: PopupStackConfig) {
62
+ const { container } = config;
63
+ const popups = new Map<string, PopupRecord>();
64
+ const order: string[] = []; // open order, oldest first — Escape closes the last.
65
+
66
+ const pinControlFrom = (node: EventTarget | null) =>
67
+ config.pinControlFrom?.(node) ?? null;
68
+
69
+ const notify = () => {
70
+ config.onChange?.(
71
+ new Set(popups.keys()),
72
+ new Set([...popups.values()].filter((p) => p.pinned).map((p) => p.key)),
73
+ );
74
+ };
75
+
76
+ const parentOf = (trigger: HTMLElement): string | null => {
77
+ const host = closestElement(trigger, '[data-popup-key]');
78
+ const key = host?.getAttribute('data-popup-key') ?? null;
79
+ return key && popups.has(key) ? key : null;
80
+ };
81
+
82
+ function position(record: PopupRecord) {
83
+ const edge = 12;
84
+ const gap = 10;
85
+ const { el, trigger } = record;
86
+ const displayEquation = trigger.closest<HTMLElement>('.katex-display');
87
+ const safeRegion = displayEquation ?? trigger;
88
+ const regionRect = safeRegion.getBoundingClientRect();
89
+ const anchorRect = trigger.getBoundingClientRect();
90
+
91
+ const headerBottom =
92
+ document.querySelector<HTMLElement>('.header')?.getBoundingClientRect()
93
+ .bottom ?? 0;
94
+ const verticalStart = Math.max(edge, headerBottom + edge);
95
+ const verticalEnd = window.innerHeight - edge;
96
+ const spaceAbove = regionRect.top - gap - verticalStart;
97
+ const spaceBelow = verticalEnd - regionRect.bottom - gap;
98
+
99
+ el.style.maxHeight = `${Math.max(spaceAbove, spaceBelow, 0)}px`;
100
+ let rect = el.getBoundingClientRect();
101
+ const placeBelow = spaceBelow >= rect.height || spaceBelow >= spaceAbove;
102
+ el.style.maxHeight = `${Math.max(placeBelow ? spaceBelow : spaceAbove, 0)}px`;
103
+ rect = el.getBoundingClientRect();
104
+
105
+ let top = placeBelow
106
+ ? regionRect.bottom + gap
107
+ : regionRect.top - gap - rect.height;
108
+ let left = anchorRect.left + anchorRect.width / 2 - rect.width / 2;
109
+
110
+ const article = document.querySelector<HTMLElement>('.sl-markdown-content');
111
+ const articleRect = article?.getBoundingClientRect();
112
+ const horizontalStart = Math.max(edge, articleRect?.left ?? edge);
113
+ const horizontalEnd = Math.min(
114
+ window.innerWidth - edge,
115
+ articleRect?.right ?? window.innerWidth - edge,
116
+ );
117
+
118
+ top = Math.max(verticalStart, Math.min(top, verticalEnd - rect.height));
119
+ left =
120
+ horizontalEnd - horizontalStart >= rect.width
121
+ ? Math.max(horizontalStart, Math.min(left, horizontalEnd - rect.width))
122
+ : Math.max(edge, Math.min(left, window.innerWidth - rect.width - edge));
123
+
124
+ // Clear of every other open popup: cascade below whichever one this
125
+ // still overlaps, then re-clamp to the viewport. A handful of popups
126
+ // open at once is the realistic ceiling here, so a bounded scan is
127
+ // plenty — this does not need to be a general bin-packer.
128
+ const others = [...popups.values()]
129
+ .filter((other) => other.key !== record.key)
130
+ .map((other) => other.el.getBoundingClientRect());
131
+ for (let attempt = 0; attempt < others.length + 4; attempt++) {
132
+ const box = {
133
+ top,
134
+ left,
135
+ right: left + rect.width,
136
+ bottom: top + rect.height,
137
+ };
138
+ const hit = others.find(
139
+ (o) =>
140
+ box.left < o.right &&
141
+ box.right > o.left &&
142
+ box.top < o.bottom &&
143
+ box.bottom > o.top,
144
+ );
145
+ if (!hit) break;
146
+ top = hit.bottom + gap;
147
+ if (top + rect.height > verticalEnd) {
148
+ top = verticalStart;
149
+ left += rect.width * 0.35 + gap;
150
+ }
151
+ }
152
+ top = Math.max(verticalStart, Math.min(top, verticalEnd - rect.height));
153
+ left = Math.max(
154
+ edge,
155
+ Math.min(left, window.innerWidth - rect.width - edge),
156
+ );
157
+
158
+ el.style.top = `${Math.round(top)}px`;
159
+ el.style.left = `${Math.round(left)}px`;
160
+ }
161
+
162
+ function scheduleLeaveCheck(record: PopupRecord) {
163
+ if (record.pinned) return;
164
+ if (record.leaveTimer !== null) {
165
+ window.cancelAnimationFrame(record.leaveTimer);
166
+ }
167
+ record.leaveTimer = window.requestAnimationFrame(() => {
168
+ record.leaveTimer = null;
169
+ if (!popups.has(record.key) || record.pinned) return;
170
+ const staysOpen =
171
+ record.trigger.matches(':hover') ||
172
+ record.trigger === document.activeElement ||
173
+ record.el.matches(':hover') ||
174
+ record.el.contains(document.activeElement) ||
175
+ // A hovered or pinned CHILD keeps its parent alive too — moving from
176
+ // the trigger straight into a child popup (a real, legitimate path;
177
+ // the child is positioned right next to it) must not close this one
178
+ // out from under the reader mid-move.
179
+ [...popups.values()].some((other) => other.parentKey === record.key);
180
+ if (staysOpen) return;
181
+ closePopup(record.key);
182
+ });
183
+ }
184
+
185
+ function closePopup(key: string) {
186
+ const record = popups.get(key);
187
+ if (!record) return;
188
+ // Children first — a card opened from inside this one has nothing left
189
+ // to anchor to once it's gone.
190
+ for (const other of [...popups.values()]) {
191
+ if (other.parentKey === key) closePopup(other.key);
192
+ }
193
+ if (record.leaveTimer !== null) {
194
+ window.cancelAnimationFrame(record.leaveTimer);
195
+ }
196
+ popups.delete(key);
197
+ const at = order.indexOf(key);
198
+ if (at !== -1) order.splice(at, 1);
199
+ record.el.remove();
200
+ if (popups.size === 0) releaseActiveSurface(config.groupId);
201
+ notify();
202
+ }
203
+
204
+ function closeAll() {
205
+ for (const key of [...popups.keys()]) closePopup(key);
206
+ }
207
+
208
+ function openPopup(key: string, trigger: HTMLElement, pin: boolean): void {
209
+ const existing = popups.get(key);
210
+ if (existing) {
211
+ if (pin && !existing.pinned) {
212
+ existing.pinned = true;
213
+ notify();
214
+ }
215
+ return;
216
+ }
217
+
218
+ const el = config.createPopup(key, trigger);
219
+ if (!el) return;
220
+
221
+ if (popups.size === 0) claimActiveSurface(config.groupId, closeAll);
222
+
223
+ el.dataset.popupKey = key;
224
+ el.style.position = 'fixed';
225
+ el.style.visibility = 'hidden';
226
+ el.hidden = false;
227
+ container.appendChild(el);
228
+
229
+ const record: PopupRecord = {
230
+ key,
231
+ el,
232
+ trigger,
233
+ parentKey: parentOf(trigger),
234
+ pinned: pin,
235
+ leaveTimer: null,
236
+ };
237
+ popups.set(key, record);
238
+ order.push(key);
239
+
240
+ position(record);
241
+ el.style.visibility = 'visible';
242
+
243
+ el.addEventListener('pointerleave', () => scheduleLeaveCheck(record));
244
+ el.addEventListener('focusout', () => scheduleLeaveCheck(record));
245
+
246
+ notify();
247
+ }
248
+
249
+ document.addEventListener('pointerover', (event) => {
250
+ if (event.pointerType === 'touch') return;
251
+ const trigger = config.triggerFrom(event.target);
252
+ if (!trigger || trigger === config.triggerFrom(event.relatedTarget)) {
253
+ return;
254
+ }
255
+ const key = config.keyFor(trigger);
256
+ if (!key) return;
257
+ openPopup(key, trigger, false);
258
+ });
259
+
260
+ document.addEventListener('pointerout', (event) => {
261
+ if (event.pointerType === 'touch') return;
262
+ const trigger = config.triggerFrom(event.target);
263
+ if (!trigger || trigger === config.triggerFrom(event.relatedTarget)) {
264
+ return;
265
+ }
266
+ const key = config.keyFor(trigger);
267
+ const record = key ? popups.get(key) : undefined;
268
+ if (record) scheduleLeaveCheck(record);
269
+ });
270
+
271
+ document.addEventListener('focusin', (event) => {
272
+ const trigger = config.triggerFrom(event.target);
273
+ const key = trigger ? config.keyFor(trigger) : null;
274
+ if (trigger && key) openPopup(key, trigger, false);
275
+ });
276
+
277
+ document.addEventListener('focusout', (event) => {
278
+ const trigger = config.triggerFrom(event.target);
279
+ const key = trigger ? config.keyFor(trigger) : null;
280
+ const record = key ? popups.get(key) : undefined;
281
+ if (record) scheduleLeaveCheck(record);
282
+ });
283
+
284
+ document.addEventListener('click', (event) => {
285
+ const control = pinControlFrom(event.target);
286
+ if (control) {
287
+ const ownKey = config.keyFor(control);
288
+ if (ownKey) openPopup(ownKey, control, true);
289
+ return;
290
+ }
291
+
292
+ const trigger = config.triggerFrom(event.target);
293
+ if (trigger && !trigger.closest('a')) {
294
+ const key = config.keyFor(trigger);
295
+ if (key) {
296
+ event.preventDefault();
297
+ if (popups.get(key)?.pinned) {
298
+ closePopup(key);
299
+ } else {
300
+ openPopup(key, trigger, true);
301
+ }
302
+ return;
303
+ }
304
+ }
305
+ if (trigger) return; // a real link trigger: let it navigate.
306
+
307
+ // Anywhere else inside an open popup collapses just that one — its body
308
+ // is never a trigger, so a click there can only mean "I'm done with
309
+ // this." (The corner "×" falls under this same rule.) A real link
310
+ // inside it — "Open the full definition" — is excluded so it navigates.
311
+ const host = closestElement(event.target, '[data-popup-key]');
312
+ const hostKey = host?.getAttribute('data-popup-key');
313
+ if (
314
+ hostKey &&
315
+ event.target instanceof Node &&
316
+ !closestElement(event.target, 'a')
317
+ ) {
318
+ closePopup(hostKey);
319
+ }
320
+ });
321
+
322
+ // Deliberately no "click outside closes everything": the reader explicitly
323
+ // asked that clicking the equation itself — the unexplained parts included
324
+ // — never collapses a card. Closing is always a targeted gesture: the "×",
325
+ // a click on a card's own body, Escape, or (for a card that was only ever
326
+ // hovered, never pinned) the pointer simply leaving.
327
+
328
+ document.addEventListener('keydown', (event) => {
329
+ if (event.key !== 'Escape' || order.length === 0) return;
330
+ closePopup(order[order.length - 1]);
331
+ });
332
+
333
+ const repositionAll = () => {
334
+ for (const record of popups.values()) position(record);
335
+ };
336
+ window.addEventListener('resize', repositionAll, { passive: true });
337
+ window.addEventListener('scroll', repositionAll, {
338
+ passive: true,
339
+ capture: true,
340
+ });
341
+
342
+ return { closeAll };
343
+ }
344
+
345
+ export { closestElement };
@@ -9,10 +9,19 @@
9
9
  * `scroll-margin`.
10
10
  *
11
11
  * This script only adds what CSS and anchors cannot:
12
- * - re-triggering the highlight when the same `#eq-…` is activated again;
13
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;
14
16
  * - moving keyboard focus onto the equation so screen-reader users land on it.
15
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
+ *
16
25
  * Everything here is gated on `prefers-reduced-motion` and degrades to the
17
26
  * native anchor behaviour if it throws.
18
27
  */
@@ -55,14 +64,20 @@
55
64
  if (!href.startsWith('#eq-')) return; // cross-page: let the browser navigate
56
65
  const target = document.getElementById(href.slice(1));
57
66
  if (!target) return;
58
- event.preventDefault();
59
- if (location.hash !== href) history.pushState(null, '', href);
60
- activate(target, true);
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
+ }
61
76
  });
62
77
 
63
78
  window.addEventListener('hashchange', () => {
64
79
  if (!location.hash.startsWith('#eq-')) return;
65
- activate(document.getElementById(location.hash.slice(1)), false);
80
+ activate(document.getElementById(location.hash.slice(1)), true);
66
81
  });
67
82
 
68
83
  if (location.hash.startsWith('#eq-')) {
@@ -0,0 +1,131 @@
1
+ ---
2
+ /**
3
+ * Progressive enhancement for content-heading deep links (Step F3), the sibling
4
+ * of `EquationEnhancer.astro`.
5
+ *
6
+ * The no-JS baseline is complete: `rehype-heading-anchors` emits Starlight's
7
+ * `.sl-heading-wrapper` + `a.sl-anchor-link` markup, its bundled
8
+ * `anchor-links.css` does the hover/focus reveal, and `global.css` does the
9
+ * `:target` flash (with a `prefers-reduced-motion` fallback) and the
10
+ * sticky-header `scroll-margin`.
11
+ *
12
+ * This script only adds what CSS and anchors cannot:
13
+ * - a smooth, header-aware scroll on an in-page jump;
14
+ * - a brief highlight of the section body (the heading's following siblings up
15
+ * to the next heading of equal or higher rank), so a deep link lands the
16
+ * reader on the whole passage, not just the title;
17
+ * - re-triggering the highlight when the same `#…` is activated again —
18
+ * the one case a plain anchor click does nothing for, since the browser
19
+ * won't navigate to a fragment that's already current;
20
+ * - moving keyboard focus onto the heading so screen-reader users land on it.
21
+ *
22
+ * It deliberately does *not* intercept an ordinary click that changes the
23
+ * hash. `:target` is set only by a genuine same-document fragment
24
+ * navigation — never by `history.pushState` (a common trap: pushState
25
+ * updates the URL but leaves `:target`, and thus `heading-flash`, dark).
26
+ * Letting the click through and reacting to the `hashchange` it produces is
27
+ * what actually plays the highlight — see `EquationEnhancer.astro`, which
28
+ * this mirrors.
29
+ *
30
+ * Everything here is gated on `prefers-reduced-motion` and degrades to the
31
+ * native anchor behaviour if it throws.
32
+ */
33
+ ---
34
+
35
+ <script>
36
+ (() => {
37
+ const root = document.querySelector('.sl-markdown-content');
38
+ if (!root) return;
39
+
40
+ const HEADINGS = 'h1, h2, h3, h4, h5, h6';
41
+ const reduce = window.matchMedia('(prefers-reduced-motion: reduce)');
42
+
43
+ const isContentHeading = (el: Element | null): el is HTMLElement =>
44
+ !!el && el.matches(HEADINGS) && root.contains(el);
45
+
46
+ const headingForId = (raw: string): HTMLElement | null => {
47
+ if (!raw) return null;
48
+ let id = raw;
49
+ try {
50
+ id = decodeURIComponent(raw);
51
+ } catch {
52
+ /* keep the raw value */
53
+ }
54
+ const el = document.getElementById(id);
55
+ return isContentHeading(el) ? el : null;
56
+ };
57
+
58
+ // Matches `global.css`'s `heading-flash` animation-duration; the buffer
59
+ // avoids clipping the CSS animation's own fade-out.
60
+ const FLASH_MS = 5200;
61
+
62
+ const flash = (el: Element | null) => {
63
+ if (!el || reduce.matches) return;
64
+ el.classList.remove('deep-link-section-flash');
65
+ void (el as HTMLElement).offsetWidth;
66
+ el.classList.add('deep-link-section-flash');
67
+ window.setTimeout(
68
+ () => el.classList.remove('deep-link-section-flash'),
69
+ FLASH_MS,
70
+ );
71
+ };
72
+
73
+ const sectionParts = (heading: HTMLElement): Element[] => {
74
+ const wrapper = heading.closest('.sl-heading-wrapper') ?? heading;
75
+ const rank = Number(heading.tagName[1]);
76
+ const parts: Element[] = [wrapper];
77
+ let node = wrapper.nextElementSibling;
78
+ while (node) {
79
+ const inner = node.matches(HEADINGS)
80
+ ? node
81
+ : node.querySelector(HEADINGS);
82
+ if (inner && Number(inner.tagName[1]) <= rank) break;
83
+ parts.push(node);
84
+ node = node.nextElementSibling;
85
+ }
86
+ return parts;
87
+ };
88
+
89
+ const activate = (heading: HTMLElement | null, doScroll: boolean) => {
90
+ if (!heading) return;
91
+ const parts = sectionParts(heading);
92
+ if (doScroll) {
93
+ parts[0].scrollIntoView({
94
+ behavior: reduce.matches ? 'auto' : 'smooth',
95
+ block: 'start',
96
+ });
97
+ }
98
+ for (const part of parts) flash(part);
99
+ heading.setAttribute('tabindex', '-1');
100
+ try {
101
+ heading.focus({ preventScroll: true });
102
+ } catch {
103
+ heading.focus();
104
+ }
105
+ };
106
+
107
+ root.addEventListener('click', (event) => {
108
+ const origin = event.target instanceof Element ? event.target : null;
109
+ const link = origin?.closest('a.sl-anchor-link');
110
+ if (!link) return;
111
+ const href = link.getAttribute('href') || '';
112
+ if (!href.startsWith('#')) return;
113
+ const heading = headingForId(href.slice(1));
114
+ if (!heading) return;
115
+ // Only the already-on-this-anchor click needs handling here — see
116
+ // `EquationEnhancer.astro`. Every other click is left to the browser's
117
+ // own fragment navigation, picked up below via `hashchange`.
118
+ if (location.hash === href) {
119
+ event.preventDefault();
120
+ activate(heading, true);
121
+ }
122
+ });
123
+
124
+ window.addEventListener('hashchange', () => {
125
+ activate(headingForId(location.hash.slice(1)), true);
126
+ });
127
+
128
+ const initial = headingForId(location.hash.slice(1));
129
+ if (initial) activate(initial, true);
130
+ })();
131
+ </script>
@@ -19,6 +19,7 @@ import NotationLayer, {
19
19
  } from '../notation/NotationLayer.astro';
20
20
  import CitationLayer from '../citation/CitationLayer.astro';
21
21
  import EquationEnhancer from './EquationEnhancer.astro';
22
+ import HeadingAnchorEnhancer from './HeadingAnchorEnhancer.astro';
22
23
 
23
24
  // `lessonId` is no longer authored (Phase C2); derive it from the route.
24
25
  const routeSlug = Astro.locals.starlightRoute.entry.id;
@@ -39,6 +40,8 @@ if (lessonId) {
39
40
  label: definition.label,
40
41
  summary: definition.summary,
41
42
  formula: definition.formula,
43
+ formulaScope: definition.formulaScope,
44
+ glosses: definition.glosses,
42
45
  units: definition.units,
43
46
  seeAlso: definition.seeAlso,
44
47
  editorialStatus: definition.editorialStatus,
@@ -55,4 +58,5 @@ if (lessonId) {
55
58
  <NotationLayer definitions={definitions} />
56
59
  {lessonId && <CitationLayer />}
57
60
  {lessonId && <EquationEnhancer />}
61
+ {lessonId && <HeadingAnchorEnhancer />}
58
62
  <DefaultFooter />
@@ -3,6 +3,7 @@ import { glob } from 'astro/loaders';
3
3
  import { z } from 'astro/zod';
4
4
  import { docsSchema } from '@astrojs/starlight/schema';
5
5
  import { isSubstantiveMeaning } from './reference/prose';
6
+ import { glossKeySegment } from './reference/gloss';
6
7
 
7
8
  // Phase F: content lives in the top-level `content/` folder, not `src/content/`.
8
9
  // `docsLoader()` is hard-wired to `<srcDir>/content/docs`, so the `docs`
@@ -71,6 +72,42 @@ const notationSource = z.union([
71
72
  * `label` is authored only where that reads wrong. The `notationProse`
72
73
  * refinement rejects an under-specified `meaning`.
73
74
  */
75
+ /**
76
+ * A **gloss** on a notation card: a symbol the card's own `formula` needs in
77
+ * order to be written down, carrying a name and nothing else (D15).
78
+ *
79
+ * A gloss is deliberately cheaper than a card. It has no description, no
80
+ * sources, no curriculum alignment, no review state, and no glossary card of
81
+ * its own; it is not a node in the curriculum graph and never creates a
82
+ * reference edge. Its key is derived, not authored — `<card key>.<slug(name)>`
83
+ * — so a gloss is scoped to the card that declares it and two cards may each
84
+ * gloss `T`. Because a formula spells LaTeX and never a key, **promoting** a
85
+ * gloss is exactly: delete this line, add `content/notation/<key>.md`.
86
+ *
87
+ * `units` is optional and stays a bare unit string: "T — time" is ambiguous in
88
+ * a corpus with several `T`s, "T — time, years from valuation" is not, and it
89
+ * is still one line. Prose belongs on a card.
90
+ */
91
+ const notationGloss = z.object({
92
+ latex: z.string().min(1),
93
+ name: z
94
+ .string()
95
+ .min(1)
96
+ .refine((value) => glossKeySegment(value) !== undefined, {
97
+ message:
98
+ 'A gloss name must contain letters or digits so a key can be derived from it.',
99
+ })
100
+ // The symbol goes in `latex`, the name names it. A name carrying LaTeX is
101
+ // the one unambiguous way to get this field wrong, so it blocks; a name
102
+ // that is merely long is a Tier-3 `gloss-name-shape` warning instead,
103
+ // because a complicated quantity may honestly need several words.
104
+ .refine((value) => !/[\\$`\n]/.test(value), {
105
+ message:
106
+ 'A gloss name is a plain noun phrase; put the symbol in `latex`, not in the name.',
107
+ }),
108
+ units: z.string().min(1).optional(),
109
+ });
110
+
74
111
  const notationEntryShape = z.object({
75
112
  key: id,
76
113
  latex: z.string().min(1),
@@ -82,6 +119,7 @@ const notationEntryShape = z.object({
82
119
  sources: z.array(notationSource).default([]),
83
120
  alignment: notationAlignmentDefault,
84
121
  label: z.string().min(1).optional(),
122
+ glosses: z.array(notationGloss).default([]),
85
123
  });
86
124
 
87
125
  const localNotationDefinition = notationEntryShape;