explico 0.5.0 → 0.7.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 (88) hide show
  1. package/CHANGELOG.md +295 -7
  2. package/README.md +16 -2
  3. package/dist/compiler/astro.d.ts +13 -2
  4. package/dist/compiler/astro.js +39 -0
  5. package/dist/compiler/cli.d.ts +22 -3
  6. package/dist/compiler/cli.js +71 -10
  7. package/dist/compiler/course-config.d.ts +31 -1
  8. package/dist/compiler/course-config.js +7 -0
  9. package/dist/compiler/exports/standalone-html.js +13 -0
  10. package/dist/compiler/logging.d.ts +109 -0
  11. package/dist/compiler/logging.js +107 -0
  12. package/dist/compiler/manifest.d.ts +16 -4
  13. package/dist/compiler/manifest.js +7 -3
  14. package/dist/reference/base-abbreviations.mjs +8 -2
  15. package/dist/reference/consistency.d.ts +8 -10
  16. package/dist/reference/consistency.js +3 -47
  17. package/dist/reference/delimiter-size.d.ts +39 -0
  18. package/dist/reference/delimiter-size.js +177 -0
  19. package/dist/reference/diagrams-validate.d.ts +1 -1
  20. package/dist/reference/equations-validate.d.ts +2 -2
  21. package/dist/reference/equations-validate.js +1 -1
  22. package/dist/reference/figures-validate.d.ts +1 -1
  23. package/dist/reference/gate-heading-length.d.ts +12 -0
  24. package/dist/reference/gate-heading-length.js +79 -0
  25. package/dist/reference/gate-math.d.ts +8 -2
  26. package/dist/reference/gate-math.js +30 -7
  27. package/dist/reference/keyed-float-validate.d.ts +2 -2
  28. package/dist/reference/keyed-float-validate.js +1 -1
  29. package/dist/reference/math-glyphs.d.mts +12 -0
  30. package/dist/reference/math-glyphs.mjs +183 -42
  31. package/dist/reference/references.js +2 -1
  32. package/dist/reference/registry.js +1 -1
  33. package/dist/reference/rehype-base-links.d.mts +23 -0
  34. package/dist/reference/rehype-base-links.mjs +40 -0
  35. package/dist/reference/remark-abbreviations.mjs +10 -12
  36. package/dist/reference/remark-citation.d.mts +5 -1
  37. package/dist/reference/remark-citation.mjs +0 -0
  38. package/dist/reference/render-lab-math.d.ts +18 -1
  39. package/dist/reference/render-lab-math.js +10 -3
  40. package/dist/reference/tables-validate.d.ts +1 -1
  41. package/dist/reference/types.d.ts +1 -1
  42. package/docs/VERSIONING.md +24 -4
  43. package/docs/authoring/README.md +14 -2
  44. package/docs/authoring/agents.md +7 -3
  45. package/docs/authoring/components.md +4 -1
  46. package/docs/authoring/migrating.md +477 -8
  47. package/docs/authoring/notation.md +194 -7
  48. package/docs/authoring/prose.md +64 -0
  49. package/docs/authoring/structure-rules.md +69 -0
  50. package/docs/authoring/visualizations.md +2 -2
  51. package/package.json +1 -1
  52. package/src/compiler/astro.ts +89 -0
  53. package/src/compiler/cli.ts +144 -12
  54. package/src/compiler/course-config.ts +40 -0
  55. package/src/compiler/exports/standalone-html.ts +15 -0
  56. package/src/compiler/logging.ts +248 -0
  57. package/src/compiler/manifest.ts +20 -3
  58. package/src/components/diagram/Diagram.astro +0 -55
  59. package/src/components/memo/Memo.astro +2 -5
  60. package/src/components/memo/ShortNote.astro +2 -5
  61. package/src/components/notation/NotationFormula.astro +199 -10
  62. package/src/components/notation/NotationGlossary.astro +89 -58
  63. package/src/components/notation/NotationLayer.astro +238 -156
  64. package/src/components/scroll-animation.ts +160 -0
  65. package/src/components/starlight/AnchorScrollEnhancer.astro +102 -0
  66. package/src/components/starlight/HeadingAnchorEnhancer.astro +2 -4
  67. package/src/components/starlight/KeyedFloatEnhancer.astro +17 -16
  68. package/src/components/starlight/LessonFooter.astro +5 -1
  69. package/src/content-config.ts +16 -1
  70. package/src/reference/base-abbreviations.mjs +8 -2
  71. package/src/reference/consistency.ts +10 -66
  72. package/src/reference/delimiter-size.ts +301 -0
  73. package/src/reference/diagrams-validate.ts +1 -1
  74. package/src/reference/equations-validate.ts +3 -3
  75. package/src/reference/figures-validate.ts +1 -1
  76. package/src/reference/gate-heading-length.ts +131 -0
  77. package/src/reference/gate-math.ts +48 -8
  78. package/src/reference/keyed-float-validate.ts +3 -3
  79. package/src/reference/math-glyphs.mjs +183 -42
  80. package/src/reference/references.ts +2 -1
  81. package/src/reference/registry.ts +1 -1
  82. package/src/reference/rehype-base-links.mjs +40 -0
  83. package/src/reference/remark-abbreviations.mjs +10 -12
  84. package/src/reference/remark-citation.mjs +0 -0
  85. package/src/reference/render-lab-math.ts +24 -1
  86. package/src/reference/tables-validate.ts +1 -1
  87. package/src/reference/types.ts +1 -1
  88. package/src/styles/global.css +120 -15
