explico 0.2.0 → 0.4.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 (83) hide show
  1. package/CHANGELOG.md +173 -0
  2. package/README.md +67 -7
  3. package/dist/compiler/astro.d.ts +30 -0
  4. package/dist/compiler/astro.js +79 -0
  5. package/dist/compiler/cli.d.ts +16 -0
  6. package/dist/compiler/cli.js +43 -9
  7. package/dist/compiler/collections.js +4 -1
  8. package/dist/compiler/course-config.d.ts +80 -4
  9. package/dist/compiler/course-config.js +12 -0
  10. package/dist/compiler/knowledge-version.d.ts +58 -0
  11. package/dist/compiler/knowledge-version.js +80 -0
  12. package/dist/compiler/lesson-derivation.d.ts +1 -1
  13. package/dist/compiler/lesson-derivation.js +2 -2
  14. package/dist/compiler/manifest.d.ts +98 -2
  15. package/dist/compiler/manifest.js +153 -24
  16. package/dist/compiler/resolution-report.d.ts +4 -0
  17. package/dist/reference/base-operators.d.mts +38 -0
  18. package/dist/reference/base-operators.mjs +103 -0
  19. package/dist/reference/consistency.d.ts +7 -2
  20. package/dist/reference/consistency.js +51 -2
  21. package/dist/reference/diagram-layout.d.ts +63 -0
  22. package/dist/reference/diagram-layout.js +122 -0
  23. package/dist/reference/equations-validate.d.ts +10 -0
  24. package/dist/reference/equations-validate.js +1 -1
  25. package/dist/reference/equations.d.mts +80 -13
  26. package/dist/reference/equations.mjs +109 -19
  27. package/dist/reference/figures-validate.d.ts +21 -0
  28. package/dist/reference/figures-validate.js +13 -0
  29. package/dist/reference/keyed-float-validate.d.ts +73 -0
  30. package/dist/reference/keyed-float-validate.js +110 -0
  31. package/dist/reference/keyed-jsx-floats.d.mts +64 -0
  32. package/dist/reference/keyed-jsx-floats.mjs +191 -0
  33. package/dist/reference/math-glyphs.d.mts +18 -0
  34. package/dist/reference/math-glyphs.mjs +92 -0
  35. package/dist/reference/remark-notation.d.mts +61 -0
  36. package/dist/reference/remark-notation.mjs +344 -10
  37. package/dist/reference/tables-validate.d.ts +21 -0
  38. package/dist/reference/tables-validate.js +13 -0
  39. package/dist/reference/units-vocab.js +19 -1
  40. package/docs/VERSIONING.md +66 -0
  41. package/docs/authoring/README.md +100 -0
  42. package/docs/authoring/agents.md +63 -0
  43. package/docs/authoring/migrating.md +169 -0
  44. package/docs/authoring/notation.md +297 -0
  45. package/docs/authoring/structure.md +139 -0
  46. package/docs/authoring/versioning.md +116 -0
  47. package/docs/authoring/visualizations.md +195 -0
  48. package/package.json +20 -5
  49. package/src/compiler/astro.ts +127 -0
  50. package/src/compiler/cli.ts +52 -7
  51. package/src/compiler/collections.ts +12 -3
  52. package/src/compiler/course-config.ts +93 -3
  53. package/src/compiler/knowledge-version.ts +157 -0
  54. package/src/compiler/lesson-derivation.ts +2 -2
  55. package/src/compiler/manifest.ts +320 -26
  56. package/src/components/CurriculumMap.astro +27 -0
  57. package/src/components/diagram/Diagram.astro +317 -0
  58. package/src/components/examples/CompactExamples.astro +1 -1
  59. package/src/components/figure/Figure.astro +104 -0
  60. package/src/components/notation/NotationLayer.astro +11 -0
  61. package/src/components/notation/render-notation-math.ts +13 -2
  62. package/src/components/reference/FloatRefLayer.astro +671 -0
  63. package/src/components/starlight/AboutPanel.astro +255 -0
  64. package/src/components/starlight/HeadingAnchorEnhancer.astro +3 -3
  65. package/src/components/starlight/KeyedFloatEnhancer.astro +137 -0
  66. package/src/components/starlight/LayoutSidebar.astro +62 -1
  67. package/src/components/starlight/LayoutSocialIcons.astro +12 -0
  68. package/src/components/starlight/LessonFooter.astro +15 -2
  69. package/src/components/table/Table.astro +105 -0
  70. package/src/reference/base-operators.mjs +103 -0
  71. package/src/reference/consistency.ts +83 -2
  72. package/src/reference/diagram-layout.ts +246 -0
  73. package/src/reference/equations-validate.ts +8 -1
  74. package/src/reference/equations.mjs +109 -19
  75. package/src/reference/figures-validate.ts +40 -0
  76. package/src/reference/keyed-float-validate.ts +210 -0
  77. package/src/reference/keyed-jsx-floats.mjs +191 -0
  78. package/src/reference/math-glyphs.mjs +92 -0
  79. package/src/reference/remark-notation.mjs +344 -10
  80. package/src/reference/tables-validate.ts +40 -0
  81. package/src/reference/units-vocab.ts +18 -0
  82. package/src/styles/global.css +244 -7
  83. package/src/components/starlight/EquationEnhancer.astro +0 -87
