explico 0.11.0 → 0.12.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 (63) hide show
  1. package/CHANGELOG.md +189 -1
  2. package/dist/compiler/astro.d.ts +7 -1
  3. package/dist/compiler/astro.js +5 -2
  4. package/dist/compiler/cli.d.ts +1 -0
  5. package/dist/compiler/collections.d.ts +25 -2
  6. package/dist/compiler/collections.js +37 -4
  7. package/dist/compiler/exports/standalone-html.d.ts +1 -0
  8. package/dist/compiler/manifest.d.ts +30 -4
  9. package/dist/compiler/manifest.js +49 -10
  10. package/dist/compiler/resolution-report.d.ts +1 -0
  11. package/dist/reference/base-operators.d.mts +6 -0
  12. package/dist/reference/base-operators.mjs +6 -8
  13. package/dist/reference/figure-symbols.d.ts +32 -0
  14. package/dist/reference/figure-symbols.js +55 -0
  15. package/dist/reference/gate-non-chapter-pages.d.ts +39 -0
  16. package/dist/reference/gate-non-chapter-pages.js +156 -0
  17. package/dist/reference/gate-part-pages.d.ts +3 -1
  18. package/dist/reference/gate-part-pages.js +2 -1
  19. package/dist/reference/lab-katex-validate.d.ts +43 -0
  20. package/dist/reference/lab-katex-validate.js +96 -0
  21. package/dist/reference/lint-ignore.js +1 -1
  22. package/dist/reference/page-notation.d.ts +5 -3
  23. package/dist/reference/page-notation.js +8 -8
  24. package/dist/reference/raw-floats.mjs +37 -13
  25. package/dist/reference/registry.js +11 -1
  26. package/dist/reference/remark-notation.mjs +195 -7
  27. package/dist/reference/render-lab-math.d.ts +11 -2
  28. package/dist/reference/render-lab-math.js +2 -2
  29. package/dist/rules/catalog.js +33 -3
  30. package/dist/rules/rules.schema.json +22 -4
  31. package/docs/VERSIONING.md +3 -1
  32. package/docs/authoring/README.md +2 -1
  33. package/docs/authoring/components.md +4 -4
  34. package/docs/authoring/migrating.md +271 -0
  35. package/docs/authoring/notation.md +4 -1
  36. package/docs/authoring/structure-rules.md +75 -5
  37. package/docs/authoring/structure.md +14 -0
  38. package/docs/authoring/visualizations.md +26 -10
  39. package/package.json +1 -1
  40. package/src/compiler/astro.ts +17 -6
  41. package/src/compiler/collections.ts +67 -9
  42. package/src/compiler/manifest.ts +89 -13
  43. package/src/components/CourseContents.astro +6 -0
  44. package/src/components/PartOverview.astro +6 -0
  45. package/src/components/abbreviation/AbbreviationGlossary.astro +6 -2
  46. package/src/components/disclosure/print-as-shown.ts +42 -6
  47. package/src/components/figure/Figure.astro +6 -1
  48. package/src/components/figure/figure-state.ts +26 -17
  49. package/src/components/starlight/LessonFooter.astro +27 -7
  50. package/src/components/table/Table.astro +1 -1
  51. package/src/reference/base-operators.mjs +6 -8
  52. package/src/reference/figure-symbols.ts +117 -0
  53. package/src/reference/gate-non-chapter-pages.ts +241 -0
  54. package/src/reference/gate-part-pages.ts +1 -1
  55. package/src/reference/lab-katex-validate.ts +156 -0
  56. package/src/reference/lint-ignore.ts +4 -1
  57. package/src/reference/page-notation.ts +14 -11
  58. package/src/reference/raw-floats.mjs +37 -13
  59. package/src/reference/registry.ts +16 -1
  60. package/src/reference/remark-notation.mjs +195 -7
  61. package/src/reference/render-lab-math.ts +12 -1
  62. package/src/rules/catalog.ts +37 -3
  63. package/src/styles/global.css +57 -38
@@ -12,11 +12,13 @@
12
12
  * `keyed-jsx-floats.mjs`'s top-level-only scan entirely, so it never even
13
13
  * reaches diagram numbering.
14
14
  *
15
- * A raw table nested in `<Table>`/`<Figure>` is the intended, wrapped case
16
- * and is not flagged; nesting inside anything else (`<CompactExample>`,
17
- * `<Aside>`, `<Memo>`, …) still is, because a raw table has no numbering
18
- * scheme of its own to opt into the way a `<Diagram>`'s top-level/`<Memo>`-
19
- * transparent scan does.
15
+ * A raw table counts only where wrapping it would number it: at the top level
16
+ * of the lesson body or in a `TRANSPARENT_COMPONENTS` body (`<Memo>`,
17
+ * `<ShortNote>`, `<Fold>`), exactly where `keyed-jsx-floats.mjs` numbers a
18
+ * `<Table>`. A raw table in `<Table>`/`<Figure>` is the intended, wrapped
19
+ * case; one in any other component (a worked example's `<CompactExample>`,
20
+ * an `<Aside>`) is illustrative, like an equation there, and a `<Table>`
21
+ * there would get no number — so neither is flagged.
20
22
  *
