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.
- package/dist/compiler/cli.d.ts +1 -0
- package/dist/compiler/cli.js +1 -1
- package/dist/compiler/collections.js +30 -3
- package/dist/compiler/manifest.d.ts +10 -1
- package/dist/compiler/manifest.js +28 -12
- package/dist/compiler/resolution-report.d.ts +1 -0
- package/dist/compiler/resolution-report.js +2 -2
- package/dist/reference/base-library.d.mts +17 -0
- package/dist/reference/citation-format.d.mts +55 -0
- package/dist/reference/consistency.d.ts +1 -1
- package/dist/reference/consistency.js +94 -3
- package/dist/reference/curriculum-alignment.js +1 -1
- package/dist/reference/equations.d.mts +84 -0
- package/dist/reference/equations.mjs +75 -39
- package/dist/reference/gate-math.d.ts +5 -4
- package/dist/reference/gate-math.js +73 -2
- package/dist/reference/gloss.d.ts +87 -0
- package/dist/reference/gloss.js +59 -0
- package/dist/reference/katex-options.d.mts +33 -0
- package/dist/reference/lab-math-scope.js +1 -1
- package/dist/reference/math-glyphs.d.mts +46 -0
- package/dist/reference/registry.js +26 -1
- package/dist/reference/rehype-fail-katex-errors.d.mts +6 -0
- package/dist/reference/rehype-heading-anchors.d.mts +1 -0
- package/dist/reference/rehype-heading-anchors.mjs +144 -0
- package/dist/reference/remark-citation.d.mts +6 -0
- package/dist/reference/remark-notation.d.mts +49 -0
- package/dist/reference/remark-notation.mjs +30 -24
- package/dist/reference/types.d.ts +25 -2
- package/dist/session/user-context.js +1 -1
- package/package.json +2 -2
- package/src/compiler/collections.ts +40 -0
- package/src/compiler/manifest.ts +31 -0
- package/src/components/HoverPanel.astro +42 -0
- package/src/components/citation/CitationLayer.astro +1 -0
- package/src/components/hover-panel.ts +71 -5
- package/src/components/notation/NotationFormula.astro +154 -0
- package/src/components/notation/NotationGlossary.astro +60 -0
- package/src/components/notation/NotationLayer.astro +248 -103
- package/src/components/notation/render-notation-math.ts +55 -0
- package/src/components/popup-stack.ts +345 -0
- package/src/components/starlight/EquationEnhancer.astro +20 -5
- package/src/components/starlight/HeadingAnchorEnhancer.astro +131 -0
- package/src/components/starlight/LessonFooter.astro +4 -0
- package/src/content-config.ts +38 -0
- package/src/reference/consistency.ts +154 -1
- package/src/reference/equations.mjs +75 -39
- package/src/reference/gate-math.ts +104 -7
- package/src/reference/gloss.ts +153 -0
- package/src/reference/registry.ts +43 -0
- package/src/reference/rehype-heading-anchors.mjs +144 -0
- package/src/reference/remark-notation.mjs +30 -24
- package/src/reference/types.ts +26 -1
- 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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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)),
|
|
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 />
|
package/src/content-config.ts
CHANGED
|
@@ -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;
|