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
@@ -106,6 +106,13 @@ function buildNotationRegistry(rawInput) {
106
106
  );
107
107
  }
108
108
  detectCycles(definitions, edgeIndex, diagnostics);
109
+ const localIdsByLesson = /* @__PURE__ */ new Map();
110
+ for (const candidate of definitions.values()) {
111
+ if (candidate.kind !== "local") continue;
112
+ const ids = localIdsByLesson.get(candidate.lesson.lessonId) ?? [];
113
+ ids.push(candidate.id);
114
+ localIdsByLesson.set(candidate.lesson.lessonId, ids);
115
+ }
109
116
  const bundles = [];
110
117
  const backlinks = [];
111
118
  const reachableDefinitions = /* @__PURE__ */ new Set();
@@ -114,7 +121,10 @@ function buildNotationRegistry(rawInput) {
114
121
  const directIds = new Set(
115
122
  references.map((reference) => reference.definitionId)
116
123
  );
117
- const definitionIds = transitiveClosure(directIds, edgeIndex);
124
+ const definitionIds = transitiveClosure(
125
+ /* @__PURE__ */ new Set([...directIds, ...localIdsByLesson.get(lesson.lessonId) ?? []]),
126
+ edgeIndex
127
+ );
118
128
  for (const id of definitionIds) reachableDefinitions.add(id);
119
129
  const bindingByKey = /* @__PURE__ */ new Map();
120
130
  for (const reference of references) {
@@ -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;
@@ -5,12 +5,21 @@ interface LabMathScopeDefinition {
5
5
  readonly key: string;
6
6
  readonly notation: string;
7
7
  }
8
+ interface RenderLabMathOptions {
9
+ /**
10
+ * `false` renders the template as inline math — a `.katex` with no
11
+ * `.katex-display` — for a formula that sits in a line: a legend entry,
12
+ * an axis title, a control label. It never scrolls (`global.css`
13
+ * `.lab-math`). Default `true`, a display equation.
14
+ */
15
+ readonly display?: boolean;
16
+ }
8
17
  /**
9
18
  * Compile a developer-authored lab math template the same way lesson `$$`
10
19
  * math is compiled: every identifier must resolve to a notation key in the
11
20
  * page's scope or this throws. Slots are set aside during binding and restored
12
21
  * as inert `data-lab-slot` spans the interactive island rewrites at runtime.
13
22
  */
14
- declare function renderLabMath(template: string, scope: readonly LabMathScopeDefinition[]): RenderedLabMath;
23
+ declare function renderLabMath(template: string, scope: readonly LabMathScopeDefinition[], { display }?: RenderLabMathOptions): RenderedLabMath;
15
24
 
16
- export { type LabMathScopeDefinition, RenderedLabMath, renderLabMath };
25
+ export { type LabMathScopeDefinition, type RenderLabMathOptions, RenderedLabMath, renderLabMath };
@@ -4,7 +4,7 @@ import { createNotationKatexOptions } from "./katex-options.mjs";
4
4
  import { slotPlaceholder } from "./lab-math-slot.js";
5
5
  const SLOT_PATTERN = /\\slot\{([a-z][a-z0-9]*)\}\{((?:[^{}]|\{[^{}]*\})*)\}/g;
6
6
  const sentinelFor = (index) => `909909${index}.909909`;
7
- function renderLabMath(template, scope) {
7
+ function renderLabMath(template, scope, { display = true } = {}) {
8
8
  const slots = [];
9
9
  const forBinding = template.replace(
10
10
  SLOT_PATTERN,
@@ -50,7 +50,7 @@ function renderLabMath(template, scope) {
50
50
  });
51
51
  const html = katex.renderToString(
52
52
  bound,
53
- createNotationKatexOptions({ displayMode: true })
53
+ createNotationKatexOptions({ displayMode: display })
54
54
  );
55
55
  return {
56
56
  html,
@@ -387,8 +387,14 @@ const RULES = [
387
387
  title: "Interactive figures: a hydrated island and the `renderLabMath` seam for equations.",
388
388
  elements: ["plot"],
389
389
  doc: "visualizations.md#v3",
390
- summary: "An interactive figure is an island under `content/labs/` inside a `<Figure>`, renders its equations through `renderLabMath`, and labels its controls; the figure\u2019s caption is its name, so the lab writes no accessible name or text alternative of its own.",
391
- checks: []
390
+ summary: "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\u2019s caption is its name, so the lab writes no accessible name or text alternative of its own.",
391
+ checks: [
392
+ check("lab-katex-direct", "error", false, {
393
+ module: "src/reference/lab-katex-validate.ts",
394
+ fn: "validateLabKatexImports"
395
+ })
396
+ ],
397
+ unchecked: "that a lab labels its controls and writes no name of its own, and math a lab draws without importing `katex` \u2014 the build\u2019s rendered check (`figure-symbols.ts`) covers the page it renders."
392
398
  },
393
399
  {
394
400
  kind: "rule",
@@ -555,7 +561,7 @@ const RULES = [
555
561
  title: 'A table is a `<Table id="\u2026">`, never a raw Markdown or HTML table.',
556
562
  elements: ["table"],
557
563
  doc: "structure-rules.md#s3",
558
- summary: 'Wrap every pipe table or `<table>` in `<Table id="\u2026" caption="\u2026">` so it gets a number and `[[tab-\u2026]]` can reach it.',
564
+ summary: 'Wrap every pipe table or `<table>` in `<Table id="\u2026" caption="\u2026">` so it gets a number and `[[tab-\u2026]]` can reach it; a table in a worked example stays plain.',
559
565
  checks: [
560
566
  check("raw-table-markdown", "warning", false, {
561
567
  module: "src/reference/raw-floats-validate.ts",
@@ -653,6 +659,30 @@ const RULES = [
653
659
  summary: '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 \u2014 its subject, its marks and symbols, the case shown \u2014 and at most the one thing to notice. The argument drawn from a figure is lesson text after it, and a lab\u2019s instructions are the first line of its `tools`.',
654
660
  checks: []
655
661
  },
662
+ {
663
+ kind: "rule",
664
+ id: "S10",
665
+ short: "A page that is not a chapter explains its symbols but numbers nothing: it links a chapter\u2019s float instead",
666
+ title: "A page that is not a chapter \u2014 the landing page, a part\u2019s overview page \u2014 explains its symbols like a chapter, but carries no numbered float: it links the chapter\u2019s float instead.",
667
+ elements: ["part-page", "plot", "table", "diagram", "equation", "notation"],
668
+ doc: "structure-rules.md#s10",
669
+ summary: "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:\u2026}` or `notation.local`/`notation.suspend`; no `$\u2026$` or `[[\u2026]]` in `hero.title`, `hero.tagline` or a `<PartOverview chain>` step.",
670
+ checks: [
671
+ check("non-chapter-float", "error", true, {
672
+ module: "src/reference/gate-non-chapter-pages.ts",
673
+ fn: "gateNonChapterPages"
674
+ }),
675
+ check("non-chapter-local-notation", "error", false, {
676
+ module: "src/reference/gate-non-chapter-pages.ts",
677
+ fn: "gateNonChapterPages"
678
+ }),
679
+ check("non-chapter-plain-text", "error", false, {
680
+ module: "src/reference/gate-non-chapter-pages.ts",
681
+ fn: "gateNonChapterPages"
682
+ })
683
+ ],
684
+ locked: "a float needs a chapter number, and such a page has none \u2014 turning the rule off would not render one."
685
+ },
656
686
  // ---- P — prose --------------------------------------------------------------
657
687
  {
658
688
  kind: "rule",
@@ -812,6 +812,24 @@
812
812
  }
813
813
  }
814
814
  },
815
+ "S10": {
816
+ "title": "S10 — A page that is not a chapter explains its symbols but numbers nothing: it links a chapter’s float instead",
817
+ "description": "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. Locked — a float needs a chapter number, and such a page has none — turning the rule off would not render one.",
818
+ "x-element": "part-page",
819
+ "x-elements": [
820
+ "part-page",
821
+ "plot",
822
+ "table",
823
+ "diagram",
824
+ "equation",
825
+ "notation"
826
+ ],
827
+ "x-enforcement": "locked",
828
+ "x-doc": "docs/authoring/structure-rules.md#s10",
829
+ "type": "object",
830
+ "additionalProperties": false,
831
+ "properties": {}
832
+ },
815
833
  "V2": {
816
834
  "title": "V2 — Static charts: SVG → D3 → Observable Plot, chosen per figure",
817
835
  "description": "For a static chart pick the lightest tool that draws it: inline SVG, then D3, then Observable Plot. State the choice in the figure’s source comment.",
@@ -834,19 +852,19 @@
834
852
  },
835
853
  "V3": {
836
854
  "title": "V3 — Interactive figures: a hydrated island + the `renderLabMath` seam",
837
- "description": "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.",
855
+ "description": "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. Not checked by the build: 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.",
838
856
  "x-element": "plot",
839
857
  "x-elements": [
840
858
  "plot"
841
859
  ],
842
- "x-enforcement": "guide",
860
+ "x-enforcement": "partial",
843
861
  "x-doc": "docs/authoring/visualizations.md#v3",
844
862
  "type": "object",
845
863
  "additionalProperties": false,
846
864
  "properties": {
847
865
  "enabled": {
848
866
  "title": "Enabled",
849
- "description": "Off: the rule leaves the rules-in-effect page and an agent neither applies it nor flags it in review.",
867
+ "description": "Off: the check does not run, and the rule leaves the rules-in-effect page.",
850
868
  "type": "boolean",
851
869
  "default": true
852
870
  }
@@ -990,7 +1008,7 @@
990
1008
  },
991
1009
  "S3": {
992
1010
  "title": "S3 — A table is a `<Table id=\"…\">`, never a raw Markdown/HTML table",
993
- "description": "Wrap every pipe table or `<table>` in `<Table id=\"…\" caption=\"…\">` so it gets a number and `[[tab-…]]` can reach it.",
1011
+ "description": "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.",
994
1012
  "x-element": "table",
995
1013
  "x-elements": [
996
1014
  "table"
@@ -105,7 +105,9 @@ applies every intervening section in order.
105
105
  the `migrating.md` section.
106
106
  6. Run `pnpm build && pnpm test`, then inspect `pnpm pack --dry-run` and confirm
107
107
  that `docs/authoring/migrating.md` and `docs/authoring/agents.md` are listed.
108
- 7. Commit, then `git tag vx.y.z`.
108
+ 7. Commit, then tag it annotated: `git tag -a vx.y.z -m "explico x.y.z"`. With
109
+ `push.followTags` on, a plain `git push` sends an annotated tag with its
110
+ commit; a lightweight tag never travels that way.
109
111
 
110
112
  ## When you change the engine
111
113
 
@@ -257,7 +257,7 @@ of to the settings file.
257
257
  | [N22](notation.md#n22) | math, notation | An accent or mark is an operator: universal, explained, or a whole form — settings: `allow` = `[]`, `deny` = `[]` | Script — `accent-explained` (warning), `suspended-operator-unresolved`, `operator-suspension-reason` (warning), `operator-suspended` (info), `operator-shadowed` (info) in [`consistency.ts`](../../src/reference/consistency.ts) `checkAccentExplained`, `checkOperatorSuspension`; [`gate-math.ts`](../../src/reference/gate-math.ts) `resolveInto` |
258
258
  | [V1](visualizations.md#v1) | diagram | Graphs and flowcharts use the `Diagram` component | Guide |
259
259
  | [V2](visualizations.md#v2) | plot | Static charts: SVG → D3 → Observable Plot, chosen per figure | Guide |
260
- | [V3](visualizations.md#v3) | plot | Interactive figures: a hydrated island + the `renderLabMath` seam | Guide |
260
+ | [V3](visualizations.md#v3) | plot | Interactive figures: a hydrated island + the `renderLabMath` seam | Partial — `lab-katex-direct` in [`lab-katex-validate.ts`](../../src/reference/lab-katex-validate.ts) `validateLabKatexImports`. review: 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 |
261
261
  | [V4](visualizations.md#v4) | plot | Randomised figures expose an adjustable, deterministic seed | Guide |
262
262
  | [V5](visualizations.md#v5) | plot | Axis ranges span the first 10 seeds at the current control values — settings: `seeds` = `10` | Guide |
263
263
  | [V6](visualizations.md#v6) | plot | Every quantitative axis shows at least three labelled values — settings: `minLabels` = `3` | Guide |
@@ -273,6 +273,7 @@ of to the settings file.
273
273
  | [S7](structure-rules.md#s7) | heading | A heading is followed by body content, never by another heading | Script — `heading-adjacent` in [`gate-heading-structure.ts`](../../src/reference/gate-heading-structure.ts) `gateHeadingStructure` |
274
274
  | [S8](structure-rules.md#s8) | prose | Prefer `<Memo>` over a Starlight aside when the content needs an anchor, a caption or collapsing | Guide |
275
275
  | [S9](structure-rules.md#s9) | plot, table, diagram | A float’s short caption names it; the long caption goes on from that name | Guide |
276
+ | [S10](structure-rules.md#s10) | part-page, plot, table, diagram, equation, notation | A page that is not a chapter explains its symbols but numbers nothing: it links a chapter’s float instead | **Locked** — a float needs a chapter number, and such a page has none — turning the rule off would not render one. Script — `non-chapter-float`, `non-chapter-local-notation`, `non-chapter-plain-text` in [`gate-non-chapter-pages.ts`](../../src/reference/gate-non-chapter-pages.ts) `gateNonChapterPages` |
276
277
  | [P1](prose.md#p1) | prose | "or" never introduces a second name for the same thing | Guide |
277
278
  | [P2](prose.md#p2) | prose | One term for each concept, repeated — no synonyms for variety | Guide |
278
279
  | [P3](prose.md#p3) | prose | One meaning for each term; no technical term in its everyday sense | Guide |
@@ -141,9 +141,8 @@ on a `<CompactExamples>`, whose heading it is, and is what
141
141
  [rule S1](structure-rules.md#s1) measures — the long caption is never capped.
142
142
 
143
143
  A figure's, a table's or a diagram's `short` is its name wherever the page
144
- names it (docs/plans/figures-and-short-captions.md, decision 9): the bold
145
- title that opens its caption line — "**Sampled flight data.** Position, …",
146
- with a period added unless it ends in punctuation of its own — a folded
144
+ names it: the bold title that opens its caption line — "**Sampled flight
145
+ data.** Position, …", with a period added unless it ends in punctuation of its own — a folded
147
146
  section's "Hidden here: **Figure 2.1.1** Projectile trajectory explorer, 2
148
147
  paragraphs", a `<Fold>`'s bar, and a copied link, which says "Link copied ·
149
148
  Figure 2.1.1 · Projectile trajectory explorer" and pastes into a document as
@@ -168,7 +167,8 @@ is shown.
168
167
  `<Figure>`, `defaultOpen` (default: the course's `disclosure.figures`) and
169
168
  `rememberState` (default `true`) are about the tools; a figure without
170
169
  them has no button and no tray, and a `<Diagram>` has neither prop. The
171
- caption line ends with a live clause a lab writes with `setFigureState`
170
+ caption line ends with a live clause a lab writes with `setFigureState`,
171
+ in text and `{ math }` runs, so each symbol in it opens its card
172
172
  ([visualizations.md § V3](visualizations.md#v3)).
173
173
  - **A lab has no title or caption of its own.** The `<Figure>` around it
174
174
  names the whole ([visualizations.md § Labs](visualizations.md#v3)).