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.
Files changed (94) hide show
  1. package/CHANGELOG.md +102 -0
  2. package/README.md +46 -5
  3. package/dist/compiler/cli.d.ts +17 -0
  4. package/dist/compiler/cli.js +41 -3
  5. package/dist/compiler/collections.js +31 -1
  6. package/dist/compiler/course-config.d.ts +52 -1
  7. package/dist/compiler/knowledge-version.d.ts +58 -0
  8. package/dist/compiler/knowledge-version.js +80 -0
  9. package/dist/compiler/manifest.d.ts +87 -3
  10. package/dist/compiler/manifest.js +133 -20
  11. package/dist/compiler/resolution-report.d.ts +5 -0
  12. package/dist/reference/base-operators.d.mts +38 -0
  13. package/dist/reference/base-operators.mjs +103 -0
  14. package/dist/reference/consistency.d.ts +7 -2
  15. package/dist/reference/consistency.js +142 -2
  16. package/dist/reference/diagram-layout.d.ts +63 -0
  17. package/dist/reference/diagram-layout.js +122 -0
  18. package/dist/reference/equations-validate.d.ts +5 -0
  19. package/dist/reference/equations-validate.js +1 -1
  20. package/dist/reference/equations.d.mts +76 -18
  21. package/dist/reference/equations.mjs +118 -41
  22. package/dist/reference/figures-validate.d.ts +21 -0
  23. package/dist/reference/figures-validate.js +13 -0
  24. package/dist/reference/gate-math.d.ts +5 -4
  25. package/dist/reference/gate-math.js +73 -2
  26. package/dist/reference/gloss.d.ts +87 -0
  27. package/dist/reference/gloss.js +59 -0
  28. package/dist/reference/keyed-float-validate.d.ts +67 -0
  29. package/dist/reference/keyed-float-validate.js +110 -0
  30. package/dist/reference/keyed-jsx-floats.d.mts +56 -0
  31. package/dist/reference/keyed-jsx-floats.mjs +167 -0
  32. package/dist/reference/math-glyphs.d.mts +18 -0
  33. package/dist/reference/math-glyphs.mjs +92 -0
  34. package/dist/reference/registry.js +25 -0
  35. package/dist/reference/rehype-heading-anchors.d.mts +1 -0
  36. package/dist/reference/rehype-heading-anchors.mjs +144 -0
  37. package/dist/reference/remark-notation.d.mts +12 -0
  38. package/dist/reference/remark-notation.mjs +228 -32
  39. package/dist/reference/tables-validate.d.ts +21 -0
  40. package/dist/reference/tables-validate.js +13 -0
  41. package/dist/reference/types.d.ts +25 -2
  42. package/dist/reference/units-vocab.js +19 -1
  43. package/docs/VERSIONING.md +66 -0
  44. package/docs/authoring/README.md +96 -0
  45. package/docs/authoring/agents.md +63 -0
  46. package/docs/authoring/migrating.md +107 -0
  47. package/docs/authoring/notation.md +296 -0
  48. package/docs/authoring/versioning.md +116 -0
  49. package/docs/authoring/visualizations.md +195 -0
  50. package/package.json +20 -5
  51. package/src/compiler/cli.ts +49 -0
  52. package/src/compiler/collections.ts +52 -3
  53. package/src/compiler/course-config.ts +54 -0
  54. package/src/compiler/knowledge-version.ts +157 -0
  55. package/src/compiler/manifest.ts +270 -17
  56. package/src/components/CurriculumMap.astro +27 -0
  57. package/src/components/HoverPanel.astro +42 -0
  58. package/src/components/citation/CitationLayer.astro +1 -0
  59. package/src/components/diagram/Diagram.astro +317 -0
  60. package/src/components/examples/CompactExamples.astro +1 -1
  61. package/src/components/figure/Figure.astro +104 -0
  62. package/src/components/hover-panel.ts +71 -5
  63. package/src/components/notation/NotationFormula.astro +154 -0
  64. package/src/components/notation/NotationGlossary.astro +60 -0
  65. package/src/components/notation/NotationLayer.astro +248 -103
  66. package/src/components/notation/render-notation-math.ts +66 -0
  67. package/src/components/popup-stack.ts +345 -0
  68. package/src/components/reference/FloatRefLayer.astro +671 -0
  69. package/src/components/starlight/AboutPanel.astro +255 -0
  70. package/src/components/starlight/HeadingAnchorEnhancer.astro +131 -0
  71. package/src/components/starlight/KeyedFloatEnhancer.astro +137 -0
  72. package/src/components/starlight/LayoutSocialIcons.astro +12 -0
  73. package/src/components/starlight/LessonFooter.astro +8 -2
  74. package/src/components/table/Table.astro +104 -0
  75. package/src/content-config.ts +38 -0
  76. package/src/reference/base-operators.mjs +103 -0
  77. package/src/reference/consistency.ts +236 -2
  78. package/src/reference/diagram-layout.ts +246 -0
  79. package/src/reference/equations-validate.ts +6 -1
  80. package/src/reference/equations.mjs +118 -41
  81. package/src/reference/figures-validate.ts +40 -0
  82. package/src/reference/gate-math.ts +104 -7
  83. package/src/reference/gloss.ts +153 -0
  84. package/src/reference/keyed-float-validate.ts +207 -0
  85. package/src/reference/keyed-jsx-floats.mjs +167 -0
  86. package/src/reference/math-glyphs.mjs +92 -0
  87. package/src/reference/registry.ts +43 -0
  88. package/src/reference/rehype-heading-anchors.mjs +144 -0
  89. package/src/reference/remark-notation.mjs +228 -32
  90. package/src/reference/tables-validate.ts +40 -0
  91. package/src/reference/types.ts +26 -1
  92. package/src/reference/units-vocab.ts +18 -0
  93. package/src/styles/global.css +193 -3
  94. package/src/components/starlight/EquationEnhancer.astro +0 -72
