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
@@ -33,6 +33,277 @@ required config block throws with the section of this file to read.
33
33
 
34
34
  ---
35
35
 
36
+ ## 0.11.1 → 0.12.0
37
+
38
+ **Course changes may be required.** Four changes can fail a build that
39
+ passed on 0.11.1:
40
+
41
+ - a lab that calls KaTeX itself (rule V3, rule N4; step 2);
42
+ - a caption run typed as text in `setFigureState` (step 3);
43
+ - a numbered float, a lab, an equation label or a notation block on the
44
+ landing page or a part's overview page (rule S10; step 4);
45
+ - a symbol that page shows without introducing it, now that its math binds
46
+ like a chapter's (rule S10, rule N4; step 5).
47
+
48
+ The rest is additive, or a fix a course may clean up after (steps 6–9).
49
+ Nothing in `astro.config.mjs` changes, and no `CourseConfig` field is added
50
+ or removed. The manifest schema is now v25 (step 8).
51
+
52
+ ### 1. Update the dependency
53
+
54
+ ```bash
55
+ pnpm add -D explico@0.12.0
56
+ ```
57
+
58
+ ### 2. A lab never calls KaTeX itself (rule V3, rule N4)
59
+
60
+ `pnpm validate:content` now follows each lesson's relative imports into the
61
+ course's components and fails, as `lab-katex-direct`, a file that imports
62
+ `katex` (a type-only import and `katex/dist/katex.min.css` are fine). It
63
+ names the chain of imports that reaches the file. `astro build` then fails
64
+ any letter KaTeX drew inside a `<Figure>` outside a notation binding
65
+ (`figure-symbols.ts`). Upright text (`\text{}`, `\mathrm{}`, `\sin`) is
66
+ exempt. Find the offenders:
67
+
68
+ ```bash
69
+ rg -n "from ['\"]katex['\"]|require\(['\"]katex['\"]\)" content src
70
+ ```
71
+
72
+ For each helper that renders math with `katex.renderToString` (an
73
+ `InlineMath`, an SVG label, a legend), render the math at build time in the
74
+ lab's `.astro` wrapper instead, and hand it to the island as a prop:
75
+
76
+ ```astro
77
+ ---
78
+ import { labMathScope } from 'explico/reference/lab-math-scope';
79
+ import { renderLabMath, type RenderedLabMath } from 'explico/reference/render-lab-math';
80
+
81
+ const entry = Astro.locals.starlightRoute.entry;
82
+ const scope = await labMathScope(
83
+ (entry.data as { notation?: { local?: Array<{ key: string; latex: string }> } }).notation,
84
+ `${entry.body ?? ''}\n${Object.values(MY_LAB_MATH).join('\n')}`,
85
+ { locals: Astro.locals, context: 'MyLab symbols' },
86
+ );
87
+ const symbols: Record<string, RenderedLabMath> = {};
88
+ for (const [id, template] of Object.entries(MY_LAB_MATH)) {
89
+ symbols[id] = renderLabMath(template, scope, { display: false });
90
+ }
91
+ ---
92
+ <MyLabPlot client:visible symbols={symbols} />
93
+ ```
94
+
95
+ `{ display: false }` is new in this release. It renders inline math for a
96
+ legend entry, an axis title or a control label, and it never scrolls. The
97
+ island then places the pre-rendered HTML where it drew text before.
98
+ `playground/content/labs/ProjectileRangePicture.astro` and
99
+ `LaunchAnglePicture.astro` in the explico repository are complete examples,
100
+ and `node_modules/explico/docs/authoring/visualizations.md#v3` is the rule.
101
+
102
+ If the course has browser tests, add the same check for what an island draws
103
+ only in the browser:
104
+
105
+ ```ts
106
+ import { unboundFigureSymbols } from 'explico/reference/figure-symbols';
107
+ expect(unboundFigureSymbols(await figure.evaluate((el) => el.outerHTML))).toEqual([]);
108
+ ```
109
+
110
+ ### 3. `setFigureState` runs: a string or `{ math }`
111
+
112
+ ```bash
113
+ rg -n "setFigureState|italic:\s*true|\{\s*sub:" content src
114
+ ```
115
+
116
+ The `{ text, italic: true }`, `{ text }` and `{ sub }` runs are gone. One
117
+ passed anyway throws, naming the fix, and `astro check` fails it, since
118
+ `FigureStateRun` is now `string | { math: RenderedLabMath }`. Write plain
119
+ text as a string. Write a symbol as `{ math: symbols.launchSpeed }`, rendered
120
+ as in step 2 with `renderLabMath(…, { display: false })`. The caption's
121
+ symbol then opens its card. `ProjectileRangeExplorer.tsx` in the playground
122
+ shows the finished clause.
123
+
124
+ ### 4. A page that is not a chapter numbers nothing (rule S10)
125
+
126
+ This covers `content/docs/index.mdx`, every `content/docs/<part>/index.mdx`,
127
+ and any other page outside a part directory. `pnpm validate:content` fails
128
+ each of the following there. The rule is locked, so it cannot be turned off
129
+ or waived.
130
+
131
+ ```bash
132
+ rg -n "<(Table|Figure|Diagram|CompactExamples|AssessmentSet)[\s/>]|client:|\\\\label\{eq:" \
133
+ content/docs/*.mdx content/docs/*/index.mdx
134
+ ```
135
+
136
+ | Diagnostic | What it found | The edit |
137
+ | --- | --- | --- |
138
+ | `non-chapter-float` | `<Table>`, `<Figure>`, `<Diagram>`, `<CompactExamples>`, `<AssessmentSet>` | Move it into the chapter it belongs to, and link it from here with `[[<chapter>#tab-<key>]]` (`fig-`, `dia-`, `ex-`, `check-`). The link's card previews it. A `<Diagram>` rendered here unnumbered on 0.11.1. |
139
+ | `non-chapter-float` | a component imported from a `labs/` module, or any `client:*` island | Move it into a chapter's `<Figure>` and link that figure. |
140
+ | `non-chapter-float` | `\label{eq:…}` | Drop the label (the equation shows unnumbered), or link the chapter's equation with `[[<chapter>#eq-<key>]]`. |
141
+ | `non-chapter-local-notation` | `notation.local` or `notation.suspend` in the frontmatter | Move the entry to the chapter that introduces the symbol, or to shared notation (`content/notation/`), and name it with `[[key]]`. |
142
+ | `non-chapter-plain-text` | `$…$` or `[[…]]` in `hero.title`, `hero.tagline` or a `<PartOverview chain>` step | Write words. These strings are plain text and print markup literally; a symbol with its card goes in the page body. |
143
+
144
+ A plain Markdown table has no caption and no number, so it stays allowed.
145
+
146
+ ### 5. Math on such a page binds like a chapter's (rule S10, rule N4)
147
+
148
+ The landing page and the overview pages now open cards: `[[key]]`, bound
149
+ math, citations, abbreviations and `[[chapter#tab-key]]` links, from the
150
+ page's own notation bundle. That bundle holds shared entries only. The same
151
+ gates as a chapter's now scan these pages: rule N4, rule N8, rule N9,
152
+ rule N10, rule N12, rule N14, rule N22, and the course's `X` rules. Plain
153
+ `$v$` that rendered with no card on 0.11.1 now fails the build. Run
154
+ `pnpm validate:content` and fix each finding as in a chapter:
155
+
156
+ - **An unresolved symbol:** introduce it with `[[key]]` in the prose before
157
+ it, or wrap it in `\explain{key}{…}`. The key must be a shared one.
158
+ - **A quantity, unit or operator in prose (rule N9), a `\mathrm{}` label
159
+ (rule N10), punctuation after display math (rule N12), an abbreviation
160
+ (rule N14), a decoration (rule N22):** fix it as the rule page says.
161
+ A waivable rule can be waived at one place with
162
+ `{/* explico-allow <ID>: <reason> */}` here as in a chapter.
163
+
164
+ Rule S2's "its math and `[[key]]` references render" in a part page's
165
+ `<PartBridge>` and `<PartScope>` now holds. A course that kept symbols off
166
+ its overview pages because `[[key]]` failed the build (data-science-I's
167
+ `tests/source-structure.test.mjs` check, for example) can drop that
168
+ workaround and write its recaps with symbols.
169
+
170
+ ### 6. The operator baseline keeps only `\lim` (rule N8)
171
+
172
+ `\limsup`, `\liminf`, `\injlim`, `\projlim`, `\varlimsup`, `\varliminf`,
173
+ `\varinjlim` and `\varprojlim` left `BASE_OPERATORS`. A course that uses one
174
+ now gets rule N8's `operator-explained` warning.
175
+
176
+ ```bash
177
+ rg -n '\\(lim(sup|inf)|injlim|projlim|varlim(sup|inf)|varinjlim|varprojlim)\b' content
178
+ ```
179
+
180
+ For each hit, give the operator a notation entry, or allow it for the whole
181
+ course in `rules.N8.allow` (`course.settings.json`, or `rules` in
182
+ `course.config.ts`):
183
+
184
+ ```json
185
+ "rules": { "N8": { "allow": ["limsup", "liminf"] } }
186
+ ```
187
+
188
+ ### 7. Clean-ups the fixes allow
189
+
190
+ Each is conditional; no hit means nothing to do.
191
+
192
+ - **Rule S3 waivers for a table in a worked example.** A table inside
193
+ `<CompactExamples>` no longer reports `raw-table-markdown` or
194
+ `raw-table-html`, so a waiver written for it is now reported as
195
+ `waiver-unused`. Delete each
196
+ `{/* explico-allow S3: … */}` that `pnpm validate:content` names.
197
+ - **Link colour on part pages and in `<CourseContents>`.** Links there use
198
+ `--sl-color-text-accent` now. Drop a course rule that set it
199
+ (`rg -n "\.part-overview a|\.course-contents a" src content`).
200
+ - **Workarounds for the menu button under the header on a phone, the
201
+ abbreviation glossary's anchors (`#abbr-…`), a figure's one-pixel vertical
202
+ scrollbar, or a 2 px scroll on a display equation that just fits.** Each
203
+ is fixed in the engine's stylesheet or component; drop the course's own
204
+ rule for it.
205
+ - **A `lint-ignore.yml` line for a kebab-case key.** A `<detail>` may now
206
+ hold a hyphen (`- notation-units:arg-min — …`). A course that shortened or
207
+ dropped such a line to get the file to load can write it as intended.
208
+ - **An `unused-definition` acknowledgement for a `notation.local` entry a
209
+ page uses only by shape** (rule N22's `{}'` for `w'`) is no longer needed.
210
+ The page's own local entries now seed its bundle.
211
+
212
+ ### 8. Manifest schema v25, and other code-facing changes
213
+
214
+ Only for course code that reads `build/manifest.json` or calls engine
215
+ helpers directly:
216
+
217
+ - The manifest schema is now v25. A new `pages` array lists every docs page
218
+ that is not a chapter, each with an `id` (`index`, `<part>.index`,
219
+ `glossary`), a `slug`, a `file` and a `notation` bundle of the lesson
220
+ shape. `notation.bundles` and `notation.backlinks` now carry these page
221
+ ids too, so code that looks each id up in `lessons` must skip one it does
222
+ not find. Update any `schemaVersion` assertion to 25.
223
+ - `notationDefinitionsForRenderedPage(manifest, pageId, dependencies)` takes
224
+ a lesson id or a `manifest.pages[].id`. Its errors, and the local-notation
225
+ parity error, are reworded ("on page …", "notation mismatch"). Update a
226
+ test that matched the old text.
227
+ - `.lab-math` no longer scrolls or pads; its `.katex-display` does. A
228
+ float's caption line is at least `1.5rem` tall while it shows a tool
229
+ button.
230
+
231
+ ### 9. Things readers see, with no course action
232
+
233
+ A link the author wrote to another chapter now shows that chapter's full
234
+ title as its tooltip (a `title` attribute), unless it has a title of its
235
+ own. An indented `[[key]]` inside a component in an `.mdx` page (a Starlight
236
+ `<Card>`, say) reaches the page's bundle, where 0.11.1 failed the build.
237
+ Printing a page no longer breaks a knowledge check. Re-record screenshots
238
+ that show a caption line, a lab's math, or a chapter link's tooltip.
239
+
240
+ ### 10. Verify
241
+
242
+ ```bash
243
+ pnpm validate:content # run twice: the first run regenerates build/resolution/*
244
+ pnpm content check --changed
245
+ astro check
246
+ astro build
247
+ ```
248
+
249
+ Then run the course's browser tests, if it has any.
250
+
251
+ ## 0.11.0 → 0.11.1
252
+
253
+ **No course changes required.** This release has one fix, in the engine's
254
+ stylesheet. A label in the navigation or the "On this page" panel that does
255
+ not fit now wraps onto more lines instead of scrolling sideways, and that
256
+ includes a panel the reader resized. No authoring rule, `CourseConfig` field,
257
+ Astro wiring, course script or CLI command changes. The manifest schema stays
258
+ v24.
259
+
260
+ ### 1. Update the dependency
261
+
262
+ ```bash
263
+ pnpm add -D explico@0.11.1
264
+ ```
265
+
266
+ **Coming from 0.8.0:** follow the "Coming from 0.8.0" note of "0.10.0 →
267
+ 0.11.0" below, and install 0.11.1 wherever it says to install 0.11.0.
268
+
269
+ ### 2. Only if your course CSS or browser tests reach into the side panels
270
+
271
+ ```bash
272
+ rg -n 'data-(navigation|contents)-resized|\.sidebar-pane|\.right-sidebar|layout-(navigation|page-sidebar)-content' src content tests
273
+ ```
274
+
275
+ No hit means nothing to do. Otherwise:
276
+
277
+ - **A resized panel's labels** have `white-space: normal` (it was `nowrap`),
278
+ and the panel never scrolls sideways. A test that asserted `nowrap`, or
279
+ `scrollWidth > clientWidth` after narrowing a panel, now asserts that the
280
+ labels wrap and stay inside the panel.
281
+ - **`.sidebar-pane`** has `overflow-x: hidden` at every width. The resized
282
+ "On this page" panel no longer sets `overflow-x: auto` or
283
+ `scrollbar-width: thin`, so it hides its scrollbar, as Starlight does by
284
+ default.
285
+ - **In a resized "On this page" panel**, the column
286
+ (`.right-sidebar-panel .sl-container`) fills the panel. Its width used to
287
+ come from the navigation's `--sl-sidebar-width`. The default,
288
+ shrink-to-fit rail is unchanged.
289
+ - **Word breaking.** Both panels' labels take `overflow-wrap: break-word`
290
+ and `hyphens: auto`, which replaces Starlight's `overflow-wrap: anywhere`
291
+ on the contents links. To break words differently, set these properties
292
+ on `.layout-navigation-content`, `.layout-page-sidebar-content` and
293
+ `.layout-page-sidebar-content a` in the course CSS.
294
+ - **Screenshots** of a narrowed panel need re-recording.
295
+
296
+ ### 3. Verify
297
+
298
+ ```bash
299
+ pnpm validate:content # run twice: the first run regenerates build/resolution/*
300
+ pnpm content check --changed
301
+ astro check
302
+ astro build
303
+ ```
304
+
305
+ Then run the course's browser tests, if it has any.
306
+
36
307
  ## 0.10.0 → 0.11.0
37
308
 
38
309
  **Course changes are required.** Figures and diagrams no longer compact.
@@ -225,7 +225,10 @@ validate:content`; `astro build` re-runs the same resolver) fails on an
225
225
  unresolved glyph (`unresolved-glyph`) in every rendered-math context;
226
226
  `consistency.ts` adds the corpus-level lints `glyph-unique-in-corpus`
227
227
  (warning), `weak-local`, `gloss-wants-promoting`, `card-wants-demoting`
228
- (info); a final rendered-trigger/payload parity check backs it at render time.
228
+ (info); a final rendered-trigger/payload parity check backs it at render time,
229
+ and a last pass over every built page fails a symbol a figure shows that KaTeX
230
+ drew outside a notation binding (`figure-symbols.ts`: a lab that called KaTeX
231
+ itself — see [V3](visualizations.md#v3)).
229
232
  This rule is **locked** — the notation layer is this rule, so a course cannot
230
233
  turn it off.
231
234
 
@@ -1,7 +1,8 @@
1
1
  # Structure rules
2
2
 
3
3
  `S` rules govern the shape of a lesson's headings, the short captions on its
4
- keyed floats, the paragraphs that open a part page, and how a table, a
4
+ keyed floats, the paragraphs that open a part page, what a page that is not
5
+ a chapter may carry, and how a table, a
5
6
  diagram, or a convention is declared so the rest of the course can reach it —
6
7
  the parts of a page a reader scans, links to, or cites. This
7
8
  is not [structure.md](structure.md), which is the taxonomy these rules (and
@@ -109,7 +110,8 @@ launch angle that gives the largest range.
109
110
  ```
110
111
 
111
112
  Keep a blank line inside each tag, so MDX parses the paragraph as Markdown and
112
- its math and `[[key]]` references render.
113
+ its math and `[[key]]` references render with their cards
114
+ ([S10](#s10)). A part page recalls symbols; it defines none.
113
115
 
114
116
  The word range is a course setting, `rules.S2` in `course.settings.json`
115
117
  (`CourseConfig.rules`). Each end defaults to 30–150 words; list only what
@@ -184,9 +186,13 @@ validate:content`) — a warning per lesson (`raw-table-markdown`,
184
186
  `raw-table-html`); `remarkNotation` repeats it at render time for content
185
187
  added since the manifest was compiled. It does not fail the build.
186
188
 
187
- _Exceptions._ A table inside a component that already gives it identity is
188
- out of scope: the scan only counts tables at the top level of the lesson
189
- body.
189
+ _Exceptions._ The scan counts a table only where a `<Table>` would number
190
+ it: at the top level of the lesson body, or in a `<Memo>`, `<ShortNote>` or
191
+ `<Fold>`, which keep their place in the reading order. A table already inside
192
+ a `<Table>` or `<Figure>` has its identity. A table in a worked example
193
+ (`<CompactExample>`) or in another component is illustrative, like an
194
+ equation there: leave it a plain Markdown table. A `<Table>` there gets no
195
+ number, and the build fails on it with that reason.
190
196
 
191
197
  ## S4
192
198
 
@@ -449,3 +455,67 @@ title from one that builds on it.
449
455
  _Exceptions._ A float that needs no name of its own may go without a
450
456
  `short`; the "Figures and tables" page then lists it by its long caption,
451
457
  muted.
458
+
459
+ ## S10
460
+
461
+ **A page that is not a chapter — the landing page, a part’s overview page —
462
+ explains its symbols like a chapter, but carries no numbered float: it links
463
+ the chapter’s float instead.**
464
+
465
+ This applies to `content/docs/index.mdx`, to every
466
+ `content/docs/<part>/index.mdx`, and to any other page outside a part
467
+ directory. What makes a card works there as in a chapter:
468
+
469
+ - a `[[key]]` term and `\explain{key}{…}` in math, each opening its card;
470
+ - plain math, bound symbol by symbol, so every symbol it shows is explained;
471
+ - a citation `[@id]`, an abbreviation, and a link to a chapter’s float,
472
+ `[[motion/constant-acceleration#tab-kinematics-summary]]`, whose card
473
+ previews the table.
474
+
475
+ The math and prose rules hold there too ([N4](notation.md#n4),
476
+ [N9](notation.md#n9), [N10](notation.md#n10), [N14](notation.md#n14), and a
477
+ course’s `X` rules). What such a page may not carry:
478
+
479
+ | Not here | Instead |
480
+ | --- | --- |
481
+ | `<Table>`, `<Figure>`, `<Diagram>`, `<CompactExamples>`, `<AssessmentSet>` | Link the chapter’s float: `[[<chapter>#tab-<key>]]` (`fig-`, `dia-`, …). |
482
+ | A lab, or any `client:*` island | Link the chapter’s `<Figure>` that holds it. |
483
+ | `\label{eq:…}` on a display equation | Drop the label (the equation shows unnumbered), or link the chapter’s equation. |
484
+ | `notation.local`, `notation.suspend` | Define the symbol in the chapter that introduces it, or in shared notation, and name it with `[[key]]`. |
485
+ | `$…$` or `[[…]]` in `hero.title`, `hero.tagline`, a `<PartOverview chain>` step | Write words; these strings are plain text and print markup literally. |
486
+
487
+ ```mdx
488
+ <PartScope>
489
+
490
+ Part 1 ended on a point that moves under a constant acceleration
491
+ [[motion.acceleration]]. The worked examples in
492
+ [[motion/constant-acceleration#tab-kinematics-summary]] give its velocity.
493
+
494
+ </PartScope>
495
+ ```
496
+
497
+ A plain Markdown table is ordinary text on such a page: it has no caption and
498
+ no number, so it is not a float.
499
+
500
+ _Rationale._ A float number starts with its chapter’s number, and a page that
501
+ is not a chapter has none: a table on a part page would need a number that
502
+ collides with a chapter’s, or no number, which makes it the one float in the
503
+ course a reference cannot reach. A part page in a printed book points to the
504
+ chapters’ floats and does not copy them. The reference card gives the reader
505
+ the same view without the copy. Symbols are different: an overview that
506
+ recalls a result needs its symbols, and a symbol without its card breaks the
507
+ promise every other page keeps.
508
+
509
+ _How to apply._ Write the overview’s recap with the chapter’s own symbols,
510
+ each introduced with `[[key]]`. Where the recap leans on a table, figure or
511
+ equation, link it instead of repeating it. If a symbol has no shared entry,
512
+ recall it in words or give it one in `content/notation/`.
513
+
514
+ _Enforcement._ Build-enforced (`gate-non-chapter-pages.ts`,
515
+ `pnpm validate:content`): `non-chapter-float` for a numbered block, a lab or
516
+ an equation label; `non-chapter-local-notation` for a notation block;
517
+ `non-chapter-plain-text` for markup in a plain-text string. `astro build`
518
+ fails the same numbered block and equation label at render time. This rule is
519
+ **locked**: turning it off would not give the page a number to print.
520
+
521
+ _Exceptions._ None.
@@ -154,6 +154,14 @@ missing, holds more than one paragraph, or falls outside the course's
154
154
  takes no float numbers, and never appears in `manifest.lessons` or a track. A
155
155
  course adds one part at a time; a part without one behaves exactly as before.
156
156
 
157
+ **It explains its symbols, and links its floats**
158
+ ([S10](structure-rules.md#s10)). A `[[key]]` term, math, a citation, an
159
+ abbreviation and a `[[chapter#tab-key]]` link each open their card on an
160
+ overview page, as on the landing page and any other page outside a part; the
161
+ manifest lists each such page under `pages` with its notation bundle. A
162
+ `<Table>`, `<Figure>`, `<Diagram>`, lab or `\label{eq:…}` stays in its
163
+ chapter, which alone can number it, and the overview links it.
164
+
157
165
  `<PartOverview>` reads the manifest for everything it lists — chapters in
158
166
  reading order with their descriptions, the competencies the part teaches, the
159
167
  parts it builds on and leads to — so the page cannot fall out of date with the
@@ -432,6 +440,12 @@ that wants them addressed should wrap the table in `<Table id="…">`/
432
440
  `<Figure id="…">`, or give the diagram an `id` and move it to the top level (or
433
441
  inside `<Memo>`/`<ShortNote>`), or wrap it in `<Figure>`.
434
442
 
443
+ A raw table counts only where a `<Table>` would number it: at the top level,
444
+ or in a `<Memo>`, `<ShortNote>` or `<Fold>`. One in a worked example
445
+ (`<CompactExample>`) or another component is illustrative, like an equation
446
+ there, and stays a plain table: a `<Table>` or a `<Figure>` there gets no
447
+ number, and the build fails on it, naming the component it sits in.
448
+
435
449
  ### Knowledge checks
436
450
 
437
451
  An assessment title is authored once, in `content/assessments/<id>.json`, and
@@ -115,11 +115,15 @@ equations.**
115
115
  An interactive figure is a React island, hydrated `client:visible` (or
116
116
  `client:idle`). It carries:
117
117
 
118
- - **Gate-checked math.** Any equation shown in the figure goes through the
118
+ - **Gate-checked math.** Every symbol the figure shows — an equation, a
119
+ legend entry, an axis title, a slider label — goes through the
119
120
  `labMathScope` / `renderLabMath` / `\slot{}` seam so its symbols are resolved
120
121
  against the page's real notation at build time and the island only patches
121
- numbers into pre-rendered KaTeX. A wrapper whose template names notation not
122
- otherwise present in lesson MDX passes
122
+ numbers into pre-rendered KaTeX. `renderLabMath(template, scope)` renders a
123
+ display equation; `renderLabMath(template, scope, { display: false })`
124
+ renders inline math for a formula that sits in a line, which never scrolls.
125
+ A lab never calls KaTeX itself: its symbols would open no card. A wrapper
126
+ whose template names notation not otherwise present in lesson MDX passes
123
127
  `{ locals: Astro.locals, context: '<ComponentName> <template>' }` as the third
124
128
  `labMathScope` argument; that is the declaration that ships those definitions
125
129
  in this page's popup payload. See
@@ -136,8 +140,7 @@ An interactive figure is a React island, hydrated `client:visible` (or
136
140
  that describes the plot ("the dot marks the landing point") belongs in the
137
141
  figure's caption.
138
142
  - **Picture, then caption, then tools.** A lab is laid out as a LaTeX figure
139
- with its controls after it (docs/plans/figures-and-short-captions.md,
140
- decision 3). The plot goes in the `<Figure>`'s default slot — its
143
+ with its controls after it. The plot goes in the `<Figure>`'s default slot — its
141
144
  picture — and the controls in its `tools` slot: an instruction line, the
142
145
  sliders and fields, the live equation. The two are separate islands that
143
146
  share their values. The tools render after the caption line, in a tray of
@@ -151,10 +154,13 @@ An interactive figure is a React island, hydrated `client:visible` (or
151
154
  case shown — "Shown for v₀ = 20 m/s and θ = 30°: range 35.3 m, time of
152
155
  flight 2.04 s." The lab writes it with `setFigureState(element, runs)`
153
156
  (`explico/components/figure/figure-state.ts`), `element` being any element
154
- of its own inside the figure and `runs` plain text, `{ text, italic: true }`
155
- or `{ sub }` — never HTML. The clause prints with the caption; a reader
156
- of the long caption elsewhere (a preview card, a fold's bar) sees the
157
- caption alone.
157
+ of its own inside the figure and each run a string of plain text or
158
+ `{ math }`, a symbol rendered at build time with
159
+ `renderLabMath(template, scope, { display: false })` and handed to the
160
+ island — never HTML, and never a symbol typed as text, which would open no
161
+ card. `ProjectileRangePicture.astro` renders the playground's. The clause
162
+ prints with the caption; a reader of the long caption elsewhere (a
163
+ preview card, a fold's bar) sees the caption alone.
158
164
  - **Which text goes where.** The caption names the figure and says what it
159
165
  shows — its subject, its marks and symbols, the case shown — and may state
160
166
  the one thing to notice. The argument drawn from the figure is lesson
@@ -184,7 +190,17 @@ _How to apply._ Keep the island's job to "patch numbers and redraw". Domain
184
190
  math lives in a plain module (`playground/content/domain/motion.ts`), not in the
185
191
  component.
186
192
 
187
- _Enforcement._ Review-only.
193
+ _Enforcement._ Partly build-enforced. `pnpm validate:content` follows each
194
+ lesson's relative imports to the course's components and fails one that
195
+ imports `katex` itself (`lab-katex-validate.ts`, `lab-katex-direct`, an
196
+ error), naming the chain of imports that reaches it. `astro build` then reads
197
+ every figure it rendered and fails a symbol KaTeX drew outside a notation
198
+ binding (`figure-symbols.ts`, rule [N4](notation.md#n4)); a course's browser
199
+ test runs the same `unboundFigureSymbols` on a hydrated figure's
200
+ `outerHTML`, which also holds what an island draws only in the browser, as
201
+ the playground's `labs.spec.ts` does. `setFigureState` takes no symbol typed
202
+ as text, so `astro check` fails an italic or a `sub` run. That a lab labels
203
+ its controls and names nothing of its own is review-only.
188
204
 
189
205
  _Exceptions._ None.
190
206
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "explico",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "Build-time engine for prerequisite-aware, notation-rigorous course content: one validated content model, a semantic notation/citation/equation system with a completeness gate, and a deterministic JSON manifest.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -12,6 +12,7 @@ import {
12
12
  writeManifest,
13
13
  } from './manifest';
14
14
  import { missingNotationPayloadKeys } from '../reference/page-notation';
15
+ import { assertFigureSymbolsBound } from '../reference/figure-symbols';
15
16
  import { writeStandaloneLessonExports } from './exports/standalone-html';
16
17
  import remarkAbbreviations from '../reference/remark-abbreviations.mjs';
17
18
  import remarkCitation from '../reference/remark-citation.mjs';
@@ -99,7 +100,12 @@ async function buildDevelopmentDownloads(
99
100
  }
100
101
  }
101
102
 
102
- /** Fail loudly instead of rendering math triggers whose local cards are stale. */
103
+ /**
104
+ * Fail loudly instead of rendering math triggers whose local cards are
105
+ * missing. The manifest bundles every `notation.local` entry with its page,
106
+ * so the two lists differ only when the manifest is older than the page, or
107
+ * when `validate:content` rejected an entry.
108
+ */
103
109
  export function assertLocalNotationParity(
104
110
  lessonId: string,
105
111
  authoredKeys: readonly string[],
@@ -114,10 +120,13 @@ export function assertLocalNotationParity(
114
120
  return;
115
121
  }
116
122
  throw new Error(
117
- `Stale Explico manifest for ${lessonId}: lesson notation.local has ` +
118
- `[${authored.join(', ')}], but the page notation bundle has ` +
119
- `[${rendered.join(', ')}]. Pass courseConfig to loadManifest() and add ` +
120
- `explicoContentRefresh() to the Astro integrations.`,
123
+ `Explico notation mismatch for ${lessonId}: the page's frontmatter ` +
124
+ `notation.local declares [${authored.join(', ')}], but the manifest's ` +
125
+ `notation bundle for the page holds [${rendered.join(', ')}]. The ` +
126
+ `manifest bundles every notation.local entry with its page, so either ` +
127
+ `it is stale — pass courseConfig to loadManifest() and add ` +
128
+ `explicoContentRefresh() to the Astro integrations — or ` +
129
+ `\`pnpm validate:content\` rejected an entry: fix the errors it reports.`,
121
130
  );
122
131
  }
123
132
 
@@ -228,7 +237,9 @@ async function validateGeneratedNotationPayloads(directory: URL): Promise<void>
228
237
  if (entry.isDirectory()) {
229
238
  await walk(child);
230
239
  } else if (entry.isFile() && entry.name.endsWith('.html')) {
231
- assertNotationPayloadHtml(relative(root, child), await readFile(child, 'utf8'));
240
+ const html = await readFile(child, 'utf8');
241
+ assertNotationPayloadHtml(relative(root, child), html);
242
+ assertFigureSymbolsBound(relative(root, child), html);
232
243
  }
233
244
  }
234
245
  };
@@ -238,15 +238,21 @@ function sourceSpan(file: string, text?: string, index?: number): SourceSpan {
238
238
  };
239
239
  }
240
240
 
241
- function maskMarkdownLiterals(text: string): string {
241
+ /**
242
+ * Blank code, keeping every newline. An indented line is code only in `.md`:
243
+ * MDX has no indented code block, so text indented inside a component
244
+ * (`<Card>`, `<Memo>`) is prose, and its `[[key]]` renders a term.
245
+ */
246
+ function maskMarkdownLiterals(text: string, mdx: boolean): string {
242
247
  const mask = (value: string) => value.replace(/[^\n]/g, ' ');
243
- return text
244
- .replace(
245
- /(^|\n)[ \t]{0,3}(`{3,}|~{3,})[^\n]*\n[\s\S]*?(?:\n[ \t]{0,3}\2[^\n]*(?=\n|$)|$)/g,
246
- mask,
247
- )
248
- .replace(/^(?: {4}|\t).*$/gm, mask)
249
- .replace(/(`+)(?!`)([^\n]*?)\1/g, mask);
248
+ const fenced = text.replace(
249
+ /(^|\n)[ \t]{0,3}(`{3,}|~{3,})[^\n]*\n[\s\S]*?(?:\n[ \t]{0,3}\2[^\n]*(?=\n|$)|$)/g,
250
+ mask,
251
+ );
252
+ return (mdx ? fenced : fenced.replace(/^(?: {4}|\t).*$/gm, mask)).replace(
253
+ /(`+)(?!`)([^\n]*?)\1/g,
254
+ mask,
255
+ );
250
256
  }
251
257
 
252
258
  /**
@@ -259,7 +265,7 @@ export function extractNotationReferences(
259
265
  kind: NotationReferenceInput['kind'] | undefined,
260
266
  ): NotationReferenceInput[] {
261
267
  const references: NotationReferenceInput[] = [];
262
- const searchable = maskMarkdownLiterals(text);
268
+ const searchable = maskMarkdownLiterals(text, file.endsWith('.mdx'));
263
269
  const patterns: readonly [RegExp, NotationReferenceInput['kind']][] = [
264
270
  [/\[\[\s*([^\][]+?)\s*\]\]/g, kind ?? 'prose'],
265
271
  [/\\explain\{([^{}]+)\}/g, kind ?? 'math'],
@@ -635,6 +641,58 @@ function notationLessonEntries(): NotationLessonInput[] {
635
641
  return lessons;
636
642
  }
637
643
 
644
+ /**
645
+ * A docs page that is **not a chapter** (rule S10): the landing page, a part's
646
+ * overview page, and any other page outside a part directory — every slug
647
+ * `isLessonSlug` rejects. Such a page may explain its symbols like a chapter,
648
+ * but it recalls them only: it takes no `notation.local` and no float numbers.
649
+ */
650
+ export interface NonChapterPage {
651
+ /** `lessonIdFromSlug(slug)` — `index`, `motion.index`, `glossary`. Never a lesson id. */
652
+ readonly id: string;
653
+ /** Docs slug without extension, e.g. `motion/index`. */
654
+ readonly slug: string;
655
+ readonly file: string;
656
+ readonly data: Record<string, unknown>;
657
+ readonly body: string;
658
+ readonly bodyLine: number;
659
+ /**
660
+ * The page as notation input — no local entries, no suspensions — so the
661
+ * registry resolves its `[[key]]` references and the text and math gates
662
+ * scan it exactly as they scan a chapter.
663
+ */
664
+ readonly notation: NotationLessonInput;
665
+ }
666
+
667
+ export function loadNonChapterPages(): NonChapterPage[] {
668
+ const pages: NonChapterPage[] = [];
669
+ for (const path of filesBelow(join(CONTENT_ROOT, 'docs'), ['.md', '.mdx'])) {
670
+ const slug = docSlug(path);
671
+ if (isLessonSlug(slug)) continue;
672
+ const { file, data, body, bodyLine } = parseDocument(path);
673
+ const id = lessonIdFromSlug(slug);
674
+ pages.push({
675
+ id,
676
+ slug: slug.replace(/\.(md|mdx)$/, ''),
677
+ file,
678
+ data,
679
+ body,
680
+ bodyLine,
681
+ notation: {
682
+ lessonId: id,
683
+ status: status(data.editorialStatus ?? 'draft', `${file} editorialStatus`),
684
+ localDefinitions: [],
685
+ suspended: [],
686
+ references: extractNotationReferences(body, file, undefined),
687
+ body,
688
+ bodyLine,
689
+ source: { file },
690
+ },
691
+ });
692
+ }
693
+ return pages;
694
+ }
695
+
638
696
  export async function loadNotationRegistryInput(): Promise<NotationRegistryInput> {
639
697
  return {
640
698
  sharedDefinitions: sharedNotationEntries(),