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
@@ -34,6 +34,7 @@ import {
34
34
  readdirSync,
35
35
  writeFileSync,
36
36
  } from 'node:fs';
37
+ import { createRequire } from 'node:module';
37
38
  import { dirname, join, relative } from 'node:path';
38
39
  import { parseFrontmatter } from '@astrojs/markdown-remark';
39
40
 
@@ -51,13 +52,26 @@ import {
51
52
  type EquationDiagnostic,
52
53
  type EquationLabelRecord,
53
54
  } from '../reference/equations-validate';
55
+ import {
56
+ validateTables,
57
+ type TableDiagnostic,
58
+ type TableLabelRecord,
59
+ } from '../reference/tables-validate';
60
+ import {
61
+ validateFigures,
62
+ type FigureDiagnostic,
63
+ type FigureLabelRecord,
64
+ } from '../reference/figures-validate';
54
65
  import {
55
66
  gateContentMath,
56
67
  type MathGateDiagnostic,
57
68
  } from '../reference/gate-math';
58
69
  import { loadLintIgnore } from '../reference/lint-ignore';
59
70
  import { buildNotationRegistry } from '../reference/registry';
71
+ import { formulaGlyphScope } from '../reference/gloss';
72
+ import type { GlyphScopeEntry } from '../reference/gloss';
60
73
  import { resolveReferenceId } from '../reference/references';