@@ -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>
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  /**
3
3
  * Progressive enhancement for content-heading deep links (Step F3), the sibling
4
- * of `EquationEnhancer.astro`.
4
+ * of `KeyedFloatEnhancer.astro`.
5
5
  *
6
6
  * The no-JS baseline is complete: `rehype-heading-anchors` emits Starlight's
7
7
  * `.sl-heading-wrapper` + `a.sl-anchor-link` markup, its bundled
@@ -24,7 +24,7 @@
24
24
  * navigation — never by `history.pushState` (a common trap: pushState
25
25
  * updates the URL but leaves `:target`, and thus `heading-flash`, dark).
26
26
  * Letting the click through and reacting to the `hashchange` it produces is
27
- * what actually plays the highlight — see `EquationEnhancer.astro`, which
27
+ * what actually plays the highlight — see `KeyedFloatEnhancer.astro`, which
28
28
  * this mirrors.
29
29
  *
30
30
  * Everything here is gated on `prefers-reduced-motion` and degrades to the
@@ -113,7 +113,7 @@
113
113
  const heading = headingForId(href.slice(1));
114
114
  if (!heading) return;
115
115
  // Only the already-on-this-anchor click needs handling here — see
116
- // `EquationEnhancer.astro`. Every other click is left to the browser's
116
+ // `KeyedFloatEnhancer.astro`. Every other click is left to the browser's
117
117
  // own fragment navigation, picked up below via `hashchange`.
118
118
  if (location.hash === href) {
119
119
  event.preventDefault();
@@ -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>
@@ -23,7 +23,68 @@ import DefaultSidebar from '@astrojs/starlight/components/Sidebar.astro';
23
23
  size="1rem"
24
24
  />
25
25
  </button>
26
- <div id="starlight__navigation-content" class="layout-navigation-content">
26
+ <div
27
+ id="starlight__navigation-content"
28
+ class="layout-navigation-content layout-compact-sidebar"
29
+ >
27
30
  <DefaultSidebar />
31
+ <script>
32
+ import { createBrowserPreferenceStore } from '../../session/PreferenceStore';
33
+
34
+ (() => {
35
+ if (!matchMedia('(min-width: 50rem)').matches) return;
36
+ const root = document.querySelector('#starlight__navigation-content');
37
+ const stateRoot = root?.querySelector('sl-sidebar-state-persist');
38
+ if (!stateRoot) return;
39
+
40
+ const groups = [
41
+ ...stateRoot.querySelectorAll(':scope > ul > li > details'),
42
+ ];
43
+ const storageKey = 'explico-sidebar-groups';
44
+ const preferences = createBrowserPreferenceStore();
45
+ let compact = groups.map(() => true);
46
+ try {
47
+ const saved = JSON.parse(preferences.get(storageKey) || 'null');
48
+ if (
49
+ saved?.hash === stateRoot.dataset.hash &&
50
+ Array.isArray(saved.compact)
51
+ ) {
52
+ compact = groups.map((_, index) => saved.compact[index] !== false);
53
+ }
54
+ } catch {}
55
+
56
+ const save = () => {
57
+ try {
58
+ preferences.set(
59
+ storageKey,
60
+ JSON.stringify({
61
+ hash: stateRoot.dataset.hash,
62
+ compact: groups.map((group) => group.hasAttribute('data-compact')),
63
+ }),
64
+ );
65
+ } catch {}
66
+ };
67
+
68
+ groups.forEach((group, index) => {
69
+ // The links remain rendered and operable in compact mode; only their
70
+ // labels collapse. Keep <details> open so this stays true for assistive
71
+ // technology as well as pointer users.
72
+ group.setAttribute('open', '');
73
+ group.toggleAttribute('data-compact', compact[index]);
74
+ const summary = group.querySelector(':scope > summary');
75
+ summary?.setAttribute('aria-expanded', String(!compact[index]));
76
+ summary?.addEventListener('click', (event) => {
77
+ event.preventDefault();
78
+ group.toggleAttribute('data-compact');
79
+ group.setAttribute('open', '');
80
+ summary.setAttribute(
81
+ 'aria-expanded',
82
+ String(!group.hasAttribute('data-compact')),
83
+ );
84
+ save();
85
+ });
86
+ });
87
+ })();
88
+ </script>
28
89
  </div>
29
90
  </div>
@@ -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 />
@@ -9,6 +9,7 @@
9
9
  * D5); this component never rebuilds the registry.
10
10
  */