@@ -0,0 +1,160 @@
1
+ /**
2
+ * A single, site-wide motion profile for "jump to an in-page anchor":
3
+ * `DURATION_MS`, constant-magnitude acceleration — speeding up through the
4
+ * first half of the trip, slowing down through the second, symmetric about
5
+ * the midpoint (the standard `easeInOutQuad` curve, which is exactly a
6
+ * constant |acceleration| profile since position is piecewise quadratic in
7
+ * time).
8
+ *
9
+ * The browser's own fragment navigation (a plain `<a href="#id">` click, with
10
+ * no `scroll-behavior: smooth` set anywhere in this site's CSS) jumps
11
+ * instantly — and CSS's `scroll-behavior: smooth` can't be tuned to a given
12
+ * duration or easing anyway. Getting an exact, consistent curve everywhere
13
+ * means animating the scroll ourselves. `AnchorScrollEnhancer.astro` (the
14
+ * generic click case, covering every same-page link on every page) and the
15
+ * handful of enhancers with their own reasons to move the viewport
16
+ * (`HeadingAnchorEnhancer.astro`, `KeyedFloatEnhancer.astro`, footnote/memo
17
+ * expansion) all render through this module, so a click that touches two of
18
+ * them at once — e.g. a heading anchor click, animated here *and* re-scrolled
19
+ * by that enhancer's own `hashchange` listener — collapses to a single
20
+ * animation via `activeAnimation` below, rather than two fighting for the
21
+ * scroll position.
22
+ *
23
+ * An earlier version of `AnchorScrollEnhancer.astro` let the browser's own
24
+ * click-triggered jump run, then tried to catch it a frame later and replay
25
+ * it as an animation. The trouble: the HTML spec gives no guarantee about
26
+ * *when*, relative to our own script, that jump actually lands — under load
27
+ * it can take several frames — so a single-frame check could conclude
28
+ * "nothing moved, there's nothing to animate" and let the browser's own
29
+ * un-animated jump land moments later, unopposed (this was the original,
30
+ * confirmed bug reports came in about). Polling for more frames narrows that
31
+ * window but never closes it — there's always some delay past which it gives
32
+ * up. `animatedScrollIntoView` below removes the dependency on that timing
33
+ * entirely: it measures the destination itself, synchronously, before
34
+ * anything has had a chance to move, so there's nothing to race.
35
+ */
36
+
37
+ const DURATION_MS = 200;
38
+ function easeInOutQuad(t: number): number {
39
+ return t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2;
40
+ }
41
+
42
+ type SettleReason = 'complete' | 'interrupted';
43
+
44
+ let activeAnimation: {
45
+ readonly target: Element;
46
+ readonly cancel: (reason: SettleReason) => void;
47
+ } | null = null;
48
+
49
+ function prefersReducedMotion(): boolean {
50
+ return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
51
+ }
52
+
53
+ // Cancel on the reader's own scroll intent — a short ride is unlikely to
54
+ // outlast one, but fighting the reader's wheel/touch/keyboard input, even
55
+ // briefly, is exactly the kind of jank this exists to avoid.
56
+ const CANCEL_EVENTS = ['wheel', 'touchstart', 'keydown'] as const;
57
+
58
+ function runAnimation(
59
+ target: Element,
60
+ startX: number,
61
+ startY: number,
62
+ endX: number,
63
+ endY: number,
64
+ onSettle?: (reason: SettleReason) => void,
65
+ ): void {
66
+ if (activeAnimation?.target === target) return;
67
+ activeAnimation?.cancel('interrupted');
68
+
69
+ if (prefersReducedMotion() || (startX === endX && startY === endY)) {
70
+ window.scrollTo(endX, endY);
71
+ activeAnimation = null;
72
+ onSettle?.('complete');
73
+ return;
74
+ }
75
+
76
+ window.scrollTo(startX, startY);
77
+
78
+ let rafId = 0;
79
+ let cancelled = false;
80
+ const startTime = performance.now();
81
+
82
+ const onCancelEvent = () => cancel('interrupted');
83
+
84
+ const cleanup = () => {
85
+ for (const type of CANCEL_EVENTS) {
86
+ window.removeEventListener(type, onCancelEvent);
87
+ }
88
+ };
89
+
90
+ const finish = (reason: SettleReason) => {
91
+ cleanup();
92
+ if (activeAnimation?.target === target) activeAnimation = null;
93
+ onSettle?.(reason);
94
+ };
95
+
96
+ const cancel = (reason: SettleReason) => {
97
+ if (cancelled) return;
98
+ cancelled = true;
99
+ cancelAnimationFrame(rafId);
100
+ finish(reason);
101
+ };
102
+
103
+ const step = (now: number) => {
104
+ if (cancelled) return;
105
+ const t = Math.min(1, (now - startTime) / DURATION_MS);
106
+ const eased = easeInOutQuad(t);
107
+ window.scrollTo(
108
+ startX + (endX - startX) * eased,
109
+ startY + (endY - startY) * eased,
110
+ );
111
+ if (t < 1) {
112
+ rafId = requestAnimationFrame(step);
113
+ } else {
114
+ cancelled = true;
115
+ finish('complete');
116
+ }
117
+ };
118
+
119
+ for (const type of CANCEL_EVENTS) {
120
+ window.addEventListener(type, onCancelEvent, { passive: true });
121
+ }
122
+
123
+ activeAnimation = { target, cancel };
124
+ rafId = requestAnimationFrame(step);
125
+ }
126
+
127
+ /**
128
+ * Drop-in replacement for `target.scrollIntoView({ behavior: 'smooth', ... })`.
129
+ * Measures the destination itself — instantly, synchronously, before
130
+ * anything has had a chance to move — via `target.scrollIntoView({behavior:
131
+ * 'auto', ...})` (this respects `scroll-margin-*`, sticky headers, etc.
132
+ * exactly as the browser's own fragment navigation would), then jumps back
133
+ * and animates the trip.
134
+ *
135
+ * `onSettle`, when given, fires once: `'complete'` if the animation ran to
136
+ * the destination (or reduced motion meant an instant jump straight there),
137
+ * `'interrupted'` if the reader's own scroll/keyboard input cut it short, or
138
+ * — a rare, intentional no-op — never, if a newer call for the *same*
139
+ * target was already in flight (this one is simply dropped; the one already
140
+ * running owns the eventual settle).
141
+ */
142
+ export function animatedScrollIntoView(
143
+ target: Element,
144
+ options: ScrollIntoViewOptions = { block: 'start' },
145
+ onSettle?: (reason: SettleReason) => void,
146
+ ): void {
147
+ if (prefersReducedMotion()) {
148
+ target.scrollIntoView({ ...options, behavior: 'auto' });
149
+ onSettle?.('complete');
150
+ return;
151
+ }
152
+
153
+ const startX = window.scrollX;
154
+ const startY = window.scrollY;
155
+ target.scrollIntoView({ ...options, behavior: 'auto' });
156
+ const endX = window.scrollX;
157
+ const endY = window.scrollY;
158
+
159
+ runAnimation(target, startX, startY, endX, endY, onSettle);
160
+ }
@@ -0,0 +1,102 @@
1
+ ---
2
+ /**
3
+ * Site-wide "jump to an in-page anchor" animation (Step F3 follow-up). Loads
4
+ * unconditionally, on every page — not just lessons — since it has to catch
5
+ * every same-page `<a href="#…">`: a heading, a keyed equation/table/figure
6
+ * number, a footnote/memo marker, and plain content links with no dedicated
7
+ * enhancer of their own at all (the notation glossary's term/anchor links,
8
+ * its "see also" list, a page's table of contents, …).
9
+ *
10
+ * This calls `preventDefault()` on a qualifying click — which looks like the
11
+ * one thing `HeadingAnchorEnhancer.astro` and `KeyedFloatEnhancer.astro` both
12
+ * carry a comment warning against, since `:target`-driven CSS (the
13
+ * heading/equation "flash") and the sticky-header `scroll-margin` offset only
14
+ * fire on a genuine same-document fragment navigation — never on a
15
+ * `history.pushState`. That warning is about *silently* replacing the
16
+ * navigation with `pushState` and never actually re-triggering it. This does
17
+ * something safer: it measures the destination and animates there itself
18
+ * (`animatedScrollIntoView`, `scroll-animation.ts` — the same call
19
+ * `HeadingAnchorEnhancer`/`KeyedFloatEnhancer` use for their own supplementary
20
+ * scrolls), and only once the viewport is actually *at* that destination does
21
+ * it assign `location.hash` — a real fragment navigation, not `pushState`,
22
+ * so `:target` matches and `hashchange` fires exactly as a plain click's
23
+ * default action would have. Because the assignment happens after arrival,
24
+ * the browser's own resulting "scroll to the fragment" pass finds nothing
25
+ * left to move and is an invisible no-op, whenever it happens to run.
26
+ *
27
+ * An earlier version instead let the native click run and tried to catch its
28
+ * jump a frame later — see the note atop `scroll-animation.ts` for why that
29
+ * was racy in a way no amount of polling fixed, and had to be replaced by
30
+ * this preventDefault-and-replay approach instead.
31
+ *
32
+ * If the reader scrolls away mid-animation (`'interrupted'` below), the URL
33
+ * still gets a plain `pushState` — reflecting the click without dragging
34
+ * them back to a destination they've already scrolled past, and without the
35
+ * `:target` flash firing for a section they're no longer looking at.
36
+ */
37
+ ---
38
+
39
+ <script>
40
+ import { animatedScrollIntoView } from '../scroll-animation';
41
+
42
+ function sameDocumentFragmentId(link: HTMLAnchorElement): string | null {
43
+ if (link.target && link.target !== '_self') return null;
44
+ let url: URL;
45
+ try {
46
+ url = new URL(link.href, location.href);
47
+ } catch {
48
+ return null;
49
+ }
50
+ if (
51
+ url.origin !== location.origin ||
52
+ url.pathname !== location.pathname ||
53
+ url.search !== location.search ||
54
+ !url.hash
55
+ ) {
56
+ return null;
57
+ }
58
+ return url.hash.slice(1);
59
+ }
60
+
61
+ document.addEventListener('click', (event) => {
62
+ if (
63
+ event.defaultPrevented ||
64
+ event.button !== 0 ||
65
+ event.metaKey ||
66
+ event.ctrlKey ||
67
+ event.shiftKey ||
68
+ event.altKey
69
+ ) {
70
+ return;
71
+ }
72
+
73
+ const origin = event.target instanceof Element ? event.target : null;
74
+ const link = origin?.closest('a[href]');
75
+ if (!(link instanceof HTMLAnchorElement)) return;
76
+
77
+ const rawId = sameDocumentFragmentId(link);
78
+ if (!rawId) return;
79
+
80
+ let id = rawId;
81
+ try {
82
+ id = decodeURIComponent(rawId);
83
+ } catch {
84
+ /* keep the raw fragment */
85
+ }
86
+ const target = document.getElementById(id);
87
+ if (!target) return; // no matching element: let the browser's own no-op stand
88
+
89
+ if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
90
+
91
+ event.preventDefault();
92
+ animatedScrollIntoView(target, { block: 'start' }, (reason) => {
93
+ const hash = `#${id}`;
94
+ if (location.hash === hash) return;
95
+ if (reason === 'complete') {
96
+ location.hash = id;
97
+ } else {
98
+ history.pushState(null, '', hash);
99
+ }
100
+ });
101
+ });
102
+ </script>
@@ -37,6 +37,7 @@
37
37
 