21
23
  * `scanRawFloats` (raw Markdown, for the manifest builder) and
22
24
  * `collectRawFloats` (mdast, for `remark-notation.mjs`) MUST agree on counts
@@ -24,7 +26,11 @@
24
26
  * `keyed-jsx-floats.mjs` holds `scanKeyedJsxFloats`/`collectKeyedJsxFloats` to.
25
27
  */
26
28
 
27
- import { stripFencedCode } from './equations.mjs';
29
+ import {
30
+ stripFencedCode,
31
+ stripJsxComponents,
32
+ TRANSPARENT_COMPONENTS,
33
+ } from './equations.mjs';
28
34
  import { collectKeyedJsxFloats, scanKeyedJsxFloats } from './keyed-jsx-floats.mjs';
29
35
 
30
36
  /** Blank `<Table>…</Table>` and `<Figure>…</Figure>` bodies — legitimately wrapped, not raw. */
@@ -67,9 +73,16 @@ function findDiagramTags(text) {
67
73
  export function scanRawFloats(markdown) {
68
74
  const codeStripped = stripFencedCode(String(markdown));
69
75
  const withoutWrappedFloats = blankFloatWrappers(codeStripped);
76
+ // Every component body but a transparent one is blanked — `<Table>` and
77
+ // `<Figure>` among them — so only the tables a `<Table>` would number remain.
78
+ const numberableText = stripJsxComponents(
79
+ codeStripped,
80
+ undefined,
81
+ TRANSPARENT_COMPONENTS,
82
+ );
70
83
 
71
- const markdownTableCount = findMarkdownTableStarts(withoutWrappedFloats).length;
72
- const htmlTableCount = findHtmlTableTags(withoutWrappedFloats).length;
84
+ const markdownTableCount = findMarkdownTableStarts(numberableText).length;
85
+ const htmlTableCount = findHtmlTableTags(numberableText).length;
73
86
 
74
87
  const diagramsOutsideWrappers = findDiagramTags(withoutWrappedFloats).length;
75
88
  const visibleDiagrams = scanKeyedJsxFloats(markdown, { tagName: 'Diagram' });
@@ -95,7 +108,10 @@ export function collectRawFloats(tree) {
95
108
  const htmlTableNodes = [];
96
109
  const allDiagramNodes = [];
97
110
 
98
- const walk = (node) => {
111
+ // `numberable`: a `<Table>` here would be numbered — the top level, or a
112
+ // transparent component's body. Any other MDX component (a capitalized
113
+ // name, the ones `stripJsxComponents` blanks) ends it for its subtree.
114
+ const walk = (node, numberable) => {
99
115
  if (!node || typeof node !== 'object') return;
100
116
  if (
101
117
  node.type === 'mdxJsxFlowElement' &&
@@ -103,18 +119,26 @@ export function collectRawFloats(tree) {
103
119
  ) {
104
120
  return; // legitimately wrapped — don't descend
105
121
  }
106
- if (node.type === 'table') markdownTableNodes.push(node);
107
- if (node.type === 'html' && /<table\b/i.test(String(node.value ?? ''))) {
122
+ if (numberable && node.type === 'table') markdownTableNodes.push(node);
123
+ if (
124
+ numberable &&
125
+ node.type === 'html' &&
126
+ /<table\b/i.test(String(node.value ?? ''))
127
+ ) {
108
128
  htmlTableNodes.push(node);
109
129
  }
110
130
  if (node.type === 'mdxJsxFlowElement' && node.name === 'Diagram') {
111
131
  allDiagramNodes.push(node);
112
132
  }
133
+ const component =
134
+ (node.type === 'mdxJsxFlowElement' || node.type === 'mdxJsxTextElement') &&
135
+ /^[A-Z]/.test(String(node.name ?? '')) &&
136
+ !TRANSPARENT_COMPONENTS.includes(node.name);
113
137
  if (Array.isArray(node.children)) {
114
- for (const child of node.children) walk(child);
138
+ for (const child of node.children) walk(child, numberable && !component);
115
139
  }
116
140
  };
117
- walk(tree);
141
+ walk(tree, true);
118
142
 
119
143
  const visibleDiagrams = collectKeyedJsxFloats(tree, { tagName: 'Diagram' });
120
144
  const visibleNodes = new Set(visibleDiagrams.map((diagram) => diagram.node));
@@ -167,6 +167,18 @@ export function buildNotationRegistry(
167
167
  }
168
168
  detectCycles(definitions, edgeIndex, diagnostics);
169
169
 
170
+ // A page's own `notation.local` entries are in its scope by construction:
171
+ // its math binds to one by shape — an operator entry `{}'` for `w'`, the
172
+ // fix rule N22 itself suggests — even where no `[[key]]` or `\explain`
173
+ // names it. Each seeds its page's bundle, so the page renders its card.
174
+ const localIdsByLesson = new Map<string, string[]>();
175
+ for (const candidate of definitions.values()) {
176
+ if (candidate.kind !== 'local') continue;
177
+ const ids = localIdsByLesson.get(candidate.lesson.lessonId) ?? [];
178
+ ids.push(candidate.id);
179
+ localIdsByLesson.set(candidate.lesson.lessonId, ids);
180
+ }
181
+
170
182
  const bundles: NotationPageBundle[] = [];
171
183
  const backlinks: NotationBacklink[] = [];
172
184
  const reachableDefinitions = new Set<string>();
@@ -176,7 +188,10 @@ export function buildNotationRegistry(
176
188
  const directIds = new Set(
177
189
  references.map((reference) => reference.definitionId),
178
190
  );
179
- const definitionIds = transitiveClosure(directIds, edgeIndex);
191
+ const definitionIds = transitiveClosure(
192
+ new Set([...directIds, ...(localIdsByLesson.get(lesson.lessonId) ?? [])]),
193
+ edgeIndex,
194
+ );
180
195
  for (const id of definitionIds) reachableDefinitions.add(id);
181
196
 
182
197
  const bindingByKey = new Map<string, string>();
@@ -46,6 +46,7 @@ import {
46
46
  equationAnchorId,
47
47
  parseEquationRef,
48
48
  stripEquationLabels,
49
+ TRANSPARENT_COMPONENTS,
49
50
  } from './equations.mjs';
50
51
  import {
51
52
  collectKeyedJsxFloats,
@@ -184,6 +185,25 @@ function lessonIdFrom(file) {
184
185
  return typeof lessonId === 'string' ? lessonId : undefined;
185
186
  }
186
187
 
188
+ /** True for any page under `content/docs/`; a lesson is one of them. */
189
+ function isDocsPageFile(file) {
190
+ return [file?.path, file?.history?.[0], file?.data?.file].some(
191
+ (value) => typeof value === 'string' && LESSON_DOC_PATH.test(value),
192
+ );
193
+ }
194
+
195
+ /**
196
+ * The blocks a chapter numbers (`makeFloatCtx` below, and the knowledge
197
+ * check). Rule S10 keeps each off a page that is not a chapter.
198
+ */
199
+ const NUMBERED_BLOCK_NAMES = new Set([
200
+ 'Table',
201
+ 'Figure',
202
+ 'Diagram',
203
+ 'CompactExamples',
204
+ 'AssessmentSet',
205
+ ]);
206
+
187
207
  const ANY_TERM_KEY = /\[\[\s*([^\][]+?)\s*\]\]/g;
188
208
  const ANY_EXPLAIN_KEY = /\\explain\s*\{([^{}]+)\}/g;
189
209
 
@@ -467,6 +487,130 @@ function injectFloatNumberAttributes(node, float, prefix) {
467
487
  });
468
488
  }
469
489
 
490
+ /**
491
+ * The URL path Starlight serves a docs page at: `motion/index.mdx` is
492
+ * `/motion/`, `motion/speed.mdx` is `/motion/speed/`, `index.mdx` is `/`.
493
+ */
494
+ function pageUrlFrom(file) {
495
+ for (const value of [file?.path, file?.history?.[0], file?.data?.file]) {
496
+ if (typeof value !== 'string') continue;
497
+ const match = value.match(LESSON_DOC_PATH);
498
+ if (!match) continue;
499
+ const route = match[1].replace(/\\/g, '/').replace(/(?:^|\/)index$/, '');
500
+ return route ? `/${route}/` : '/';
501
+ }
502
+ return undefined;
503
+ }
504
+
505
+ /**
506
+ * The chapter slug a link's `href` names — `motion/speed` for
507
+ * `/motion/speed/#sec-x`, for `../speed/` from a sibling page, and for
508
+ * `/base/motion/speed/` under the course's `base` — or `undefined` for a
509
+ * same-page anchor, another site, or a scheme such as `mailto:`.
510
+ */
511
+ function linkedSlug(href, pageUrl, base) {
512
+ if (typeof href !== 'string' || href === '' || href.startsWith('#')) return undefined;
513
+ if (href.startsWith('//') || /^[a-z][a-z0-9+.-]*:/i.test(href)) return undefined;
514
+ let url;
515
+ try {
516
+ url = new URL(href, `https://course.invalid${pageUrl ?? '/'}`);
517
+ } catch {
518
+ return undefined;
519
+ }
520
+ let path;
521
+ try {
522
+ path = decodeURIComponent(url.pathname);
523
+ } catch {
524
+ return undefined;
525
+ }
526
+ const prefix = base && base !== '/' ? base.replace(/\/+$/, '') : '';
527
+ if (prefix && (path === prefix || path.startsWith(`${prefix}/`))) {
528
+ path = path.slice(prefix.length);
529
+ }
530
+ return path.replace(/^\/+|\/+$/g, '');
531
+ }
532
+
533
+ /**
534
+ * A link the author wrote to another chapter shows that chapter's full title
535
+ * as its native tooltip (`title`): prose names a chapter any way it likes —
536
+ * "the previous chapter", "Chapter 1.2", "this derivation" — and a reader can
537
+ * see where the link goes before following it. A Markdown link and a JSX
538
+ * `<a href>` both get it; a link with a title of its own, or one to the page
539
+ * it is on, keeps what it has.
540
+ */
541
+ function titleChapterLinks(tree, file, titleBySlug, base) {
542
+ if (titleBySlug.size === 0) return;
543
+ const pageUrl = pageUrlFrom(file);
544
+ const here = pageUrl?.replace(/^\/+|\/+$/g, '');
545
+ const titleOf = (href) => {
546
+ const slug = linkedSlug(href, pageUrl, base);
547
+ return slug !== undefined && slug !== here ? titleBySlug.get(slug) : undefined;
548
+ };
549
+ const walk = (node) => {
550
+ if (!node || typeof node !== 'object') return;
551
+ if (node.type === 'link' && !node.title) {
552
+ const title = titleOf(node.url);
553
+ if (title) node.title = title;
554
+ } else if (
555
+ (node.type === 'mdxJsxTextElement' || node.type === 'mdxJsxFlowElement') &&
556
+ node.name === 'a'
557
+ ) {
558
+ const attributes = node.attributes ?? [];
559
+ const named = (name) =>
560
+ attributes.find((attribute) => attribute?.type === 'mdxJsxAttribute' && attribute.name === name);
561
+ const title = named('title') ? undefined : titleOf(named('href')?.value);
562
+ if (title) {
563
+ node.attributes = [...attributes, { type: 'mdxJsxAttribute', name: 'title', value: title }];
564
+ }
565
+ }
566
+ if (Array.isArray(node.children)) node.children.forEach(walk);
567
+ };
568
+ walk(tree);
569
+ }
570
+
571
+ /**
572
+ * The innermost component around `target` that numbering does not see
573
+ * through — the reason a float in it has no number — or `undefined`.
574
+ */
575
+ function blockingComponent(tree, target) {
576
+ const search = (node, blocking) => {
577
+ if (node === target) return blocking;
578
+ if (!node || typeof node !== 'object' || !Array.isArray(node.children)) return undefined;
579
+ const here =
580
+ node.type === 'mdxJsxFlowElement' &&
581
+ node.name &&
582
+ !TRANSPARENT_COMPONENTS.includes(node.name)
583
+ ? node.name
584
+ : blocking;
585
+ for (const child of node.children) {
586
+ const found = search(child, here);
587
+ if (found !== undefined) return found;
588
+ }
589
+ return undefined;
590
+ };
591
+ return search(tree, undefined);
592
+ }
593
+
594
+ /**
595
+ * Why a `<Table>`/`<Figure>` on a lesson page got no number: it sits in a
596
+ * component other than the `TRANSPARENT_COMPONENTS`, where a float is
597
+ * illustrative, as an equation there is (`keyed-jsx-floats.mjs`).
598
+ */
599
+ function unnumberedFloatMessage(tagName, component) {
600
+ const where = component ? `<${component}>` : 'another component';
601
+ const noun = tagName === 'Table' ? 'table' : 'figure';
602
+ const instead =
603
+ tagName === 'Table'
604
+ ? 'write it as a plain Markdown table (rule S3 does not flag one there)'
605
+ : 'show its content without <Figure>';
606
+ return (
607
+ `<${tagName}> inside ${where} gets no number: a lesson numbers a ${noun} ` +
608
+ `at its top level or in a <Memo>, <ShortNote> or <Fold>. A ${noun} in a ` +
609
+ `worked example or another component is illustrative, like an equation ` +
610
+ `there: ${instead}, or move the <${tagName}> out of ${where}.`
611
+ );
612
+ }
613
+
470
614
  /** Resolve a `[[eq-key]]` / `[[slug#eq-key]]` reference to `{ number, href }`. */
471
615
  function resolveEquationRef(eqRef, eqCtx, file, node) {
472
616
  if (eqRef.lesson) {
@@ -1287,6 +1431,9 @@ export default function remarkNotation(options = {}) {
1287
1431
  // The manifest supplies every numbering input at once; an explicit option
1288
1432
  // still overrides it, so existing per-field wiring keeps working.
1289
1433
  const numbering = { ...numberingFromManifest(options.manifest), ...options };
1434
+ const chapterTitleBySlug = new Map(
1435
+ (options.manifest?.lessons ?? []).map((lesson) => [lesson.slug, lesson.title]),
1436
+ );
1290
1437
  // Every lesson's headings by anchor, built once: `[[lesson#sec-…]]` reads
1291
1438
  // another page's, `[[sec-…]]` the page's own.
1292
1439
  const sectionSettings = { show: true, depth: 4, ...numbering.sectionNumbering };
@@ -1310,6 +1457,9 @@ export default function remarkNotation(options = {}) {
1310
1457
  const suspendedOperators = suspendedOperatorsFromFile(file);
1311
1458
  const lessonId = lessonIdFrom(file);
1312
1459
  const lessonSlug = lessonId ? lessonId.replace(/\./g, '/') : undefined;
1460
+ // The landing page, a part's overview page (rule S10): its math binds
1461
+ // like a chapter's, but it numbers nothing.
1462
+ const nonChapterPage = !lessonId && isDocsPageFile(file);
1313
1463
  // `{ prefix, offset }` per kind of float — see the `numbering` option.
1314
1464
  // Missing ⇒ the legacy `##`-section numbering.
1315
1465
  const equationNumbering = pageNumbering(numbering, lessonSlug, 'equations');
@@ -1329,6 +1479,13 @@ export default function remarkNotation(options = {}) {
1329
1479
  const equationLabels = collectEquationLabels(tree, equationNumbering);
1330
1480
  const pageLabels = new Map();
1331
1481
  for (const label of equationLabels) {
1482
+ if (nonChapterPage) {
1483
+ fail(
1484
+ file,
1485
+ `\\label{eq:${label.key}} on a page that is not a chapter (rule S10): only a chapter numbers an equation. Drop the label to show the equation unnumbered, or link the chapter's own with [[<chapter>#eq-<key>]].`,
1486
+ label.node,
1487
+ );
1488
+ }
1332
1489
  if (pageLabels.has(label.key)) {
1333
1490
  fail(
1334
1491
  file,
@@ -1568,14 +1725,19 @@ export default function remarkNotation(options = {}) {
1568
1725
 
1569
1726
  inspectExplainCalls(value, resolve, file, node, references);
1570
1727
 
1571
- const bindingScope = lessonId
1572
- ? mathScope
1573
- : registryFile
1574
- ? registryMathDefinitions
1575
- : undefined;
1728
+ const bindingScope =
1729
+ lessonId || nonChapterPage
1730
+ ? mathScope
1731
+ : registryFile
1732
+ ? registryMathDefinitions
1733
+ : undefined;
1576
1734
 
1577
1735
  if (bindingScope) {
1578
- const context = lessonId ? 'lesson math' : 'notation definition math';
1736
+ const context = lessonId
1737
+ ? 'lesson math'
1738
+ : nonChapterPage
1739
+ ? 'page math'
1740
+ : 'notation definition math';
1579
1741
 
1580
1742
  let result;
1581
1743
  try {
@@ -1608,7 +1770,9 @@ export default function remarkNotation(options = {}) {
1608
1770
  .join(', ');
1609
1771
  fail(
1610
1772
  file,
1611
- `Unresolved notation in ${context}: ${unresolved}. Introduce the symbol with [[key]], define it in notation.local, or wrap it in \\explain{key}{latex}.`,
1773
+ nonChapterPage
1774
+ ? `Unresolved notation in ${context}: ${unresolved}. Introduce the symbol with [[key]], or wrap it in \\explain{key}{latex}: a page that is not a chapter recalls shared notation and defines none (rule S10).`
1775
+ : `Unresolved notation in ${context}: ${unresolved}. Introduce the symbol with [[key]], define it in notation.local, or wrap it in \\explain{key}{latex}.`,
1612
1776
  node,
1613
1777
  );
1614
1778
  }
@@ -1668,6 +1832,27 @@ export default function remarkNotation(options = {}) {
1668
1832
  injectFloatNumberAttributes(child, diagramByNode.get(child), 'dia');
1669
1833
  } else if (child?.type === 'mdxJsxFlowElement' && exampleByNode.has(child)) {
1670
1834
  injectFloatNumberAttributes(child, exampleByNode.get(child), 'ex');
1835
+ } else if (
1836
+ nonChapterPage &&
1837
+ child?.type === 'mdxJsxFlowElement' &&
1838
+ NUMBERED_BLOCK_NAMES.has(child.name)
1839
+ ) {
1840
+ // `validate:content` reports this first (gate-non-chapter-pages.ts);
1841
+ // this is the same message for a build that skipped it.
1842
+ fail(
1843
+ file,
1844
+ `<${child.name}> on a page that is not a chapter (rule S10): only a chapter numbers it. Move it into the chapter it belongs to, and link it from here: the link shows it in its card.`,
1845
+ child,
1846
+ );
1847
+ } else if (
1848
+ lessonId &&
1849
+ child?.type === 'mdxJsxFlowElement' &&
1850
+ (child.name === 'Table' || child.name === 'Figure')
1851
+ ) {
1852
+ // Numbered everywhere a lesson numbers one, so this one is nested
1853
+ // in a component that is not transparent. Say so here, at its
1854
+ // line, before the component fails on its missing number.
1855
+ fail(file, unnumberedFloatMessage(child.name, blockingComponent(tree, child)), child);
1671
1856
  }
1672
1857
  }
1673
1858
  };
@@ -1727,6 +1912,9 @@ export default function remarkNotation(options = {}) {
1727
1912
  }
1728
1913
  }
1729
1914
 
1915
+ // Before `visit`, which writes the `[[…]]` reference links: each of
1916
+ // those opens a card of its own.
1917
+ titleChapterLinks(tree, file, chapterTitleBySlug, options.base);
1730
1918
  visit(tree);
1731
1919
 
1732
1920
  file.data.notationReferences = references;
@@ -12,6 +12,16 @@ export interface LabMathScopeDefinition {
12
12
  readonly notation: string;
13
13
  }
14
14
 
15
+ export interface RenderLabMathOptions {
16
+ /**
17
+ * `false` renders the template as inline math — a `.katex` with no
18
+ * `.katex-display` — for a formula that sits in a line: a legend entry,
19
+ * an axis title, a control label. It never scrolls (`global.css`
20
+ * `.lab-math`). Default `true`, a display equation.
21
+ */
22
+ readonly display?: boolean;
23
+ }
24
+
15
25
  interface BindResult {
16
26
  latex: string;
17
27
  bindings: ReadonlyArray<{ key: string }>;
@@ -36,6 +46,7 @@ const sentinelFor = (index: number): string => `909909${index}.909909`;
36
46
  export function renderLabMath(
37
47
  template: string,
38
48
  scope: readonly LabMathScopeDefinition[],
49
+ { display = true }: RenderLabMathOptions = {},
39
50
  ): RenderedLabMath {
40
51
  const slots: Array<{ name: string; fallback: string }> = [];
41
52
  const forBinding = template.replace(
@@ -95,7 +106,7 @@ export function renderLabMath(
95
106
 
96
107
  const html = katex.renderToString(
97
108
  bound,
98
- createNotationKatexOptions({ displayMode: true }),
109
+ createNotationKatexOptions({ displayMode: display }),
99
110
  );
100
111
 
101
112
  return {
@@ -613,8 +613,15 @@ const RULES: readonly RuleEntry[] = [
613
613
  elements: ['plot'],
614
614
  doc: 'visualizations.md#v3',
615
615
  summary:
616
- 'An interactive figure is an island under `content/labs/` inside a `<Figure>`, renders its equations through `renderLabMath`, and labels its controls; the figure’s caption is its name, so the lab writes no accessible name or text alternative of its own.',
617
- checks: [],
616
+ 'An interactive figure is an island under `content/labs/` inside a `<Figure>`, renders its equations through `renderLabMath` (`{ display: false }` for a legend, an axis title or a label; a `{ math }` run for the caption clause) and never KaTeX directly, and labels its controls; the figure’s caption is its name, so the lab writes no accessible name or text alternative of its own.',
617
+ checks: [
618
+ check('lab-katex-direct', 'error', false, {
619
+ module: 'src/reference/lab-katex-validate.ts',
620
+ fn: 'validateLabKatexImports',
621
+ }),
622
+ ],
623
+ unchecked:
624
+ 'that a lab labels its controls and writes no name of its own, and math a lab draws without importing `katex` — the build’s rendered check (`figure-symbols.ts`) covers the page it renders.',
618
625
  },
619
626
  {
620
627
  kind: 'rule',
@@ -799,7 +806,7 @@ const RULES: readonly RuleEntry[] = [
799
806
  elements: ['table'],
800
807
  doc: 'structure-rules.md#s3',
801
808
  summary:
802
- 'Wrap every pipe table or `<table>` in `<Table id="…" caption="…">` so it gets a number and `[[tab-…]]` can reach it.',
809
+ 'Wrap every pipe table or `<table>` in `<Table id="…" caption="…">` so it gets a number and `[[tab-…]]` can reach it; a table in a worked example stays plain.',
803
810
  checks: [
804
811
  check('raw-table-markdown', 'warning', false, {
805
812
  module: 'src/reference/raw-floats-validate.ts',
@@ -906,6 +913,33 @@ const RULES: readonly RuleEntry[] = [
906
913
  'Give each figure, table and diagram a `short` caption that names it: it opens the caption line in bold, and names the float in "Hidden here", a copied link and the "Figures and tables" page. The long `caption` goes on from that name to say what the float shows — its subject, its marks and symbols, the case shown — and at most the one thing to notice. The argument drawn from a figure is lesson text after it, and a lab’s instructions are the first line of its `tools`.',
907
914
  checks: [],
908
915
  },
916
+ {
917
+ kind: 'rule',
918
+ id: 'S10',
919
+ short: 'A page that is not a chapter explains its symbols but numbers nothing: it links a chapter’s float instead',
920
+ title:
921
+ 'A page that is not a chapter — the landing page, a part’s overview page — explains its symbols like a chapter, but carries no numbered float: it links the chapter’s float instead.',
922
+ elements: ['part-page', 'plot', 'table', 'diagram', 'equation', 'notation'],
923
+ doc: 'structure-rules.md#s10',
924
+ summary:
925
+ 'On `content/docs/index.mdx`, a `content/docs/<part>/index.mdx`, or any page outside a part: `[[key]]`, `\\explain`, math, citations and `[[chapter#tab-key]]` links work and open cards, and the math and prose gates apply. No `<Table>`, `<Figure>`, `<Diagram>`, `<CompactExamples>`, `<AssessmentSet>`, lab, `\\label{eq:…}` or `notation.local`/`notation.suspend`; no `$…$` or `[[…]]` in `hero.title`, `hero.tagline` or a `<PartOverview chain>` step.',
926
+ checks: [
927
+ check('non-chapter-float', 'error', true, {
928
+ module: 'src/reference/gate-non-chapter-pages.ts',
929
+ fn: 'gateNonChapterPages',
930
+ }),
931
+ check('non-chapter-local-notation', 'error', false, {
932
+ module: 'src/reference/gate-non-chapter-pages.ts',
933
+ fn: 'gateNonChapterPages',
934
+ }),
935
+ check('non-chapter-plain-text', 'error', false, {
936
+ module: 'src/reference/gate-non-chapter-pages.ts',
937
+ fn: 'gateNonChapterPages',
938
+ }),
939
+ ],
940
+ locked:
941
+ 'a float needs a chapter number, and such a page has none — turning the rule off would not render one.',
942
+ },
909
943
 
910
944
  // ---- P — prose --------------------------------------------------------------
911
945
  {
@@ -149,14 +149,15 @@ body {
149
149
  color: var(--sl-color-text);
150
150
  }
151
151
 
152
- /* Clip only the inline axis: `overflow-x: auto` alone makes the block axis
153
- compute to `auto` too, which adds a spurious vertical scrollbar around
154
- display math. */
155
- .lab-math {
152
+ /* A lab's math (`renderLabMath`), in the element the lab puts it in. The
153
+ wrapper never scrolls: a display template's `.katex-display` scrolls
154
+ sideways when it is wider than its box, like a lesson equation (below),
155
+ so a lab equation is one scroll container at most, and one that fits
156
+ draws no scrollbar. An inline template (`{ display: false }` — a legend
157
+ entry, an axis title, a control label) has no `.katex-display`: it sits
158
+ in its line, with no margin and nothing to scroll. */
159
+ .lab-math:has(.katex-display) {
156
160
  margin-block: 1.25rem;
157
- padding-block: 0.15rem;
158
- overflow-x: auto;
159
- overflow-y: hidden;
160
161
  }
161
162
 
162
163
  .lab-math .katex-display {
@@ -171,12 +172,20 @@ body {
171
172
  edge is KaTeX's metric strut — but glyph ink overshoots its metrics (a
172
173
  denominator's `g` descender, a stretched `\right]`), so the bottom of the
173
174
  equation was cut off. The padding is the room that ink needs; em-based so
174
- it scales with the math. */
175
- .sl-markdown-content .katex-display {
175
+ it scales with the math.
176
+ The inline padding is KaTeX's own: every stacked script box
177
+ (`.vlist-t2`) has `margin-right: -2px` and holds a 2px spacer cell, which
178
+ lies outside the box's layout width but inside its scrollable overflow.
179
+ An equation within 2px of its column's width scrolled by those 2px for
180
+ nothing. Padding both sides reserves them and keeps a centred equation
181
+ centred. The same holds for a lab's display math (`.lab-math`, above). */
182
+ .sl-markdown-content .katex-display,
183
+ .lab-math .katex-display {
176
184
  max-width: 100%;
177
185
  overflow-x: auto;
178
186
  overflow-y: hidden;
179
187
  padding-block: 0.25em;
188
+ padding-inline: 2px;
180
189
  }
181
190
 
182
191
  /* Focusable only while it overflows (`KeyedFloatEnhancer.astro`), so it can
@@ -1043,28 +1052,24 @@ details.layout-part-has-open > summary > .group-label {
1043
1052
  display: none;
1044
1053
  }
1045
1054
 
1046
- /* A panel the reader narrowed past its own labels scrolls sideways rather
1047
- than wrapping every chapter title into a column of single words. */
1048
- html[data-navigation-resized] .layout-navigation-content :is(a, summary) {
1049
- white-space: nowrap;
1055
+ /* A label that does not fit wraps onto more lines, and the side panels
1056
+ never scroll sideways: a word longer than the panel is wide is
1057
+ hyphenated where the language allows, else broken. Starlight's
1058
+ `overflow-wrap: anywhere` on the contents links broke words at any
1059
+ letter before hyphenation got a chance. */
1060
+ .layout-navigation-content,
1061
+ .layout-page-sidebar-content,
1062
+ .layout-page-sidebar-content a {
1063
+ overflow-wrap: break-word;
1064
+ hyphens: auto;
1050
1065
  }
1051
1066
 
1052
- html[data-navigation-resized] .sidebar-pane {
1053
- overflow-x: auto;
1054
- }
1055
-
1056
- /* A compact chapter's tooltip is a child of one of those links, and it is
1057
- the one label that must still wrap — it is sized by its own max-width,
1058
- not by the panel, so inheriting `nowrap` would run its text straight out
1059
- of its own background. */
1060
- html[data-navigation-resized]
1061
- .layout-compact-sidebar
1062
- details[data-compact]
1063
- > ul:not(.top-level)
1064
- > li
1065
- > a
1066
- > span {
1067
- white-space: normal;
1067
+ /* With every label wrapping, what still reaches past the navigation's
1068
+ edge is a compact chapter's resting tooltip (absolutely placed, and
1069
+ invisible): it would only add a sideways scrollbar. A showing tooltip is
1070
+ `position: fixed` (below), so this clip never cuts it. */
1071
+ .sidebar-pane {
1072
+ overflow-x: hidden;
1068
1073
  }
1069
1074
 
1070
1075
  html[data-navigation-hidden] {
@@ -1249,15 +1254,15 @@ details.layout-part-has-open > summary > .group-label {
1249
1254
  max-width: none;
1250
1255
  }
1251
1256
 
1252
- /* Starlight hides this panel's scrollbar (`scrollbar-width: none`), which
1253
- would quietly cut a heading the reader narrowed the panel past. */
1254
- html[data-contents-resized]:not([data-page-sidebar-hidden]) .right-sidebar {
1255
- overflow-x: auto;
1256
- scrollbar-width: thin;
1257
- }
1258
-
1259
- html[data-contents-resized] .layout-page-sidebar-content :is(a, h2) {
1260
- white-space: nowrap;
1257
+ /* Starlight sizes the column inside from the navigation's width
1258
+ (`--sl-sidebar-width`), not from this panel: in a panel the reader set,
1259
+ it fills the panel, so its headings wrap at the panel's edge instead of
1260
+ running past it. */
1261
+ html[data-contents-resized]:not([data-page-sidebar-hidden])
1262
+ .right-sidebar-panel
1263
+ .sl-container {
1264
+ width: auto;
1265
+ max-width: none;
1261
1266
  }
1262
1267
 
1263
1268
  html[data-page-sidebar-hidden] {
@@ -1362,6 +1367,15 @@ details.layout-part-has-open > summary > .group-label {
1362
1367
  z-index: calc(var(--sl-z-index-navbar) + 1);
1363
1368
  }
1364
1369
 
1370
+ /* Below 50rem Starlight's menu button is not in the header: it is a fixed
1371
+ button in `nav.sidebar`, at the navbar's level, laid over the header's
1372
+ end. It rises above the raised header, or the header's box takes every
1373
+ tap and the navigation never opens on a phone. Starlight hides the
1374
+ button from 50rem up, so the rule needs no media query. */
1375
+ starlight-menu-button > button {
1376
+ z-index: calc(var(--sl-z-index-navbar) + 2);
1377
+ }
1378
+
1365
1379
  /* A slimmer site header: 3rem at every width, where Starlight's is 3.5rem
1366
1380
  and 4rem from 50rem up. The title, the logo (sized from the height and
1367
1381
  padding) and the search box scale with it, so more of the window goes to
@@ -2024,7 +2038,11 @@ details.layout-part-has-open > summary > .group-label {
2024
2038
  gap: 0.25rem;
2025
2039
  }
2026
2040
 
2041
+ /* The line is at least as tall as its buttons (`.float-tool`'s height), so
2042
+ the row never hangs below it — past a figure's body, or into a table's
2043
+ header — even when a course hides the caption's text. */
2027
2044
  .sl-markdown-content .float-caption:has(> .float-tools > .float-tool:not([hidden])) {
2045
+ min-block-size: 1.5rem;
2028
2046
  padding-inline-end: 2.25rem;
2029
2047
  }
2030
2048
 
@@ -2096,6 +2114,7 @@ details.layout-part-has-open > summary > .group-label {
2096
2114
  }
2097
2115
 
2098
2116
  .sl-markdown-content .float-caption:has(> .float-tools) {
2117
+ min-block-size: auto;
2099
2118
  padding-inline-end: 0;
2100
2119
  }
2101
2120
  }