74
+ import type { NotationGlossEntry } from '../reference/types';
61
75
  import type {
62
76
  EditorialStatus,
63
77
  NotationBacklink,
@@ -67,6 +81,10 @@ import type {
67
81
  NotationPageBundle,
68
82
  } from '../reference/types';
69
83
  import type { CourseConfig } from './course-config';
84
+ import {
85
+ resolveKnowledgeVersion,
86
+ type KnowledgeVersionDiagnostic,
87
+ } from './knowledge-version';
70
88
  import {
71
89
  loadCurriculumCatalog,
72
90
  loadNotationDefinitions,
@@ -78,6 +96,7 @@ import {
78
96
  buildSidebar,
79
97
  deriveSources,
80
98
  isLessonSlug,
99
+ lessonIdFromSlug,
81
100
  type SidebarGroup,
82
101
  } from './lesson-derivation';
83
102
 
@@ -89,8 +108,16 @@ import {
89
108
  * `diagnostics.consistency` (Tier-3 corpus checks).
90
109
  * v3 (D7): `equations` (labelled display equations + cross-page number table),
91
110
  * `lessons[].equations`, and `diagnostics.equations`.
111
+ * v4 (F-tables-figures): `tables`/`figures` (same shape as `equations`, for
112
+ * `<Table>`/`<Figure>`), `lessons[].tables`/`lessons[].figures`, and
113
+ * `diagnostics.tables`/`diagnostics.figures`.
114
+ * v5 (about): `about` (engine identity from `explico`'s `package.json`, the
115
+ * course's reader-facing `about` block, and the knowledge version resolved
116
+ * against `content/CHANGELOG.md`) and `diagnostics.knowledge`.
117
+ * v6 (chapter numbering): `lessons[].chapter` — the lesson's 1-based course
118
+ * ordinal, the chapter in every equation/table/figure number (`(2.1)`).
92
119
  */
93
- export const MANIFEST_SCHEMA_VERSION = 3;
120
+ export const MANIFEST_SCHEMA_VERSION = 6;
94
121
 
95
122
  const REPOSITORY_ROOT = process.cwd();
96
123
  // Content is the top-level `content/` folder; the build output is `build/`
@@ -98,6 +125,33 @@ const REPOSITORY_ROOT = process.cwd();
98
125
  const CONTENT_ROOT = join(REPOSITORY_ROOT, 'content');
99
126
  export const MANIFEST_PATH = join(REPOSITORY_ROOT, 'build', 'manifest.json');
100
127
 
128
+ interface EnginePackage {
129
+ readonly name: string;
130
+ readonly version: string;
131
+ readonly homepage?: string;
132
+ }
133
+
134
+ /**
135
+ * `explico`'s own identity — the golden source for the engine version the About
136
+ * panel shows (the README points here, not a literal).
137
+ *
138
+ * Resolved as the bare specifier `explico/package.json` (a published `exports`
139
+ * entry, and a package self-reference inside this repo) through `createRequire`,
140
+ * so it survives being re-bundled by a course's Astro build — a relative
141
+ * `../../package.json` would follow the *bundle* location, not the source's.
142
+ * Lazy + cached: importing this module for `readManifestSync` must stay free of
143
+ * filesystem side effects; only `compileManifest` needs the value.
144
+ */
145
+ let enginePackage: EnginePackage | undefined;
146
+ function readEnginePackage(): EnginePackage {
147
+ if (!enginePackage) {
148
+ enginePackage = createRequire(import.meta.url)(
149
+ 'explico/package.json',
150
+ ) as EnginePackage;
151
+ }
152
+ return enginePackage;
153
+ }
154
+
101
155
  // --- shape ------------------------------------------------------------
102
156
 
103
157
  export interface ManifestCitation {
@@ -137,12 +191,26 @@ export interface ManifestLessonNotation {
137
191
  readonly seeAlso: readonly string[];
138
192
  readonly units?: string;
139
193
  readonly formula?: string;
194
+ /** Symbols the formula names that have no card of their own (D15). */
195
+ readonly glosses: readonly NotationGlossEntry[];
196
+ /**
197
+ * Entry-scoped glyph table for `formula` — the entry, its glosses, and any
198
+ * card the formula names. Precomputed here because the whole card set lives
199
+ * in the compiler, not in the page component.
200
+ */
201
+ readonly formulaScope: readonly GlyphScopeEntry[];
140
202
  }
141
203
 
142
204
  export interface ManifestLesson {
143
205
  readonly id: string;
144
206
  /** Docs-loader slug, e.g. `bonds/price-yield-relationship`. */
145
207
  readonly slug: string;
208
+ /**
209
+ * The lesson's 1-based ordinal in the course (merged track order, then any
210
+ * unranked lessons by slug). It is the chapter in every equation, table, and
211
+ * figure number on the page — `(2.1)` is the first equation of chapter 2.
212
+ */
213
+ readonly chapter: number;
146
214
  readonly file: string;
147
215
  readonly title: string;
148
216
  readonly description: string;
@@ -161,6 +229,16 @@ export interface ManifestLesson {
161
229
  readonly key: string;
162
230
  readonly number: string;
163
231
  }[];
232
+ /** Keyed `<Table>` elements in appearance order. */
233
+ readonly tables: readonly {
234
+ readonly key: string;
235
+ readonly number: string;
236
+ }[];
237
+ /** Keyed `<Figure>` elements in appearance order. */
238
+ readonly figures: readonly {
239
+ readonly key: string;
240
+ readonly number: string;
241
+ }[];
164
242
  readonly notation: {
165
243
  readonly bindings: readonly {
166
244
  readonly key: string;
@@ -173,8 +251,37 @@ export interface ManifestLesson {
173
251
  };
174
252
  }
175
253
 
254
+ /** The reader-facing "about this material" bundle — the `[?]` panel's data. */
255
+ export interface ManifestAbout {
256
+ /** `explico`'s own identity, from its `package.json` (the version's golden source). */
257
+ readonly engine: {
258
+ readonly name: string;
259
+ readonly version: string;
260
+ readonly homepage?: string;
261
+ };
262
+ readonly material: { readonly name: string; readonly summary: string };
263
+ /** The course's knowledge version — semver over the content, engine-independent. */
264
+ readonly knowledge: {
265
+ readonly version: string;
266
+ /** Date of the newest `content/CHANGELOG.md` entry, if it has one. */
267
+ readonly date?: string;
268
+ /** Bullet notes from that entry. */
269
+ readonly notes: readonly string[];
270
+ /** Content fingerprint at compile time — ties the shown version to a build. */
271
+ readonly contentTree: string;
272
+ };
273
+ readonly authors: readonly {
274
+ readonly name: string;
275
+ readonly contact?: string;
276
+ }[];
277
+ readonly disclaimer: string;
278
+ readonly changelogHref?: string;
279
+ }
280
+
176
281
  export interface Manifest {
177
282
  readonly schemaVersion: number;
283
+ /** Reader-facing identity + versions (the `[?]` About panel). */
284
+ readonly about: ManifestAbout;
178
285
  readonly notation: {
179
286
  readonly definitions: readonly NotationDefinitionRecord[];
180
287
  readonly bundles: readonly NotationPageBundle[];
@@ -196,6 +303,16 @@ export interface Manifest {
196
303
  /** `slug -> { eqKey: number }` — passed to `remarkNotation` for `[[slug#eq:key]]`. */
197
304
  readonly numbersBySlug: Record<string, Record<string, string>>;
198
305
  };
306
+ /** Keyed-float identity for `<Table>` — same shape as `equations`. */
307
+ readonly tables: {
308
+ readonly labels: readonly TableLabelRecord[];
309
+ readonly numbersBySlug: Record<string, Record<string, string>>;
310
+ };
311
+ /** Keyed-float identity for `<Figure>` — same shape as `equations`. */
312
+ readonly figures: {
313
+ readonly labels: readonly FigureLabelRecord[];
314
+ readonly numbersBySlug: Record<string, Record<string, string>>;
315
+ };
199
316
  readonly diagnostics: {
200
317
  readonly curriculum: readonly CurriculumIssue[];
201
318
  readonly notation: readonly NotationDiagnostic[];
@@ -203,8 +320,14 @@ export interface Manifest {
203
320
  readonly math: readonly MathGateDiagnostic[];
204
321
  /** Tier-3 corpus consistency checks (D6). Warnings only. */
205
322
  readonly consistency: readonly ConsistencyDiagnostic[];
323
+ /** Knowledge-version checks (C3): `about.version` vs `content/CHANGELOG.md`. Warnings only. */
324
+ readonly knowledge: readonly KnowledgeVersionDiagnostic[];
206
325
  /** Equation-identity checks (D7): `eq-duplicate-key`, `eq-ref-resolves` (error); `eq-key-unused` (warning). */
207
326
  readonly equations: readonly EquationDiagnostic[];
327
+ /** `<Table>` identity checks (F-tables-figures) — same codes, `tab-` prefixed. */
328
+ readonly tables: readonly TableDiagnostic[];
329
+ /** `<Figure>` identity checks (F-tables-figures) — same codes, `fig-` prefixed. */
330
+ readonly figures: readonly FigureDiagnostic[];
208
331
  };
209
332
  readonly hashes: {
210
333
  /** `sha256` of every content source file, keyed by repo-relative path. */
@@ -306,6 +429,7 @@ function lessonNotationBundle(
306
429
  bundle: NotationPageBundle | undefined,
307
430
  definitionsById: ReadonlyMap<string, NotationDefinitionRecord>,
308
431
  resolution: readonly ManifestNotationResolution[],
432
+ cardsByKey: ReadonlyMap<string, GlyphScopeEntry>,
309
433
  ): ManifestLesson['notation'] {
310
434
  const definitions: ManifestLessonNotation[] = [];
311
435
  for (const definitionId of bundle?.definitionIds ?? []) {
@@ -320,6 +444,16 @@ function lessonNotationBundle(
320
444
  summary: definition.summary,
321
445
  editorialStatus: definition.status,
322
446
  seeAlso: [...definition.seeAlso],
447
+ glosses: [...definition.glosses],
448
+ formulaScope: formulaGlyphScope({
449
+ key: definition.key,
450
+ notation: definition.notation,
451
+ glosses: definition.glosses,
452
+ cardsByKey,
453
+ ...(definition.formula === undefined
454
+ ? {}
455
+ : { formula: definition.formula }),
456
+ }),
323
457
  ...(definition.units === undefined ? {} : { units: definition.units }),
324
458
  ...(definition.formula === undefined
325
459
  ? {}
@@ -373,12 +507,36 @@ function lessonFrontmatterById(): Map<
373
507
  export async function compileManifest(
374
508
  courseConfig: CourseConfig,
375
509
  ): Promise<Manifest> {
510
+ // `about` became required in explico 0.3.0. A course upgrading from 0.2.x
511
+ // hits this before the raw `Cannot read properties of undefined` further
512
+ // down; the message names the fix and the guide.
513
+ if (
514
+ courseConfig.about === undefined ||
515
+ courseConfig.about.version === undefined ||
516
+ courseConfig.about.authors === undefined ||
517
+ courseConfig.about.disclaimer === undefined
518
+ ) {
519
+ throw new Error(
520
+ "course.config.ts is missing a complete `about` block (required since explico 0.3.0): " +
521
+ "`about: { version, authors: [{ name }], disclaimer, materialName?, summary?, changelogHref? }`. " +
522
+ 'See docs/authoring/migrating.md (§ 0.2.x → 0.3.0) in the installed package.',
523
+ );
524
+ }
525
+
376
526
  const [catalog, notationInput] = await Promise.all([
377
527
  loadCurriculumCatalog(),
378
528
  loadNotationRegistryInput(),
379
529
  ]);
380
530
 
381
531
  const registry = buildNotationRegistry(notationInput);
532
+ // Cards a `formula` may name with `\explain{key}` (D15). Shared entries
533
+ // only: a page-local symbol is not addressable from another entry.
534
+ const cardsByKey = new Map<string, GlyphScopeEntry>(
535
+ notationInput.sharedDefinitions.map((definition) => [
536
+ definition.key,
537
+ { key: definition.key, notation: definition.notation },
538
+ ]),
539
+ );
382
540
  const definitionsById = new Map(
383
541
  registry.definitions.map((definition) => [definition.id, definition]),
384
542
  );
@@ -422,20 +580,57 @@ export async function compileManifest(
422
580
  );
423
581
  const frontmatterById = lessonFrontmatterById();
424
582
 
425
- const equationValidation = validateEquations(
426
- notationInput.lessons.map((lesson) => ({
427
- lessonId: lesson.lessonId,
428
- slug: lesson.lessonId.replace(/\./g, '/'),
429
- file: lesson.source.file,
430
- body: lesson.body,
431
- })),
583
+ const lessonSlugs = [...notationInput.lessons]
584
+ .map((lesson) => lesson.lessonId.replace(/\./g, '/'))
585
+ .sort();
586
+ const sidebar = buildSidebar(
587
+ catalog.tracks.map((track) => ({ id: track.id, lessons: track.lessons })),
588
+ lessonSlugs,
589
+ courseConfig.sections,
432
590
  );
591
+
592
+ // A lesson's chapter is its position when the sidebar is read top to bottom
593
+ // (sections in config order, lessons within a section by merged track order
594
+ // then slug). It prefixes every equation / table / figure number on the page
595
+ // (`(2.1)` = first equation of chapter 2). Derived from `sidebar` so there is
596
+ // one lesson-ordering source, not two.
597
+ const chapterByLessonId = new Map<string, number>();
598
+ let chapterCursor = 0;
599
+ for (const group of sidebar) {
600
+ for (const item of group.items) {
601
+ if (typeof item !== 'string') continue; // the static Reference group
602
+ chapterByLessonId.set(lessonIdFromSlug(item), ++chapterCursor);
603
+ }
604
+ }
605
+
606
+ const keyedFloatLessonInputs = notationInput.lessons.map((lesson) => ({
607
+ lessonId: lesson.lessonId,
608
+ slug: lesson.lessonId.replace(/\./g, '/'),
609
+ file: lesson.source.file,
610
+ body: lesson.body,
611
+ chapter: chapterByLessonId.get(lesson.lessonId),
612
+ }));
613
+ const equationValidation = validateEquations(keyedFloatLessonInputs);
433
614
  const equationsBySlug = new Map(
434
615
  Object.entries(equationValidation.numbersBySlug).map(([slug, table]) => [
435
616
  slug,
436
617
  Object.entries(table).map(([key, number]) => ({ key, number })),
437
618
  ]),
438
619
  );
620
+ const tableValidation = validateTables(keyedFloatLessonInputs);
621
+ const tablesBySlug = new Map(
622
+ Object.entries(tableValidation.numbersBySlug).map(([slug, table]) => [
623
+ slug,
624
+ Object.entries(table).map(([key, number]) => ({ key, number })),
625
+ ]),
626
+ );
627
+ const figureValidation = validateFigures(keyedFloatLessonInputs);
628
+ const figuresBySlug = new Map(
629
+ Object.entries(figureValidation.numbersBySlug).map(([slug, table]) => [
630
+ slug,
631
+ Object.entries(table).map(([key, number]) => ({ key, number })),
632
+ ]),
633
+ );
439
634
 
440
635
  const lessons: ManifestLesson[] = [];
441
636
  for (const notationLesson of notationInput.lessons) {
@@ -450,6 +645,10 @@ export async function compileManifest(
450
645
  lessons.push({
451
646
  id,
452
647
  slug: id.replace(/\./g, '/'),
648
+ // 0 = the lesson is not placed in any configured section; a curriculum
649
+ // diagnostic covers that separately, and both numbering paths then fall
650
+ // back to the legacy `##`-section scheme.
651
+ chapter: chapterByLessonId.get(id) ?? 0,
453
652
  file: notationLesson.source.file,
454
653
  title: frontmatter.title,
455
654
  description: frontmatter.description,
@@ -461,6 +660,8 @@ export async function compileManifest(
461
660
  sources: deriveSources(notationLesson.body),
462
661
  citations: lessonCitations(notationLesson.body),
463
662
  equations: equationsBySlug.get(id.replace(/\./g, '/')) ?? [],
663
+ tables: tablesBySlug.get(id.replace(/\./g, '/')) ?? [],
664
+ figures: figuresBySlug.get(id.replace(/\./g, '/')) ?? [],
464
665
  prereqEdges: sortEdges(
465
666
  teaches.flatMap((to) =>
466
667
  (prerequisitesById.get(to) ?? [])
@@ -476,6 +677,7 @@ export async function compileManifest(
476
677
  definitionsById,
477
678
  availableDefinitionIds,
478
679
  ),
680
+ cardsByKey,
479
681
  ),
480
682
  });
481
683
  }
@@ -488,6 +690,9 @@ export async function compileManifest(
488
690
  catalog,
489
691
  lintIgnore: loadLintIgnore(),
490
692
  conventions: courseConfig.conventions,
693
+ ...(courseConfig.operators === undefined
694
+ ? {}
695
+ : { operators: courseConfig.operators }),
491
696
  });
492
697
 
493
698
  const rawNotation = [...loadNotationDefinitions()].sort((a, b) =>
@@ -499,17 +704,54 @@ export async function compileManifest(
499
704
  const sources = [...loadSourceRecords()].sort(byId);
500
705
  const tracks = catalog.tracks.map((entry) => ({ ...entry })).sort(byId);
501
706
 
502
- const lessonSlugs = [...notationInput.lessons]
503
- .map((lesson) => lesson.lessonId.replace(/\./g, '/'))
504
- .sort();
505
- const sidebar = buildSidebar(
506
- catalog.tracks.map((track) => ({ id: track.id, lessons: track.lessons })),
507
- lessonSlugs,
508
- courseConfig.sections,
509
- );
707
+ const hashes = contentHashes();
708
+
709
+ // Knowledge version (C3): `about.version` vs the newest `content/CHANGELOG.md`
710
+ // entry. Every finding is a Tier-3 warning; the resolved entry feeds the
711
+ // `[?]` About panel.
712
+ const knowledgeChangelogPath = join(CONTENT_ROOT, 'CHANGELOG.md');
713
+ const knowledge = resolveKnowledgeVersion({
714
+ changelogText: existsSync(knowledgeChangelogPath)
715
+ ? readFileSync(knowledgeChangelogPath, 'utf8')
716
+ : undefined,
717
+ declaredVersion: courseConfig.about.version,
718
+ file: relative(REPOSITORY_ROOT, knowledgeChangelogPath).replace(/\\/g, '/'),
719
+ });
720
+
721
+ const enginePkg = readEnginePackage();
722
+ const about: ManifestAbout = {
723
+ engine: {
724
+ name: enginePkg.name,
725
+ version: enginePkg.version,
726
+ ...(enginePkg.homepage === undefined
727
+ ? {}
728
+ : { homepage: enginePkg.homepage }),
729
+ },
730
+ material: {
731
+ name: courseConfig.about.materialName ?? courseConfig.title,
732
+ summary: courseConfig.about.summary ?? courseConfig.description,
733
+ },
734
+ knowledge: {
735
+ version: knowledge.version,
736
+ ...(knowledge.latest?.date === undefined
737
+ ? {}
738
+ : { date: knowledge.latest.date }),
739
+ notes: knowledge.latest?.notes ?? [],
740
+ contentTree: hashes.contentTree,
741
+ },
742
+ authors: courseConfig.about.authors.map((author) => ({
743
+ name: author.name,
744
+ ...(author.contact === undefined ? {} : { contact: author.contact }),
745
+ })),
746
+ disclaimer: courseConfig.about.disclaimer,
747
+ ...(courseConfig.about.changelogHref === undefined
748
+ ? {}
749
+ : { changelogHref: courseConfig.about.changelogHref }),
750
+ };
510
751
 
511
752
  return {
512
753
  schemaVersion: MANIFEST_SCHEMA_VERSION,
754
+ about,
513
755
  notation: {
514
756
  definitions: registry.definitions,
515
757
  bundles: registry.bundles,
@@ -527,6 +769,14 @@ export async function compileManifest(
527
769
  labels: equationValidation.labels,
528
770
  numbersBySlug: equationValidation.numbersBySlug,
529
771
  },
772
+ tables: {
773
+ labels: tableValidation.labels,
774
+ numbersBySlug: tableValidation.numbersBySlug,
775
+ },
776
+ figures: {
777
+ labels: figureValidation.labels,
778
+ numbersBySlug: figureValidation.numbersBySlug,
779
+ },
530
780
  diagnostics: {
531
781
  curriculum: curriculumIssues,
532
782
  notation: registry.diagnostics,
@@ -534,8 +784,11 @@ export async function compileManifest(
534
784
  math: mathIssues,
535
785
  consistency: consistencyIssues,
536
786
  equations: equationValidation.diagnostics,
787
+ tables: tableValidation.diagnostics,
788
+ figures: figureValidation.diagnostics,
789
+ knowledge: knowledge.diagnostics,
537
790
  },
538
- hashes: contentHashes(),
791
+ hashes,
539
792
  };
540
793
  }
541
794
 
@@ -2,6 +2,7 @@
2
2
  import { getCollection } from 'astro:content';
3
3
  import { loadManifest } from '../compiler/manifest';
4
4
  import { isLessonSlug, lessonIdFromSlug } from '../compiler/lesson-derivation';
5
+ import Diagram, { type DiagramEdge, type DiagramNode } from './diagram/Diagram.astro';
5
6
 
6
7
  const [tracks, docs, competencies, manifest] = await Promise.all([
7
8
  getCollection('tracks'),
@@ -22,6 +23,20 @@ const lessonById = new Map(
22
23
  const competencyById = new Map(
23
24
  competencies.map((entry) => [entry.data.id, entry.data]),
24
25
  );
26
+
27
+ // The dependency graph is read straight off the authored `prerequisites`
28
+ // lists — no separate content to keep in sync, and the same data the build's
29
+ // DAG/cycle validator already checks.
30
+ const competencyNodes: DiagramNode[] = competencies.map(({ data }) => ({
31
+ id: data.id,
32
+ text: data.title,
33
+ }));
34
+ const competencyEdges: DiagramEdge[] = competencies.flatMap(({ data }) =>
35
+ data.prerequisites.map((prerequisiteId) => ({
36
+ from: prerequisiteId,
37
+ to: data.id,
38
+ })),
39
+ );
25
40
  ---
26
41
 
27
42
  {
@@ -110,6 +125,18 @@ const competencyById = new Map(
110
125
  The build rejects unknown references, cycles, invalid lesson ordering, and
111
126
  insufficient assessment coverage.
112
127
  </p>
128
+
129
+ {
130
+ competencyNodes.length > 0 && (
131
+ <Diagram
132
+ id="competency-graph"
133
+ label="Competency dependency graph"
134
+ nodes={competencyNodes}
135
+ edges={competencyEdges}
136
+ />
137
+ )
138
+ }
139
+
113
140
  <ol class="competency-map">
114
141
  {
115
142
  competencies
@@ -11,6 +11,14 @@
11
11
  * Extra attributes (e.g. `data-notation-panel`, `data-citation-panel`) are
12
12
  * forwarded to the `<aside>` so each layer keeps the hook its tests and pin
13
13
  * controls already reference.
14
+ *
15
+ * `tabindex="-1"`: never in the tab order, but a valid target for
16
+ * `panel.focus()`. A layer that lets the reader click deeper into the panel's
17
+ * own content (D15's in-panel navigation) needs somewhere stable to move
18
+ * focus to before it replaces whatever the reader just clicked — otherwise
19
+ * removing the focused node drops focus to nothing, and the shared
20
+ * `focusout` handler in `./hover-panel.ts` reads that as "the reader left"
21
+ * and closes the panel it was just asked to update.
14
22
  */
15
23
  export interface Props {
16
24
  /** Element id — pin controls point `aria-controls` at it. */
@@ -31,8 +39,17 @@ const { id, label, class: className, ...rest } = Astro.props;
31
39
  aria-live="polite"
32
40
  aria-atomic="true"
33
41
  hidden
42
+ tabindex="-1"
34
43
  {...rest}
35
44
  >
45
+ <button
46
+ class="hover-panel-close"
47
+ type="button"
48
+ data-hover-panel-close
49
+ aria-label="Close"
50
+ >
51
+ <span aria-hidden="true">&times;</span>
52
+ </button>
36
53
  <slot />
37
54
  </aside>
38
55
 
@@ -48,6 +65,7 @@ const { id, label, class: className, ...rest } = Astro.props;
48
65
  width: min(23rem, calc(100vw - 1.5rem));
49
66
  overflow: auto;
50
67
  padding: 1rem;
68
+ padding-inline-end: 2.4rem;
51
69
  border: 1px solid var(--sl-color-gray-5);
52
70
  border-top: 0.25rem solid var(--sl-color-accent);
53
71
  border-radius: 0.65rem;
@@ -57,6 +75,30 @@ const { id, label, class: className, ...rest } = Astro.props;
57
75
  font-size: var(--sl-text-sm);
58
76
  }
59
77
 
78
+ .hover-panel-close {
79
+ position: absolute;
80
+ top: 0.5rem;
81
+ right: 0.5rem;
82
+ width: 1.6rem;
83
+ height: 1.6rem;
84
+ display: flex;
85
+ align-items: center;
86
+ justify-content: center;
87
+ padding: 0;
88
+ border: 0;
89
+ border-radius: 0.35rem;
90
+ color: var(--sl-color-gray-3);
91
+ background: transparent;
92
+ font-size: 1.15rem;
93
+ line-height: 1;
94
+ cursor: pointer;
95
+ }
96
+
97
+ .hover-panel-close:hover {
98
+ color: var(--sl-color-text);
99
+ background: var(--sl-color-gray-6);
100
+ }
101
+
60
102
  /* Slotted fields carry the owning layer's scope, not this component's, so
61
103
  the descendant match must be global. */
62
104
  .hover-panel :global(:focus-visible) {
@@ -108,6 +108,7 @@ import HoverPanel from '../HoverPanel.astro';
108
108
 
109
109
  createHoverPanel({
110
110
  panel,
111
+ groupId: 'citation',
111
112
  triggerFrom: markerFrom,
112
113
  keyFor: (element) => element.dataset.citationNumber ?? null,
113
114
  render,