38
38
  <script>
39
39
  import { HEADINGS, sectionParts } from './section-parts';
40
+ import { animatedScrollIntoView } from '../scroll-animation';
40
41
 
41
42
  (() => {
42
43
  const root = document.querySelector('.sl-markdown-content');
@@ -78,10 +79,7 @@
78
79
  if (!heading) return;
79
80
  const parts = sectionParts(heading);
80
81
  if (doScroll) {
81
- parts[0].scrollIntoView({
82
- behavior: reduce.matches ? 'auto' : 'smooth',
83
- block: 'start',
84
- });
82
+ animatedScrollIntoView(parts[0], { block: 'start' });
85
83
  }
86
84
  for (const part of parts) flash(part);
87
85
  heading.setAttribute('tabindex', '-1');
@@ -40,6 +40,8 @@
40
40
  ---
41
41
 
42
42
  <script>
43
+ import { animatedScrollIntoView } from '../scroll-animation';
44
+
43
45
  (() => {
44
46
  const root = document.querySelector('.sl-markdown-content');
45
47
  if (!root) return;
@@ -97,10 +99,7 @@
97
99
  target.style.removeProperty('animation');
98
100
  }
99
101
  if (doScroll) {
100
- target.scrollIntoView({
101
- behavior: reduce.matches ? 'auto' : 'smooth',
102
- block: 'start',
103
- });
102
+ animatedScrollIntoView(target, { block: 'start' });
104
103
  }
105
104
  target.setAttribute('tabindex', '-1');
106
105
  try {
@@ -132,13 +131,15 @@
132
131
 
133
132
  // "Copy LaTeX" for an equation, two ways: its own button (only a numbered
134
133
  // display equation has one — `data-eq-copy`, `remark-notation.mjs`'s
135
- // `equationNode`), or a click anywhere else in the equation that isn't a
136
- // symbol with its own notation card (an operator, a bare number, a
137
- // parenthesis, …) — the same gesture works on an inline equation too
138
- // (`inlineEquationNode`), which has no room for a persistent button of
139
- // its own without breaking the reading line. A notation-registry symbol
140
- // keeps its existing click-to-pin behavior either way
141
- // (`NotationLayer.astro`), so this skips anything under `[data-notation-key]`.
134
+ // `equationNode`), or a click anywhere else in the box — including the
135
+ // reserved gutter between the equation and its number/button, not just
136
+ // the rendered `.katex-html` — that isn't the number link or a symbol
137
+ // with its own notation card. The same gesture works on an inline
138
+ // equation too (`inlineEquationNode`), which has no room for a
139
+ // persistent button of its own without breaking the reading line. A
140
+ // notation-registry symbol keeps its existing click-to-pin behavior
141
+ // either way (`NotationLayer.astro`), so this skips anything under
142
+ // `[data-notation-key]`.
142
143
  root.addEventListener('click', (event) => {
143
144
  const origin = event.target instanceof Element ? event.target : null;
144
145
  if (!origin) return;
@@ -150,9 +151,7 @@
150
151
  const container = ownButton
151
152
  ? null
152
153
  : origin.closest<HTMLElement>('.keyed-equation, .inline-equation');
153
- if (!ownButton && (!container || !origin.closest('.katex-html'))) {
154
- return;
155
- }
154
+ if (!ownButton && !container) return;
156
155
 
157
156
  const feedbackTarget =
158
157
  ownButton ??
@@ -185,12 +184,14 @@
185
184
  root.addEventListener('mouseover', (event) => {
186
185
  const origin = event.target instanceof Element ? event.target : null;
187
186
  if (!origin || origin.getAttribute('title') === 'Copy LaTeX') return;
188
- if (origin.closest('[data-notation-key], [data-eq-copy]')) return;
189
187
  if (
190
- !origin.closest('.keyed-equation .katex-html, .inline-equation .katex-html')
188
+ origin.closest(
189
+ '[data-notation-key], [data-eq-copy], a.keyed-equation__number',
190
+ )
191
191
  ) {
192
192
  return;
193
193
  }
194
+ if (!origin.closest('.keyed-equation, .inline-equation')) return;
194
195
  origin.setAttribute('title', 'Copy LaTeX');
195
196
  });
196
197
 
@@ -23,6 +23,7 @@ import AbbreviationLayer from '../abbreviation/AbbreviationLayer.astro';
23
23
  import KeyedFloatEnhancer from './KeyedFloatEnhancer.astro';
24
24
  import FloatRefLayer from '../reference/FloatRefLayer.astro';
25
25
  import HeadingAnchorEnhancer from './HeadingAnchorEnhancer.astro';
26
+ import AnchorScrollEnhancer from './AnchorScrollEnhancer.astro';
26
27
  import CollapsibleSections from './CollapsibleSections.astro';
27
28
  import TableOverflowExpander from '../table/TableOverflowExpander.astro';
28
29
  import DiagramEnhancer from './DiagramEnhancer.astro';
@@ -40,8 +41,10 @@ const lessonId = isLessonSlug(routeSlug)
40
41
  const glossaryBase = `${import.meta.env.BASE_URL.replace(/\/$/, '')}/glossary/`;
41
42
 
42
43
  const definitions: NotationLayerDefinition[] = [];
44
+ let notationDefaultOpen = false;
43
45
  if (lessonId) {
44
46
  const manifest = await loadManifest();
47
+ notationDefaultOpen = manifest.disclosureDefaults.notation;
45
48
  const lesson = manifest.lessons.find((entry) => entry.id === lessonId);
46
49
  const routeData = Astro.locals.starlightRoute.entry.data as {
47
50
  notation?: { local?: readonly { key?: unknown }[] };
@@ -80,7 +83,8 @@ if (lessonId) {
80
83
  }
81
84
  ---
82
85
 
83
- <NotationLayer definitions={definitions} />
86
+ <NotationLayer definitions={definitions} defaultOpen={notationDefaultOpen} />
87
+ <AnchorScrollEnhancer />
84
88
  {lessonId && <CitationLayer />}
85
89
  {lessonId && <AbbreviationLayer />}
86
90
  {lessonId && <KeyedFloatEnhancer />}
@@ -196,6 +196,21 @@ const assessmentItemBase = z.object({
196
196
  explanation: z.string().min(1),
197
197
  });
198
198
 
199
+ /**
200
+ * `AssessmentSet.astro` renders the numbered heading itself — `<h2>Knowledge
201
+ * check {number} — {title}</h2>` — so `title` must be just the topic. A title
202
+ * that repeats the "Knowledge check" label duplicates it on every page that
203
+ * uses `AssessmentSet` (the standalone `AssessmentRunner` heading fallback
204
+ * reads fine either way, so this only bites the common path).
205
+ */
206
+ const assessmentTitle = z
207
+ .string()
208
+ .min(1)
209
+ .refine((value) => !/^knowledge check\b/i.test(value), {
210
+ message:
211
+ 'Assessment title must not start with "Knowledge check" — AssessmentSet already renders "Knowledge check {number} — {title}", so a title with the same prefix duplicates it. Use just the topic, e.g. "AIC and KL risk".',
212
+ });
213
+
199
214
  const assessments = defineCollection({
200
215
  loader: glob({
201
216
  pattern: '**/*.json',
@@ -203,7 +218,7 @@ const assessments = defineCollection({
203
218
  }),
204
219
  schema: z.object({
205
220
  id,
206
- title: z.string().min(1),
221
+ title: assessmentTitle,
207
222
  editorialStatus,
208
223
  items: z
209
224
  .array(
@@ -20,12 +20,18 @@ export const BASE_ABBREVIATIONS = Object.freeze(
20
20
  'e.g.',
21
21
  'i.e.',
22
22
  'etc.',
23
+ 'e.t.c.',
23
24
  'cf.',
24
25
  'vs.',
25
- 'a.m.',
26
- 'p.m.',
27
26
  'a.k.a.',
28
27
  'et al.',
28
+ // time of day, in each casing/punctuation style a lesson might use
29
+ 'a.m.',
30
+ 'p.m.',
31
+ 'A.M.',
32
+ 'P.M.',
33
+ 'AM',
34
+ 'PM',
29
35
  // generic acronyms with no domain-specific meaning in this engine
30
36
  'AI',
31
37
  'ID',
@@ -2,11 +2,11 @@
2
2
  * Corpus consistency checks (Step D6) — the Tier 1–2 checks from the cleanup
3
3
  * plan's "Enforced checks" section that no earlier step covers.
4
4
  *
5
- * Every check here enters as a **Tier-3 warning**: `pnpm validate:content`
6
- * surfaces the counts, `manifest.diagnostics.consistency` records the detail,
7
- * and `resolution/consistency-report.json` is the committed, diff-checkable
8
- * snapshot. Nothing here changes content — each finding is an open item for a
9
- * separate human-quantitative-reviewed pass. A finding is silenced only by a
5
+ * Every check here is non-blocking Tier 3: repairable violations are warnings
6
+ * and optional restructuring advice is info. `pnpm validate:content` surfaces
7
+ * warning counts, `manifest.diagnostics.consistency` records every detail, and
8
+ * `resolution/consistency-report.json` is the committed, diff-checkable
9
+ * snapshot. Nothing here changes content. A finding is silenced only by a
10
10
  * reviewed line in `lint-ignore.yml` (see `./lint-ignore`).
11
11
  *
12
12
  * (a) glyph-unique-in-corpus one rendered glyph → one meaning corpus-wide
@@ -14,8 +14,6 @@
14
14
  * unit strings drawn from a controlled vocab
15
15
  * (c) convention-single-definition a sign / cash-flow-perspective convention a
16
16
  * lesson uses resolves to one declaration
17
- * (e) numerals-tagged numerals in `$…$` / result tables are tagged
18
- * to a `src/domain/` call or a source locator
19
17
  * (f) weak-local a `notation.local` symbol used only in its
20
18
  * own defining equation and prose
21
19
  * (g) notation-source-locator every notation `sources` entry has a locator
@@ -54,7 +52,6 @@ export type ConsistencyCode =
54
52
  | 'notation-units'
55
53
  | 'units-vocab'
56
54
  | 'convention-single-definition'
57
- | 'numerals-tagged'
58
55
  | 'weak-local'
59
56
  | 'notation-source-locator'
60
57
  | 'gloss-wants-promoting'
@@ -65,8 +62,8 @@ export type ConsistencyCode =
65
62
 
66
63
  export interface ConsistencyDiagnostic {
67
64
  readonly code: ConsistencyCode;
68
- /** Tier 3 — every finding here is a warning until content is brought clean. */
69
- readonly severity: 'warning';
65
+ /** Tier 3 — repairable violations are warnings; optional advice is info. */
66
+ readonly severity: 'warning' | 'info';
70
67
  readonly message: string;
71
68
  readonly file?: string;
72
69
  readonly lessonId?: string;
@@ -119,28 +116,6 @@ function mathSpans(body: string): MathSpan[] {
119
116
  return spans;
120
117
  }
121
118
 
122
- function tableRows(body: string): string[] {
123
- const rows: string[] = [];
124
- for (const line of stripCode(body).split('\n')) {
125
- const trimmed = line.trim();
126
- if (!trimmed.startsWith('|') || !trimmed.slice(1).includes('|')) continue;
127
- if (/^\s*\|[\s:|-]+\|\s*$/.test(line)) continue;
128
- rows.push(line);
129
- }
130
- return rows;
131
- }
132
-
133
- const NUMERAL_RE = /(?<![\w.])-?\d+(?:\.\d+)?/g;
134
-
135
- function numeralCount(text: string): number {
136
- return (text.replace(/\\[a-zA-Z]+/g, ' ').match(NUMERAL_RE) ?? []).length;
137
- }
138
-
139
- /** A math span / table row is "tagged" when it carries a citation or a domain marker. */
140
- function isTagged(text: string): boolean {
141
- return /\[@[a-z0-9]/i.test(text) || /<!--\s*domain:/i.test(text);
142
- }
143
-
144
119
  // --- glyph regex for weak-local --------------------------------------
145
120
 
146
121
  function glyphMatcher(latex: string): RegExp | undefined {
@@ -274,35 +249,6 @@ function checkConventionSingleDefinition(
274
249
  }
275
250
  }
276
251
 
277
- function checkNumeralsTagged(
278
- input: ConsistencyInput,
279
- out: ConsistencyDiagnostic[],
280
- ): void {
281
- for (const lesson of input.notationInput.lessons) {
282
- if (input.lintIgnore.matches('numerals-tagged', lesson.lessonId)) continue;
283
- let math = 0;
284
- for (const span of mathSpans(lesson.body)) {
285
- if (isTagged(span.latex)) continue;
286
- math += numeralCount(span.latex);
287
- }
288
- let table = 0;
289
- for (const row of tableRows(lesson.body)) {
290
- if (isTagged(row)) continue;
291
- table += numeralCount(row.replace(/\$[^$\n]*\$/g, ' '));
292
- }
293
- const count = math + table;
294
- if (count === 0) continue;
295
- out.push({
296
- code: 'numerals-tagged',
297
- severity: 'warning',
298
- lessonId: lesson.lessonId,
299
- file: lesson.source.file,
300
- count,
301
- message: `${lesson.lessonId}: ${count} numeral(s) in $…$ / result tables (${math} in math, ${table} in tables) are not tagged to a src/domain/ call or a source locator`,
302
- });
303
- }
304
- }
305
-
306
252
  function checkWeakLocal(
307
253
  input: ConsistencyInput,
308
254
  out: ConsistencyDiagnostic[],
@@ -317,7 +263,7 @@ function checkWeakLocal(
317
263
  if (uses > 1) continue;
318
264
  out.push({
319
265
  code: 'weak-local',
320
- severity: 'warning',
266
+ severity: 'info',
321
267
  lessonId: lesson.lessonId,
322
268
  file: lesson.source.file,
323
269
  key: definition.key,
@@ -390,7 +336,7 @@ function checkGlossWantsPromoting(
390
336
  if (input.lintIgnore.matches('gloss-wants-promoting', glyph)) continue;
391
337
  out.push({
392
338
  code: 'gloss-wants-promoting',
393
- severity: 'warning',
339
+ severity: 'info',
394
340
  glyph,
395
341
  message: `gloss ${JSON.stringify(glyph)} (${label}) is declared on ${keys.size} entries (${[...keys].sort().join(', ')}); a meaning reused this widely wants promoting to a card`,
396
342
  });
@@ -425,7 +371,7 @@ function checkCardWantsDemoting(
425
371
  }
426
372
  out.push({
427
373
  code: 'card-wants-demoting',
428
- severity: 'warning',
374
+ severity: 'info',
429
375
  key: definition.key,
430
376
  file: definition.source.file,
431
377
  count: words,
@@ -627,7 +573,6 @@ const CHECKS: ((
627
573
  checkGlyphUniqueness,
628
574
  checkNotationUnits,
629
575
  checkConventionSingleDefinition,
630
- checkNumeralsTagged,
631
576
  checkWeakLocal,
632
577
  checkNotationSourceLocator,
633
578
  checkGlossWantsPromoting,
@@ -666,7 +611,6 @@ export function consistencyCounts(
666
611
  'notation-units': 0,
667
612
  'units-vocab': 0,
668
613
  'convention-single-definition': 0,
669
- 'numerals-tagged': 0,
670
614
  'weak-local': 0,
671
615
  'notation-source-locator': 0,
672
616
  'gloss-wants-promoting': 0,