@@ -0,0 +1,255 @@
1
+ ---
2
+ /**
3
+ * The `[?]` "about this material" control, rendered top-right in the header next
4
+ * to the social icons (`LayoutSocialIcons.astro` composes both). It appears on
5
+ * every route — landing page and study-material pages alike.
6
+ *
7
+ * All of its data comes from the one compiler manifest (`manifest.about`): the
8
+ * engine identity from `explico`'s own `package.json`, the course's reader-facing
9
+ * `about` block, and the knowledge version resolved against
10
+ * `content/CHANGELOG.md`. This component never re-reads the filesystem.
11
+ *
12
+ * It is a plain `<details>` disclosure: the browser gives keyboard toggle and
13
+ * `aria-expanded` for free; the inline script only adds Escape-to-close and
14
+ * outside-click-to-close.
15
+ */
16
+ import { loadManifest } from '../../compiler/manifest';
17
+
18
+ const { about } = await loadManifest();
19
+ const { engine, material, knowledge, authors, disclaimer, changelogHref } =
20
+ about;
21
+
22
+ /** `mailto:` an email, pass through an http(s) URL, plain text otherwise. */
23
+ function contactHref(contact: string): string | undefined {
24
+ if (/^https?:\/\//i.test(contact)) return contact;
25
+ if (/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(contact)) return `mailto:${contact}`;
26
+ return undefined;
27
+ }
28
+
29
+ const authorLabel = authors.length > 1 ? 'Authors' : 'Author';
30
+ const knowledgeLine = knowledge.date
31
+ ? `${knowledge.version} · ${knowledge.date}`
32
+ : knowledge.version;
33
+ ---
34
+
35
+ <details class="about" data-about-panel>
36
+ <summary
37
+ class="about-toggle"
38
+ aria-label={`About ${material.name}`}
39
+ title={`About ${material.name}`}
40
+ >
41
+ <span aria-hidden="true">?</span>
42
+ </summary>
43
+ <div class="about-body">
44
+ <p class="about-name">{material.name}</p>
45
+ {material.summary && <p class="about-summary">{material.summary}</p>}
46
+
47
+ <dl class="about-facts">
48
+ <div>
49
+ <dt>Knowledge version</dt>
50
+ <dd>{knowledgeLine}</dd>
51
+ </div>
52
+ <div>
53
+ <dt>Engine</dt>
54
+ <dd>
55
+ {
56
+ engine.homepage ? (
57
+ <a href={engine.homepage}>
58
+ {engine.name} {engine.version}
59
+ </a>
60
+ ) : (
61
+ <span>
62
+ {engine.name} {engine.version}
63
+ </span>
64
+ )
65
+ }
66
+ </dd>
67
+ </div>
68
+ {
69
+ authors.length > 0 && (
70
+ <div>
71
+ <dt>{authorLabel}</dt>
72
+ <dd>
73
+ {authors.map((author, index) => {
74
+ const href = author.contact
75
+ ? contactHref(author.contact)
76
+ : undefined;
77
+ return (
78
+ <>
79
+ {index > 0 && ', '}
80
+ {author.name}
81
+ {author.contact && (
82
+ <>
83
+ {' '}
84
+ (
85
+ {href ? (
86
+ <a href={href}>{author.contact}</a>
87
+ ) : (
88
+ author.contact
89
+ )}
90
+ )
91
+ </>
92
+ )}
93
+ </>
94
+ );
95
+ })}
96
+ </dd>
97
+ </div>
98
+ )
99
+ }
100
+ </dl>
101
+
102
+ {disclaimer && <p class="about-disclaimer">{disclaimer}</p>}
103
+
104
+ {
105
+ changelogHref && (
106
+ <p class="about-links">
107
+ <a href={changelogHref}>What changed in this material</a>
108
+ </p>
109
+ )
110
+ }
111
+ </div>
112
+ </details>
113
+
114
+ <style>
115
+ @layer starlight.components {
116
+ .about {
117
+ position: relative;
118
+ display: inline-flex;
119
+ }
120
+
121
+ .about-toggle {
122
+ display: inline-flex;
123
+ align-items: center;
124
+ justify-content: center;
125
+ width: 1.75rem;
126
+ height: 1.75rem;
127
+ border: 1px solid var(--sl-color-gray-5);
128
+ border-radius: 999px;
129
+ color: var(--sl-color-gray-1);
130
+ background: var(--sl-color-bg);
131
+ font-size: var(--sl-text-sm);
132
+ font-weight: 600;
133
+ line-height: 1;
134
+ cursor: pointer;
135
+ user-select: none;
136
+ list-style: none;
137
+ }
138
+
139
+ .about-toggle::-webkit-details-marker {
140
+ display: none;
141
+ }
142
+
143
+ .about-toggle:hover {
144
+ color: var(--sl-color-text);
145
+ border-color: var(--sl-color-gray-4);
146
+ }
147
+
148
+ .about[open] .about-toggle {
149
+ color: var(--sl-color-text);
150
+ border-color: var(--sl-color-accent);
151
+ }
152
+
153
+ .about-toggle:focus-visible {
154
+ outline: 2px solid var(--sl-color-accent);
155
+ outline-offset: 2px;
156
+ }
157
+
158
+ .about-body {
159
+ position: absolute;
160
+ inset-inline-end: 0;
161
+ top: calc(100% + 0.5rem);
162
+ z-index: 90;
163
+ width: min(21rem, calc(100vw - 1.5rem));
164
+ max-height: min(70vh, 34rem);
165
+ overflow-y: auto;
166
+ padding: 1rem;
167
+ border: 1px solid var(--sl-color-gray-5);
168
+ border-top: 0.25rem solid var(--sl-color-accent);
169
+ border-radius: 0.65rem;
170
+ color: var(--sl-color-text);
171
+ background: var(--sl-color-bg);
172
+ box-shadow: var(--sl-shadow-lg);
173
+ font-size: var(--sl-text-xs);
174
+ line-height: 1.5;
175
+ }
176
+
177
+ .about-name {
178
+ margin: 0;
179
+ font-size: var(--sl-text-sm);
180
+ font-weight: 600;
181
+ }
182
+
183
+ .about-summary {
184
+ margin: 0.35rem 0 0;
185
+ color: var(--sl-color-gray-2);
186
+ }
187
+
188
+ .about-facts {
189
+ margin: 0.75rem 0 0;
190
+ display: grid;
191
+ gap: 0.4rem;
192
+ }
193
+
194
+ .about-facts > div {
195
+ display: grid;
196
+ grid-template-columns: 8rem 1fr;
197
+ gap: 0.5rem;
198
+ }
199
+
200
+ .about-facts dt {
201
+ color: var(--sl-color-gray-3);
202
+ text-transform: uppercase;
203
+ letter-spacing: 0.03em;
204
+ font-size: 0.65rem;
205
+ line-height: 1.6;
206
+ }
207
+
208
+ .about-facts dd {
209
+ margin: 0;
210
+ overflow-wrap: anywhere;
211
+ }
212
+
213
+ .about-disclaimer {
214
+ margin: 0.85rem 0 0;
215
+ padding-top: 0.75rem;
216
+ border-top: 1px solid var(--sl-color-gray-6);
217
+ color: var(--sl-color-gray-2);
218
+ }
219
+
220
+ .about-links {
221
+ margin: 0.75rem 0 0;
222
+ }
223
+
224
+ .about-body a {
225
+ color: var(--sl-color-text-accent);
226
+ }
227
+ }
228
+ </style>
229
+
230
+ <script>
231
+ (() => {
232
+ const panels = Array.from(
233
+ document.querySelectorAll<HTMLDetailsElement>('details[data-about-panel]'),
234
+ );
235
+ if (panels.length === 0) return;
236
+
237
+ document.addEventListener('keydown', (event) => {
238
+ if (event.key !== 'Escape') return;
239
+ for (const details of panels) {
240
+ if (!details.open) continue;
241
+ details.open = false;
242
+ details.querySelector<HTMLElement>('summary')?.focus();
243
+ }
244
+ });
245
+
246
+ document.addEventListener('pointerdown', (event) => {
247
+ const target = event.target instanceof Node ? event.target : null;
248
+ for (const details of panels) {
249
+ if (details.open && (!target || !details.contains(target))) {
250
+ details.open = false;
251
+ }
252
+ }
253
+ });
254
+ })();
255
+ </script>
@@ -0,0 +1,131 @@
1
+ ---
2
+ /**
3
+ * Progressive enhancement for content-heading deep links (Step F3), the sibling
4
+ * of `KeyedFloatEnhancer.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 `KeyedFloatEnhancer.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
+ // `KeyedFloatEnhancer.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>
@@ -0,0 +1,137 @@
1
+ ---
2
+ /**
3
+ * Progressive enhancement for keyed floats — equations (Step D7), extended
4
+ * to `<Table>`/`<Figure>` (F-tables-figures). Generalizes the original
5
+ * equation-only `EquationEnhancer`: the scroll/focus/flash mechanics are
6
+ * unchanged, parameterized over `KINDS` below so a deep-link to `#eq-`,
7
+ * `#tab-`, or `#fig-` is handled the same way.
8
+ *
9
+ * The no-JS baseline is complete: `remark-notation` emits a real
10
+ * `id="eq-<key>"` / `id="tab-<key>"` / `id="fig-<key>"` container, a
11
+ * focusable number `<a>`, and a `[[…]]` reference renders a plain anchor;
12
+ * `global.css` does the `:target` deep-link highlight (with a
13
+ * `prefers-reduced-motion` fallback) and the sticky-header scroll offset via
14
+ * `scroll-margin`.
15
+ *
16
+ * This script only adds what CSS and anchors cannot:
17
+ * - a smooth, header-aware scroll on an in-page jump;
18
+ * - re-triggering the highlight when the same `#eq-…`/`#tab-…`/`#fig-…` is
19
+ * activated again — the one case a plain anchor click does nothing for,
20
+ * since the browser won't navigate to a fragment that's already current;
21
+ * - moving keyboard focus onto the float so screen-reader users land on it;
22
+ * - for an equation only, marking its number permanently visible
23
+ * (`.eq-number-revealed`, styled in `global.css`) once the reader has
24
+ * actually arrived at it, so it doesn't fade back down once the
25
+ * `:target`/`:hover` state ends — a table/figure number isn't faded to
26
+ * begin with (it's part of the always-visible caption), so it has
27
+ * nothing to reveal.
28
+ *
29
+ * It deliberately does *not* intercept an ordinary click that changes the
30
+ * hash. `:target` is set only by a genuine same-document fragment
31
+ * navigation — never by `history.pushState` (a common trap: pushState
32
+ * updates the URL but leaves `:target`, and thus the flash, dark). Letting
33
+ * the click through and reacting to the `hashchange` it produces is what
34
+ * actually plays the highlight.
35
+ *
36
+ * Everything here is gated on `prefers-reduced-motion` and degrades to the
37
+ * native anchor behaviour if it throws.
38
+ */
39
+ ---
40
+
41
+ <script>
42
+ (() => {
43
+ const root = document.querySelector('.sl-markdown-content');
44
+ if (!root) return;
45
+
46
+ interface Kind {
47
+ readonly prefix: string;
48
+ readonly triggerSelector: string;
49
+ readonly numberSelector: string;
50
+ readonly reveal: boolean;
51
+ }
52
+
53
+ const KINDS: readonly Kind[] = [
54
+ {
55
+ prefix: '#eq-',
56
+ triggerSelector: 'a.equation-ref, a.keyed-equation__number',
57
+ numberSelector: '.keyed-equation__number',
58
+ reveal: true,
59
+ },
60
+ {
61
+ prefix: '#tab-',
62
+ triggerSelector: 'a.table-ref, a.keyed-table__number',
63
+ numberSelector: '.keyed-table__number',
64
+ reveal: false,
65
+ },
66
+ {
67
+ prefix: '#fig-',
68
+ triggerSelector: 'a.figure-ref, a.keyed-figure__number',
69
+ numberSelector: '.keyed-figure__number',
70
+ reveal: false,
71
+ },
72
+ ];
73
+ const TRIGGER_SELECTOR = KINDS.map((kind) => kind.triggerSelector).join(', ');
74
+ const kindForHash = (hash: string) =>
75
+ KINDS.find((kind) => hash.startsWith(kind.prefix));
76
+
77
+ const reduce = window.matchMedia('(prefers-reduced-motion: reduce)');
78
+
79
+ const activate = (target: HTMLElement | null, doScroll: boolean, kind: Kind) => {
80
+ if (!target) return;
81
+ if (kind.reveal) {
82
+ target
83
+ .querySelector(kind.numberSelector)
84
+ ?.classList.add('eq-number-revealed');
85
+ }
86
+ if (!reduce.matches) {
87
+ // Restart the CSS `:target` animation even when the hash is unchanged.
88
+ target.style.animation = 'none';
89
+ void target.offsetWidth;
90
+ target.style.removeProperty('animation');
91
+ }
92
+ if (doScroll) {
93
+ target.scrollIntoView({
94
+ behavior: reduce.matches ? 'auto' : 'smooth',
95
+ block: 'start',
96
+ });
97
+ }
98
+ target.setAttribute('tabindex', '-1');
99
+ try {
100
+ target.focus({ preventScroll: true });
101
+ } catch {
102
+ target.focus();
103
+ }
104
+ };
105
+
106
+ root.addEventListener('click', (event) => {
107
+ const origin = event.target instanceof Element ? event.target : null;
108
+ const link = origin?.closest(TRIGGER_SELECTOR);
109
+ if (!link) return;
110
+ const href = link.getAttribute('href') || '';
111
+ const kind = kindForHash(href);
112
+ if (!kind) return; // cross-page or unrecognized: let the browser navigate
113
+ const target = document.getElementById(href.slice(1));
114
+ if (!target) return;
115
+ // Only the already-on-this-anchor click needs handling here — the
116
+ // browser has nothing new to navigate to, so it won't fire `hashchange`
117
+ // and nothing else will re-trigger the flash. Every other click is left
118
+ // alone: its native navigation sets `:target` and fires `hashchange`,
119
+ // which the listener below turns into the same `activate()`.
120
+ if (location.hash === href) {
121
+ event.preventDefault();
122
+ activate(target, true, kind);
123
+ }
124
+ });
125
+
126
+ window.addEventListener('hashchange', () => {
127
+ const kind = kindForHash(location.hash);
128
+ if (!kind) return;
129
+ activate(document.getElementById(location.hash.slice(1)), true, kind);
130
+ });
131
+
132
+ const initialKind = kindForHash(location.hash);
133
+ if (initialKind) {
134
+ activate(document.getElementById(location.hash.slice(1)), true, initialKind);
135
+ }
136
+ })();
137
+ </script>
@@ -0,0 +1,12 @@
1
+ ---
2
+ /**
3
+ * Starlight allows one `SocialIcons` override, so this composes the stock
4
+ * social-link row with the `[?]` About control (`AboutPanel.astro`). Both sit in
5
+ * the header's right cluster; the About panel is present on every route.
6
+ */
7
+ import DefaultSocialIcons from '@astrojs/starlight/components/SocialIcons.astro';
8
+ import AboutPanel from './AboutPanel.astro';
9
+ ---
10
+
11
+ <DefaultSocialIcons />
12
+ <AboutPanel />
@@ -18,7 +18,9 @@ import NotationLayer, {
18
18
  type NotationLayerDefinition,
19
19
  } from '../notation/NotationLayer.astro';
20
20
  import CitationLayer from '../citation/CitationLayer.astro';
21
- import EquationEnhancer from './EquationEnhancer.astro';
21
+ import KeyedFloatEnhancer from './KeyedFloatEnhancer.astro';
22
+ import FloatRefLayer from '../reference/FloatRefLayer.astro';
23
+ import HeadingAnchorEnhancer from './HeadingAnchorEnhancer.astro';
22
24
 
23
25
  // `lessonId` is no longer authored (Phase C2); derive it from the route.
24
26
  const routeSlug = Astro.locals.starlightRoute.entry.id;
@@ -39,6 +41,8 @@ if (lessonId) {
39
41
  label: definition.label,
40
42
  summary: definition.summary,
41
43
  formula: definition.formula,
44
+ formulaScope: definition.formulaScope,
45
+ glosses: definition.glosses,
42
46
  units: definition.units,
43
47
  seeAlso: definition.seeAlso,
44
48
  editorialStatus: definition.editorialStatus,
@@ -54,5 +58,7 @@ if (lessonId) {
54
58
 
55
59
  <NotationLayer definitions={definitions} />
56
60
  {lessonId && <CitationLayer />}
57
- {lessonId && <EquationEnhancer />}
61
+ {lessonId && <KeyedFloatEnhancer />}
62
+ {lessonId && <FloatRefLayer />}
63
+ {lessonId && <HeadingAnchorEnhancer />}
58
64
  <DefaultFooter />
@@ -0,0 +1,104 @@
1
+ ---
2
+ /**
3
+ * A referenceable table (F-tables-figures) — the table equivalent of a
4
+ * keyed display equation. `remark-notation.mjs` numbers every `<Table>` on a
5
+ * lesson page `‹chapter›.‹n›` (chapter = the lesson's course ordinal, `n`
6
+ * sequential over the lesson), keyed or not, and injects the resulting
7
+ * `number`/`anchorId` as props; this component only renders what it's given
8
+ * — it never computes its own number, same as `equationNode()`'s output for
9
+ * equations.
10
+ *
11
+ * An author sets `id` to make the table referenceable by name with
12
+ * `[[tab-<id>]]` (same page) or `[[lesson-slug#tab-<id>]]` (cross page); a
13
+ * table without `id` is still numbered and deep-linkable, just not
14
+ * referenceable by name — the same rule an unkeyed equation follows.
15
+ */
16
+ export interface Props {
17
+ /** Stable key, e.g. `id="kinematics-summary"`. Omit for a positional-only table. */
18
+ id?: string;
19
+ /** Visible caption, shown below the table and in the hover-preview card. */
20
+ caption: string;
21
+ /** Computed by `remark-notation.mjs` — never author this directly. */
22
+ number?: string;
23
+ /** Computed by `remark-notation.mjs` — never author this directly. */
24
+ anchorId?: string;
25
+ }
26
+
27
+ const { id, caption, number, anchorId } = Astro.props;
28
+
29
+ if (typeof caption !== 'string' || !caption.trim()) {
30
+ throw new Error('Table requires a non-empty `caption` prop.');
31
+ }
32
+ if (!number || !anchorId) {
33
+ throw new Error(
34
+ 'Table is missing its computed `number`/`anchorId` — this page must run remark-notation with a `tables` cross-page table configured (see astro.config.mjs).',
35
+ );
36
+ }
37
+ ---
38
+
39
+ <figure
40
+ class="keyed-table"
41
+ id={anchorId}
42
+ data-tab-number={number}
43
+ data-tab-key={id}
44
+ >
45
+ <div class="keyed-table__body">
46
+ <slot />
47
+ </div>
48
+ <figcaption class="keyed-table__caption">
49
+ <a
50
+ class="keyed-table__number"
51
+ href={`#${anchorId}`}
52
+ aria-label={`Table ${number}`}>Table {number}</a
53
+ >
54
+ <span class="keyed-table__caption-text">{caption}</span>
55
+ </figcaption>
56
+ </figure>
57
+
58
+ <style>
59
+ .keyed-table {
60
+ margin-block: 1.5rem;
61
+ padding: 0;
62
+ }
63
+
64
+ .keyed-table__body {
65
+ overflow-x: auto;
66
+ }
67
+
68
+ .keyed-table__body :global(table) {
69
+ margin: 0;
70
+ }
71
+
72
+ .keyed-table__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-table__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-table__number:hover,
86
+ .keyed-table__number:focus-visible {
87
+ text-decoration: underline;
88
+ }
89
+
90
+ .keyed-table__number:focus-visible {
91
+ outline: 2px solid var(--sl-color-accent);
92
+ outline-offset: 0.15rem;
93
+ }
94
+
95
+ .keyed-table__caption-text::before {
96
+ content: ' — ';
97
+ }
98
+
99
+ @media print {
100
+ .keyed-table__number {
101
+ color: inherit;
102
+ }
103
+ }
104
+ </style>