explico 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +102 -0
- package/README.md +46 -5
- package/dist/compiler/cli.d.ts +17 -0
- package/dist/compiler/cli.js +41 -3
- package/dist/compiler/collections.js +31 -1
- package/dist/compiler/course-config.d.ts +52 -1
- package/dist/compiler/knowledge-version.d.ts +58 -0
- package/dist/compiler/knowledge-version.js +80 -0
- package/dist/compiler/manifest.d.ts +87 -3
- package/dist/compiler/manifest.js +133 -20
- package/dist/compiler/resolution-report.d.ts +5 -0
- package/dist/reference/base-operators.d.mts +38 -0
- package/dist/reference/base-operators.mjs +103 -0
- package/dist/reference/consistency.d.ts +7 -2
- package/dist/reference/consistency.js +142 -2
- package/dist/reference/diagram-layout.d.ts +63 -0
- package/dist/reference/diagram-layout.js +122 -0
- package/dist/reference/equations-validate.d.ts +5 -0
- package/dist/reference/equations-validate.js +1 -1
- package/dist/reference/equations.d.mts +76 -18
- package/dist/reference/equations.mjs +118 -41
- package/dist/reference/figures-validate.d.ts +21 -0
- package/dist/reference/figures-validate.js +13 -0
- 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/keyed-float-validate.d.ts +67 -0
- package/dist/reference/keyed-float-validate.js +110 -0
- package/dist/reference/keyed-jsx-floats.d.mts +56 -0
- package/dist/reference/keyed-jsx-floats.mjs +167 -0
- package/dist/reference/math-glyphs.d.mts +18 -0
- package/dist/reference/math-glyphs.mjs +92 -0
- package/dist/reference/registry.js +25 -0
- package/dist/reference/rehype-heading-anchors.d.mts +1 -0
- package/dist/reference/rehype-heading-anchors.mjs +144 -0
- package/dist/reference/remark-notation.d.mts +12 -0
- package/dist/reference/remark-notation.mjs +228 -32
- package/dist/reference/tables-validate.d.ts +21 -0
- package/dist/reference/tables-validate.js +13 -0
- package/dist/reference/types.d.ts +25 -2
- package/dist/reference/units-vocab.js +19 -1
- package/docs/VERSIONING.md +66 -0
- package/docs/authoring/README.md +96 -0
- package/docs/authoring/agents.md +63 -0
- package/docs/authoring/migrating.md +107 -0
- package/docs/authoring/notation.md +296 -0
- package/docs/authoring/versioning.md +116 -0
- package/docs/authoring/visualizations.md +195 -0
- package/package.json +20 -5
- package/src/compiler/cli.ts +49 -0
- package/src/compiler/collections.ts +52 -3
- package/src/compiler/course-config.ts +54 -0
- package/src/compiler/knowledge-version.ts +157 -0
- package/src/compiler/manifest.ts +270 -17
- package/src/components/CurriculumMap.astro +27 -0
- package/src/components/HoverPanel.astro +42 -0
- package/src/components/citation/CitationLayer.astro +1 -0
- package/src/components/diagram/Diagram.astro +317 -0
- package/src/components/examples/CompactExamples.astro +1 -1
- package/src/components/figure/Figure.astro +104 -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 +66 -0
- package/src/components/popup-stack.ts +345 -0
- package/src/components/reference/FloatRefLayer.astro +671 -0
- package/src/components/starlight/AboutPanel.astro +255 -0
- package/src/components/starlight/HeadingAnchorEnhancer.astro +131 -0
- package/src/components/starlight/KeyedFloatEnhancer.astro +137 -0
- package/src/components/starlight/LayoutSocialIcons.astro +12 -0
- package/src/components/starlight/LessonFooter.astro +8 -2
- package/src/components/table/Table.astro +104 -0
- package/src/content-config.ts +38 -0
- package/src/reference/base-operators.mjs +103 -0
- package/src/reference/consistency.ts +236 -2
- package/src/reference/diagram-layout.ts +246 -0
- package/src/reference/equations-validate.ts +6 -1
- package/src/reference/equations.mjs +118 -41
- package/src/reference/figures-validate.ts +40 -0
- package/src/reference/gate-math.ts +104 -7
- package/src/reference/gloss.ts +153 -0
- package/src/reference/keyed-float-validate.ts +207 -0
- package/src/reference/keyed-jsx-floats.mjs +167 -0
- package/src/reference/math-glyphs.mjs +92 -0
- package/src/reference/registry.ts +43 -0
- package/src/reference/rehype-heading-anchors.mjs +144 -0
- package/src/reference/remark-notation.mjs +228 -32
- package/src/reference/tables-validate.ts +40 -0
- package/src/reference/types.ts +26 -1
- package/src/reference/units-vocab.ts +18 -0
- package/src/styles/global.css +193 -3
- package/src/components/starlight/EquationEnhancer.astro +0 -72
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* A small directed diagram whose node math is checked and explained exactly
|
|
4
|
+
* like lesson body math (Dagre for build-time layout, no runtime layout
|
|
5
|
+
* library shipped; KaTeX through the same trust boundary as everywhere
|
|
6
|
+
* else).
|
|
7
|
+
*
|
|
8
|
+
* Each node is plain `text`, a `formula` (raw LaTeX resolved against
|
|
9
|
+
* `scope` — the same page glyph table `NotationFormula` uses), or both. A
|
|
10
|
+
* formula's every symbol must resolve: an unresolved glyph fails the build
|
|
11
|
+
* with the offending node id, the same severity the completeness gate gives
|
|
12
|
+
* lesson body math — this component exists specifically so diagram math is
|
|
13
|
+
* never a silent exception to that rule.
|
|
14
|
+
*
|
|
15
|
+
* The rendered symbols carry the same `data-notation-key` markers as prose
|
|
16
|
+
* and equations, so they become working hover/pin triggers with **no new
|
|
17
|
+
* client script**: `NotationLayer`'s trigger scan already queries the whole
|
|
18
|
+
* document, not just its own subtree.
|
|
19
|
+
*
|
|
20
|
+
* Box sizes are a generous estimate (`reference/diagram-layout.ts`) unless a
|
|
21
|
+
* node gives an explicit `width`/`height` — there is no DOM at build time to
|
|
22
|
+
* measure a real render against.
|
|
23
|
+
*/
|
|
24
|
+
import { estimateBoxSize, layoutDiagram } from '../../reference/diagram-layout';
|
|
25
|
+
import { renderNotationFormula } from '../notation/render-notation-math';
|
|
26
|
+
import type { SafeMathNode as SafeMathNodeType } from '../notation/render-notation-math';
|
|
27
|
+
import SafeMathNode from '../notation/SafeMathNode.astro';
|
|
28
|
+
import type { GlyphScopeEntry } from '../../reference/gloss';
|
|
29
|
+
|
|
30
|
+
export interface DiagramNode {
|
|
31
|
+
readonly id: string;
|
|
32
|
+
/** Plain prose caption, e.g. "Decompose velocity". */
|
|
33
|
+
readonly text?: string;
|
|
34
|
+
/** Raw LaTeX, resolved against `scope` before rendering. */
|
|
35
|
+
readonly formula?: string;
|
|
36
|
+
/** Overrides the size estimate — use for a label the heuristic gets wrong. */
|
|
37
|
+
readonly width?: number;
|
|
38
|
+
readonly height?: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface DiagramEdge {
|
|
42
|
+
readonly from: string;
|
|
43
|
+
readonly to: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface Props {
|
|
47
|
+
/** Unique on the page — namespaces the arrowhead marker id. */
|
|
48
|
+
id: string;
|
|
49
|
+
/** Visible caption; also the diagram's accessible name. */
|
|
50
|
+
label: string;
|
|
51
|
+
nodes: readonly DiagramNode[];
|
|
52
|
+
edges: readonly DiagramEdge[];
|
|
53
|
+
/** Glyph scope `formula` resolves against — omit if no node uses `formula`. */
|
|
54
|
+
scope?: readonly GlyphScopeEntry[];
|
|
55
|
+
direction?: 'TB' | 'LR';
|
|
56
|
+
/**
|
|
57
|
+
* Prose text alternative for the SVG. When omitted, the fallback is
|
|
58
|
+
* generated from the node labels and the edge chain — readable, but a
|
|
59
|
+
* hand-written sentence usually reads better for a screen-reader user.
|
|
60
|
+
*/
|
|
61
|
+
description?: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const {
|
|
65
|
+
id,
|
|
66
|
+
label,
|
|
67
|
+
nodes,
|
|
68
|
+
edges,
|
|
69
|
+
scope = [],
|
|
70
|
+
direction = 'TB',
|
|
71
|
+
description,
|
|
72
|
+
} = Astro.props;
|
|
73
|
+
|
|
74
|
+
const idPattern = /^[A-Za-z][A-Za-z0-9_-]*$/;
|
|
75
|
+
if (!idPattern.test(id)) {
|
|
76
|
+
throw new Error(`Unsafe Diagram id: ${id}`);
|
|
77
|
+
}
|
|
78
|
+
if (nodes.length === 0) {
|
|
79
|
+
throw new Error(`Diagram "${id}" has no nodes.`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
interface RenderedNode extends DiagramNode {
|
|
83
|
+
readonly mathNodes?: readonly SafeMathNodeType[];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const rendered: RenderedNode[] = nodes.map((node) => {
|
|
87
|
+
if (!node.text && !node.formula) {
|
|
88
|
+
throw new Error(`Diagram "${id}" node "${node.id}" has neither text nor formula.`);
|
|
89
|
+
}
|
|
90
|
+
if (!node.formula) return { ...node };
|
|
91
|
+
|
|
92
|
+
const { nodes: mathNodes, unresolved, degraded } = renderNotationFormula(
|
|
93
|
+
node.formula,
|
|
94
|
+
scope,
|
|
95
|
+
);
|
|
96
|
+
if (degraded) {
|
|
97
|
+
throw new Error(
|
|
98
|
+
`Diagram "${id}" node "${node.id}" formula "${node.formula}" could not be ` +
|
|
99
|
+
`bound to the notation scope: the LaTeX did not parse, or a bare glyph is ` +
|
|
100
|
+
`the base of more than one page symbol. Wrap each symbol in ` +
|
|
101
|
+
`\\explain{key}{latex}, or brace a subscripted base (write {v_0}^{2}, not ` +
|
|
102
|
+
`v_0^{2}), so every glyph resolves — a diagram formula is never allowed to ` +
|
|
103
|
+
`render as silent, unexplained math.`,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
if (unresolved.length > 0) {
|
|
107
|
+
throw new Error(
|
|
108
|
+
`Diagram "${id}" node "${node.id}" formula uses undefined notation ${unresolved
|
|
109
|
+
.map((token) => `"${token}"`)
|
|
110
|
+
.join(', ')}. Introduce the symbol with [[key]], define it in notation.local, ` +
|
|
111
|
+
`or pass it in the diagram's scope.`,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
return { ...node, mathNodes };
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const sized = rendered.map((node) => ({
|
|
118
|
+
node,
|
|
119
|
+
box:
|
|
120
|
+
node.width && node.height
|
|
121
|
+
? { width: node.width, height: node.height }
|
|
122
|
+
: estimateBoxSize(
|
|
123
|
+
node.formula ?? node.text ?? node.id,
|
|
124
|
+
node.formula ? 'formula' : 'text',
|
|
125
|
+
),
|
|
126
|
+
}));
|
|
127
|
+
|
|
128
|
+
const layout = layoutDiagram(
|
|
129
|
+
sized.map(({ node, box }) => ({ id: node.id, width: box.width, height: box.height })),
|
|
130
|
+
edges.map((edge) => ({ from: edge.from, to: edge.to })),
|
|
131
|
+
{ direction },
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
const nodeById = new Map(rendered.map((node) => [node.id, node]));
|
|
135
|
+
const laidOutById = new Map(layout.nodes.map((node) => [node.id, node]));
|
|
136
|
+
const markerId = `diagram-arrow-${id}`;
|
|
137
|
+
|
|
138
|
+
/** A node's human label for the text fallback — never the internal id. */
|
|
139
|
+
function fallbackLabel(nodeId: string): string {
|
|
140
|
+
const node = nodeById.get(nodeId);
|
|
141
|
+
return node?.text ?? node?.formula ?? nodeId;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function pathFrom(points: readonly { x: number; y: number }[]): string {
|
|
145
|
+
if (points.length === 0) return '';
|
|
146
|
+
const [first, ...rest] = points;
|
|
147
|
+
return `M ${first!.x} ${first!.y} ` + rest.map((point) => `L ${point.x} ${point.y}`).join(' ');
|
|
148
|
+
}
|
|
149
|
+
---
|
|
150
|
+
|
|
151
|
+
<figure class="diagram" id={`diagram-${id}`}>
|
|
152
|
+
<figcaption id={`${id}-caption`}>{label}</figcaption>
|
|
153
|
+
<div class="diagram-scroll" tabindex="0" role="group" aria-labelledby={`${id}-caption`}>
|
|
154
|
+
<svg
|
|
155
|
+
viewBox={`0 0 ${layout.width} ${layout.height}`}
|
|
156
|
+
width={layout.width}
|
|
157
|
+
height={layout.height}
|
|
158
|
+
>
|
|
159
|
+
<defs>
|
|
160
|
+
<marker
|
|
161
|
+
id={markerId}
|
|
162
|
+
viewBox="0 0 10 10"
|
|
163
|
+
refX="9"
|
|
164
|
+
refY="5"
|
|
165
|
+
markerWidth="7"
|
|
166
|
+
markerHeight="7"
|
|
167
|
+
orient="auto-start-reverse"
|
|
168
|
+
>
|
|
169
|
+
<path d="M 0 0 L 10 5 L 0 10 z" class="diagram-arrowhead" />
|
|
170
|
+
</marker>
|
|
171
|
+
</defs>
|
|
172
|
+
|
|
173
|
+
{
|
|
174
|
+
layout.edges.map((edge) => (
|
|
175
|
+
<path
|
|
176
|
+
d={pathFrom(edge.points)}
|
|
177
|
+
class="diagram-edge"
|
|
178
|
+
marker-end={`url(#${markerId})`}
|
|
179
|
+
/>
|
|
180
|
+
))
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
{
|
|
184
|
+
layout.nodes.map((position) => {
|
|
185
|
+
const node = nodeById.get(position.id)!;
|
|
186
|
+
return (
|
|
187
|
+
<g transform={`translate(${position.x}, ${position.y})`} class="diagram-node">
|
|
188
|
+
<rect width={position.width} height={position.height} rx="8" ry="8" />
|
|
189
|
+
<foreignObject width={position.width} height={position.height}>
|
|
190
|
+
<div xmlns="http://www.w3.org/1999/xhtml" class="diagram-node-content">
|
|
191
|
+
{node.text && <span class="diagram-node-text">{node.text}</span>}
|
|
192
|
+
{node.mathNodes && (
|
|
193
|
+
<span class="diagram-node-formula notation-math">
|
|
194
|
+
{node.mathNodes.map((mathNode) => (
|
|
195
|
+
<SafeMathNode node={mathNode} />
|
|
196
|
+
))}
|
|
197
|
+
</span>
|
|
198
|
+
)}
|
|
199
|
+
</div>
|
|
200
|
+
</foreignObject>
|
|
201
|
+
</g>
|
|
202
|
+
);
|
|
203
|
+
})
|
|
204
|
+
}
|
|
205
|
+
</svg>
|
|
206
|
+
</div>
|
|
207
|
+
|
|
208
|
+
<details class="diagram-fallback">
|
|
209
|
+
<summary>Diagram description</summary>
|
|
210
|
+
{
|
|
211
|
+
description ? (
|
|
212
|
+
<p>{description}</p>
|
|
213
|
+
) : (
|
|
214
|
+
<Fragment>
|
|
215
|
+
<ol>
|
|
216
|
+
{rendered.map((node) => (
|
|
217
|
+
<li>{fallbackLabel(node.id)}</li>
|
|
218
|
+
))}
|
|
219
|
+
</ol>
|
|
220
|
+
{edges.length > 0 && (
|
|
221
|
+
<p class="diagram-fallback-flow">
|
|
222
|
+
{edges.map((edge, index) => (
|
|
223
|
+
<Fragment>
|
|
224
|
+
{index > 0 && '; '}
|
|
225
|
+
{fallbackLabel(edge.from)} → {fallbackLabel(edge.to)}
|
|
226
|
+
</Fragment>
|
|
227
|
+
))}
|
|
228
|
+
</p>
|
|
229
|
+
)}
|
|
230
|
+
</Fragment>
|
|
231
|
+
)
|
|
232
|
+
}
|
|
233
|
+
</details>
|
|
234
|
+
</figure>
|
|
235
|
+
|
|
236
|
+
<style>
|
|
237
|
+
@layer starlight.components {
|
|
238
|
+
.diagram {
|
|
239
|
+
margin: 1.5rem 0;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
.diagram figcaption {
|
|
243
|
+
margin-block-end: 0.5rem;
|
|
244
|
+
font-weight: 600;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/* A wide graph scrolls inside its own box rather than shrinking to
|
|
248
|
+
illegibility — the same tradeoff `.notation-formula-math` makes for a
|
|
249
|
+
wide equation. `tabindex` on the scroller is the accessibility half of
|
|
250
|
+
that: a scrollable region a keyboard user cannot reach is unreachable
|
|
251
|
+
content, not just an awkward one. */
|
|
252
|
+
.diagram-scroll {
|
|
253
|
+
overflow-x: auto;
|
|
254
|
+
padding-block: 0.15rem;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
.diagram-scroll:focus-visible {
|
|
258
|
+
outline: 3px solid var(--sl-color-accent);
|
|
259
|
+
outline-offset: 2px;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
.diagram svg {
|
|
263
|
+
display: block;
|
|
264
|
+
overflow: visible;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
.diagram-node rect {
|
|
268
|
+
fill: var(--sl-color-gray-7);
|
|
269
|
+
stroke: var(--sl-color-gray-5);
|
|
270
|
+
stroke-width: 1;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
.diagram-node-content {
|
|
274
|
+
display: flex;
|
|
275
|
+
height: 100%;
|
|
276
|
+
flex-direction: column;
|
|
277
|
+
align-items: center;
|
|
278
|
+
justify-content: center;
|
|
279
|
+
gap: 0.15rem;
|
|
280
|
+
padding: 0.2rem 0.5rem;
|
|
281
|
+
color: var(--sl-color-text);
|
|
282
|
+
font-family: var(--sl-font);
|
|
283
|
+
font-size: 0.85rem;
|
|
284
|
+
line-height: 1.25;
|
|
285
|
+
text-align: center;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
.diagram-node-formula :global(.katex) {
|
|
289
|
+
font-size: 1em;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
.diagram-edge {
|
|
293
|
+
fill: none;
|
|
294
|
+
stroke: var(--sl-color-gray-4);
|
|
295
|
+
stroke-width: 1.5;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
.diagram-arrowhead {
|
|
299
|
+
fill: var(--sl-color-gray-4);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
.diagram-fallback {
|
|
303
|
+
margin-block-start: 0.5rem;
|
|
304
|
+
color: var(--sl-color-gray-3);
|
|
305
|
+
font-size: var(--sl-text-sm);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
.diagram-fallback ol,
|
|
309
|
+
.diagram-fallback p {
|
|
310
|
+
margin: 0.35rem 0 0;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
.diagram-fallback ol {
|
|
314
|
+
padding-inline-start: 1.25rem;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
</style>
|
|
@@ -161,7 +161,7 @@ const {
|
|
|
161
161
|
this.dataset.enhanced = 'true';
|
|
162
162
|
const instanceId = `compact-examples-${CompactExamplesElement.nextId++}`;
|
|
163
163
|
const tablist = document.createElement('div');
|
|
164
|
-
tablist.className = 'compact-examples__tabs';
|
|
164
|
+
tablist.className = 'compact-examples__tabs not-content';
|
|
165
165
|
tablist.setAttribute('role', 'tablist');
|
|
166
166
|
tablist.setAttribute(
|
|
167
167
|
'aria-label',
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* A referenceable figure (F-tables-figures) — static image, inline SVG, a
|
|
4
|
+
* `Diagram`, or a hydrated React lab — numbered and anchored the same way
|
|
5
|
+
* `<Table>` is; see `Table.astro` for the full identity model, which this
|
|
6
|
+
* mirrors exactly (separate counter, same `[[fig-<id>]]` / cross-page
|
|
7
|
+
* reference grammar, `number`/`anchorId` computed by `remark-notation.mjs`).
|
|
8
|
+
*
|
|
9
|
+
* `previewable` (default `true`) controls what the hover-preview card
|
|
10
|
+
* (`FloatRefLayer.astro`) shows for a `[[fig-<id>]]` reference elsewhere in
|
|
11
|
+
* the prose: `true` clones this figure's visual into the card alongside its
|
|
12
|
+
* caption; `false` shows the caption and number only. Set it `false` for
|
|
13
|
+
* interactive content — a hydrated `client:*` island cloned into a floating
|
|
14
|
+
* card would be an inert, non-hydrated, id-colliding duplicate, not a
|
|
15
|
+
* working preview.
|
|
16
|
+
*/
|
|
17
|
+
export interface Props {
|
|
18
|
+
/** Stable key, e.g. `id="projectile-trajectory"`. Omit for a positional-only figure. */
|
|
19
|
+
id?: string;
|
|
20
|
+
/** Visible caption, shown below the figure and in the hover-preview card. */
|
|
21
|
+
caption: string;
|
|
22
|
+
/** `false` for interactive content the hover-preview must not clone. */
|
|
23
|
+
previewable?: boolean;
|
|
24
|
+
/** Computed by `remark-notation.mjs` — never author this directly. */
|
|
25
|
+
number?: string;
|
|
26
|
+
/** Computed by `remark-notation.mjs` — never author this directly. */
|
|
27
|
+
anchorId?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const { id, caption, previewable = true, number, anchorId } = Astro.props;
|
|
31
|
+
|
|
32
|
+
if (typeof caption !== 'string' || !caption.trim()) {
|
|
33
|
+
throw new Error('Figure requires a non-empty `caption` prop.');
|
|
34
|
+
}
|
|
35
|
+
if (!number || !anchorId) {
|
|
36
|
+
throw new Error(
|
|
37
|
+
'Figure is missing its computed `number`/`anchorId` — this page must run remark-notation with a `figures` cross-page table configured (see astro.config.mjs).',
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
<figure
|
|
43
|
+
class="keyed-figure"
|
|
44
|
+
id={anchorId}
|
|
45
|
+
data-fig-number={number}
|
|
46
|
+
data-fig-key={id}
|
|
47
|
+
data-fig-previewable={String(previewable)}
|
|
48
|
+
>
|
|
49
|
+
<div class="keyed-figure__body">
|
|
50
|
+
<slot />
|
|
51
|
+
</div>
|
|
52
|
+
<figcaption class="keyed-figure__caption">
|
|
53
|
+
<a
|
|
54
|
+
class="keyed-figure__number"
|
|
55
|
+
href={`#${anchorId}`}
|
|
56
|
+
aria-label={`Figure ${number}`}>Figure {number}</a
|
|
57
|
+
>
|
|
58
|
+
<span class="keyed-figure__caption-text">{caption}</span>
|
|
59
|
+
</figcaption>
|
|
60
|
+
</figure>
|
|
61
|
+
|
|
62
|
+
<style>
|
|
63
|
+
.keyed-figure {
|
|
64
|
+
margin-block: 1.5rem;
|
|
65
|
+
padding: 0;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.keyed-figure__body {
|
|
69
|
+
overflow-x: auto;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.keyed-figure__caption {
|
|
73
|
+
margin-block-start: 0.5rem;
|
|
74
|
+
font-size: var(--sl-text-sm);
|
|
75
|
+
color: var(--sl-color-gray-3);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.keyed-figure__number {
|
|
79
|
+
font-weight: 600;
|
|
80
|
+
font-variant-numeric: tabular-nums;
|
|
81
|
+
color: var(--sl-color-text-accent);
|
|
82
|
+
text-decoration: none;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
.keyed-figure__number:hover,
|
|
86
|
+
.keyed-figure__number:focus-visible {
|
|
87
|
+
text-decoration: underline;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
.keyed-figure__number:focus-visible {
|
|
91
|
+
outline: 2px solid var(--sl-color-accent);
|
|
92
|
+
outline-offset: 0.15rem;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
.keyed-figure__caption-text::before {
|
|
96
|
+
content: ' — ';
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
@media print {
|
|
100
|
+
.keyed-figure__number {
|
|
101
|
+
color: inherit;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
</style>
|
|
@@ -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 (
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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>
|