11
11
  import DefaultFooter from '@astrojs/starlight/components/Footer.astro';
12
+ import { assertLocalNotationParity } from '../../compiler/astro';
12
13
  import { loadManifest } from '../../compiler/manifest';
13
14
  import {
14
15
  isLessonSlug,
@@ -18,7 +19,8 @@ import NotationLayer, {
18
19
  type NotationLayerDefinition,
19
20
  } from '../notation/NotationLayer.astro';
20
21
  import CitationLayer from '../citation/CitationLayer.astro';
21
- import EquationEnhancer from './EquationEnhancer.astro';
22
+ import KeyedFloatEnhancer from './KeyedFloatEnhancer.astro';
23
+ import FloatRefLayer from '../reference/FloatRefLayer.astro';
22
24
  import HeadingAnchorEnhancer from './HeadingAnchorEnhancer.astro';
23
25
 
24
26
  // `lessonId` is no longer authored (Phase C2); derive it from the route.
@@ -33,6 +35,16 @@ const definitions: NotationLayerDefinition[] = [];
33
35
  if (lessonId) {
34
36
  const manifest = await loadManifest();
35
37
  const lesson = manifest.lessons.find((entry) => entry.id === lessonId);
38
+ const routeData = Astro.locals.starlightRoute.entry.data as {
39
+ notation?: { local?: readonly { key?: unknown }[] };
40
+ };
41
+ const authoredLocalKeys = (routeData.notation?.local ?? [])
42
+ .map((entry) => entry.key)
43
+ .filter((key): key is string => typeof key === 'string');
44
+ const renderedLocalKeys = (lesson?.notation.definitions ?? [])
45
+ .filter((definition) => definition.scope === 'local')
46
+ .map((definition) => definition.key);
47
+ assertLocalNotationParity(lessonId, authoredLocalKeys, renderedLocalKeys);
36
48
  for (const definition of lesson?.notation.definitions ?? []) {
37
49
  definitions.push({
38
50
  key: definition.key,
@@ -57,6 +69,7 @@ if (lessonId) {
57
69
 
58
70
  <NotationLayer definitions={definitions} />
59
71
  {lessonId && <CitationLayer />}
60
- {lessonId && <EquationEnhancer />}
72
+ {lessonId && <KeyedFloatEnhancer />}
73
+ {lessonId && <FloatRefLayer />}
61
74
  {lessonId && <HeadingAnchorEnhancer />}
62
75
  <DefaultFooter />
@@ -0,0 +1,105 @@
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 `‹part›.‹n›` (the part is the sidebar group's ordinal, `n`
6
+ * sequential over every `<Table>` in that part — see
7
+ * `docs/authoring/structure.md`), keyed or not, and injects the resulting
8
+ * `number`/`anchorId` as props; this component only renders what it's given
9
+ * — it never computes its own number, same as `equationNode()`'s output for
10
+ * equations.
11
+ *
12
+ * An author sets `id` to make the table referenceable by name with
13
+ * `[[tab-<id>]]` (same page) or `[[lesson-slug#tab-<id>]]` (cross page); a
14
+ * table without `id` is still numbered and deep-linkable, just not
15
+ * referenceable by name — the same rule an unkeyed equation follows.
16
+ */
17
+ export interface Props {
18
+ /** Stable key, e.g. `id="kinematics-summary"`. Omit for a positional-only table. */
19
+ id?: string;
20
+ /** Visible caption, shown below the table and in the hover-preview card. */
21
+ caption: string;
22
+ /** Computed by `remark-notation.mjs` — never author this directly. */
23
+ number?: string;
24
+ /** Computed by `remark-notation.mjs` — never author this directly. */
25
+ anchorId?: string;
26
+ }
27
+
28
+ const { id, caption, number, anchorId } = Astro.props;
29
+
30
+ if (typeof caption !== 'string' || !caption.trim()) {
31
+ throw new Error('Table requires a non-empty `caption` prop.');
32
+ }
33
+ if (!number || !anchorId) {
34
+ throw new Error(
35
+ 'Table is missing its computed `number`/`anchorId` — this page must run remark-notation with a `tables` cross-page table configured (see astro.config.mjs).',
36
+ );
37
+ }
38
+ ---
39
+
40
+ <figure
41
+ class="keyed-table"
42
+ id={anchorId}
43
+ data-tab-number={number}
44
+ data-tab-key={id}
45
+ >
46
+ <div class="keyed-table__body">
47
+ <slot />
48
+ </div>
49
+ <figcaption class="keyed-table__caption">
50
+ <a
51
+ class="keyed-table__number"
52
+ href={`#${anchorId}`}
53
+ aria-label={`Table ${number}`}>Table {number}</a
54
+ >
55
+ <span class="keyed-table__caption-text">{caption}</span>
56
+ </figcaption>
57
+ </figure>
58
+
59
+ <style>
60
+ .keyed-table {
61
+ margin-block: 1.5rem;
62
+ padding: 0;
63
+ }
64
+
65
+ .keyed-table__body {
66
+ overflow-x: auto;
67
+ }
68
+
69
+ .keyed-table__body :global(table) {
70
+ margin: 0;
71
+ }
72
+
73
+ .keyed-table__caption {
74
+ margin-block-start: 0.5rem;
75
+ font-size: var(--sl-text-sm);
76
+ color: var(--sl-color-gray-3);
77
+ }
78
+
79
+ .keyed-table__number {
80
+ font-weight: 600;
81
+ font-variant-numeric: tabular-nums;
82
+ color: var(--sl-color-text-accent);
83
+ text-decoration: none;
84
+ }
85
+
86
+ .keyed-table__number:hover,
87
+ .keyed-table__number:focus-visible {
88
+ text-decoration: underline;
89
+ }
90
+
91
+ .keyed-table__number:focus-visible {
92
+ outline: 2px solid var(--sl-color-accent);
93
+ outline-offset: 0.15rem;
94
+ }
95
+
96
+ .keyed-table__caption-text::before {
97
+ content: ' — ';
98
+ }
99
+
100
+ @media print {
101
+ .keyed-table__number {
102
+ color: inherit;
103
+ }
104
+ }
105
+ </style>