explico 0.5.0 → 0.6.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.
@@ -42,6 +42,10 @@ export interface Props {
42
42
  * links to reveal an explanation on hover.
43
43
  */
44
44
  presentation?: 'full' | 'panel';
45
+ /** Author-chosen state for a reader with nothing saved yet. Default: `false` (closed) — see `docs/authoring` on `CourseConfig.disclosureDefaults`. */
46
+ defaultOpen?: boolean;
47
+ /** Persist the reader's open/closed choice across visits, per page. Default: `true`. */
48
+ rememberState?: boolean;
45
49
  }
46
50
 
47
51
  import NotationMath from './NotationMath.astro';
@@ -55,6 +59,8 @@ const {
55
59
  label = 'Notation used on this page',
56
60
  glossaryHref = `${import.meta.env.BASE_URL.replace(/\/$/, '')}/glossary/`,
57
61
  presentation = 'full',
62
+ defaultOpen = false,
63
+ rememberState = true,
58
64
  } = Astro.props;
59
65
 
60
66
  const panelOnly = presentation === 'panel';
@@ -111,16 +117,16 @@ for (const gloss of glossItems) {
111
117
  class:list={['notation-layer', panelOnly && 'notation-layer--panel-only']}
112
118
  data-notation-layer
113
119
  data-notation-layer-id={id}
114
- aria-labelledby={panelOnly ? undefined : `${id}-heading`}
115
- aria-label={panelOnly ? 'Notation explanations' : undefined}
120
+ aria-label={panelOnly ? 'Notation explanations' : label}
116
121
  >
117
- {!panelOnly && (
118
- <h2 id={`${id}-heading`} class="notation-layer-heading">
119
- Page notation
120
- </h2>
121
- )}
122
-
123
- <details class="notation-list" hidden={panelOnly}>
122
+ <div class="notation-list-titlebar">
123
+ <details
124
+ class="notation-list"
125
+ id={id}
126
+ hidden={panelOnly}
127
+ data-remember-state={String(rememberState)}
128
+ open={defaultOpen}
129
+ >
124
130
  <summary>
125
131
  {label} <span class="notation-count">({items.length})</span>
126
132
  </summary>
@@ -183,16 +189,6 @@ for (const gloss of glossItems) {
183
189
  <strong>Units:</strong> {definition.units}
184
190
  </p>
185
191
  )}
186
- <button
187
- class="notation-pin"
188
- type="button"
189
- data-notation-control={definition.key}
190
- aria-expanded="false"
191
- aria-pressed="false"
192
- hidden
193
- >
194
- Pin explanation
195
- </button>
196
192
  </dd>
197
193
  </div>
198
194
  ))}
@@ -216,14 +212,32 @@ for (const gloss of glossItems) {
216
212
  </div>
217
213
  ))}
218
214
  </details>
215
+ {!panelOnly && (
216
+ <a
217
+ class="notation-list-anchor"
218
+ href={`#${id}`}
219
+ aria-label={`${label}, titled section`}
220
+ >
221
+ <svg
222
+ aria-hidden="true"
223
+ width="16"
224
+ height="16"
225
+ viewBox="0 0 24 24"
226
+ fill="currentColor"
227
+ ><path
228
+ d="m12.11 15.39-3.88 3.88a2.52 2.52 0 0 1-3.5 0 2.47 2.47 0 0 1 0-3.5l3.88-3.88a1 1 0 1 0-1.42-1.42l-3.88 3.89a4.48 4.48 0 0 0 6.33 6.33l3.89-3.88a1 1 0 0 0-1.42-1.42m8.58-12.08a4.49 4.49 0 0 0-6.33 0l-3.89 3.88a1 1 0 1 0 1.42 1.42l3.88-3.88a2.52 2.52 0 0 1 3.5 0 2.47 2.47 0 0 1 0 3.5l-3.88 3.88a1 1 0 0 0 0 1.42 1 1 0 0 0 1.42 0l3.88-3.89a4.49 4.49 0 0 0 0-6.33M8.83 15.17a1 1 0 0 0 .71.29 1 1 0 0 0 .71-.29l4.92-4.92a1 1 0 1 0-1.42-1.42l-4.92 4.92a1 1 0 0 0 0 1.42"
229
+ ></path></svg
230
+ >
231
+ </a>
232
+ )}
233
+ </div>
219
234
 
220
235
  {!panelOnly && (
221
236
  <p class="notation-enhancement-help" data-notation-help hidden>
222
237
  Point to a marked symbol for a quick explanation, or use the list
223
238
  above — pointing at a symbol inside an open card explains that one
224
- too, alongside it. Click a symbol, or select “Pin explanation”, to
225
- keep a card open; click the card (or its “×”) to close it, or press
226
- Escape.
239
+ too, alongside it. Click a symbol to keep its card open; click the
240
+ card (or its “×”) to close it, or press Escape.
227
241
  </p>
228
242
  )}
229
243
 
@@ -270,6 +284,7 @@ for (const gloss of glossItems) {
270
284
 
271
285
  <script>
272
286
  import { createPopupStack, closestElement } from '../popup-stack';
287
+ import { wirePersistedDetails } from '../disclosure/persisted-disclosure';
273
288
 
274
289
  const triggerSelector = '[data-notation-key]';
275
290
 
@@ -296,10 +311,6 @@ for (const gloss of glossItems) {
296
311
  );
297
312
  if (!template) return;
298
313
 
299
- const controls = Array.from(
300
- root.querySelectorAll<HTMLButtonElement>('[data-notation-control]'),
301
- );
302
-
303
314
  // Static triggers, computed once: markers embedded in prose or an
304
315
  // equation, and the symbol glyphs in the "Notation used on this page"
305
316
  // list. These get the always-on `.notation-trigger` cursor/hover
@@ -324,20 +335,32 @@ for (const gloss of glossItems) {
324
335
  for (const trigger of highlightTargets) {
325
336
  trigger.classList.add('notation-trigger');
326
337
  }
327
- for (const control of controls) control.hidden = false;
328
338
  const help = root.querySelector<HTMLElement>('[data-notation-help]');
329
339
  if (help) help.hidden = false;
330
340
 
331
- const keyFor = (element: HTMLElement | null) =>
332
- element?.dataset.notationKey ??
333
- element?.dataset.notationControl ??
334
- element?.dataset.notationLink ??
335
- null;
341
+ // The visible list's own symbol glyphs are the only keyboard path to
342
+ // pinning a card from this list (the old "Pin explanation" button was
343
+ // the other one, and is gone) — make them a real, labelled, Enter/Space-
344
+ // operable control rather than a mouse-only `<span>`. `symbolTriggers`
345
+ // (defined above) is already scoped to entries inside this list.
346
+ for (const trigger of symbolTriggers) {
347
+ const key = trigger.dataset.notationKey ?? '';
348
+ const entryLabel =
349
+ entries.get(key)?.querySelector<HTMLElement>('[data-notation-link]')
350
+ ?.textContent?.trim() ?? key;
351
+ trigger.setAttribute('role', 'button');
352
+ trigger.tabIndex = 0;
353
+ trigger.setAttribute('aria-pressed', 'false');
354
+ trigger.setAttribute('aria-label', `Pin the ${entryLabel} explanation`);
355
+ trigger.addEventListener('keydown', (event) => {
356
+ if (event.key !== 'Enter' && event.key !== ' ') return;
357
+ event.preventDefault();
358
+ trigger.click();
359
+ });
360
+ }
336
361
 
337
- const controlFrom = (node: EventTarget | null) => {
338
- const candidate = closestElement(node, '[data-notation-control]');
339
- return candidate && root.contains(candidate) ? candidate : null;
340
- };
362
+ const keyFor = (element: HTMLElement | null) =>
363
+ element?.dataset.notationKey ?? element?.dataset.notationLink ?? null;
341
364
 
342
365
  const staticLinkFrom = (node: EventTarget | null) => {
343
366
  const candidate = closestElement(node, '[data-notation-link]');
@@ -357,7 +380,7 @@ for (const gloss of glossItems) {
357
380
  };
358
381
 
359
382
  const triggerFrom = (node: EventTarget | null) =>
360
- controlFrom(node) ?? staticLinkFrom(node) ?? symbolFrom(node);
383
+ staticLinkFrom(node) ?? symbolFrom(node);
361
384
 
362
385
  const labelOf = (entry: HTMLElement, key: string) =>
363
386
  entry
@@ -465,21 +488,6 @@ for (const gloss of glossItems) {
465
488
  return el;
466
489
  };
467
490
 
468
- const updateControls = (
469
- open: ReadonlySet<string>,
470
- pinned: ReadonlySet<string>,
471
- ) => {
472
- for (const control of controls) {
473
- const key = control.dataset.notationControl ?? '';
474
- const selected = pinned.has(key);
475
- control.setAttribute('aria-pressed', String(selected));
476
- control.setAttribute('aria-expanded', String(open.has(key)));
477
- control.textContent = selected
478
- ? 'Unpin explanation'
479
- : 'Pin explanation';
480
- }
481
- };
482
-
483
491
  createPopupStack({
484
492
  // Popups are `position: fixed` and float above everything regardless
485
493
  // of DOM placement — *except* z-index only ever wins within its own
@@ -493,7 +501,6 @@ for (const gloss of glossItems) {
493
501
  triggerFrom,
494
502
  keyFor,
495
503
  createPopup,
496
- pinControlFrom: controlFrom,
497
504
  onChange: (open, pinned) => {
498
505
  for (const trigger of highlightTargets) {
499
506
  trigger.classList.toggle(
@@ -501,15 +508,44 @@ for (const gloss of glossItems) {
501
508
  open.has(trigger.dataset.notationKey ?? ''),
502
509
  );
503
510
  }
504
- updateControls(open, pinned);
511
+ for (const trigger of symbolTriggers) {
512
+ trigger.setAttribute(
513
+ 'aria-pressed',
514
+ String(pinned.has(trigger.dataset.notationKey ?? '')),
515
+ );
516
+ }
505
517
  },
506
518
  });
507
519
  }
508
520
 
521
+ function setupNotationDisclosure(details: HTMLDetailsElement) {
522
+ if (details.dataset.disclosureWired === 'true') return;
523
+ details.dataset.disclosureWired = 'true';
524
+
525
+ // Same convention as `Figure.astro`: the SSR-rendered `open` attribute
526
+ // already carries the author's `defaultOpen` prop verbatim.
527
+ const defaultOpen = details.open;
528
+ if (details.dataset.rememberState === 'true') {
529
+ wirePersistedDetails(details, `notation:${details.id}`, defaultOpen);
530
+ }
531
+
532
+ // Deep-linking to `#page-notation` (the anchor link, or a shared URL)
533
+ // must reveal it even if a reader left it collapsed.
534
+ const openIfTargeted = () => {
535
+ if (location.hash === `#${details.id}`) details.open = true;
536
+ };
537
+ openIfTargeted();
538
+ window.addEventListener('hashchange', openIfTargeted);
539
+ }
540
+
509
541
  for (const root of document.querySelectorAll<HTMLElement>(
510
542
  '[data-notation-layer]',
511
543
  )) {
512
544
  setupNotationLayer(root);
545
+ const details = root.querySelector<HTMLDetailsElement>(
546
+ ':scope > .notation-list-titlebar > .notation-list[id]',
547
+ );
548
+ if (details) setupNotationDisclosure(details);
513
549
  }
514
550
  </script>
515
551
 
@@ -526,9 +562,13 @@ for (const gloss of glossItems) {
526
562
  margin: 0;
527
563
  }
528
564
 
529
- .notation-layer-heading {
530
- margin: 0 0 0.65rem;
531
- font-size: var(--sl-text-lg);
565
+ /* Positioning context for `.notation-list-anchor`, mirroring
566
+ `Figure.astro`'s `.keyed-figure__titlebar` — the anchor link is a DOM
567
+ sibling of `<details>`, not nested inside `<summary>` (a focusable `<a>`
568
+ inside `<summary>` trips axe's `nested-interactive` check the same way
569
+ it would there). */
570
+ .notation-list-titlebar {
571
+ position: relative;
532
572
  }
533
573
 
534
574
  .notation-list {
@@ -539,8 +579,52 @@ for (const gloss of glossItems) {
539
579
 
540
580
  .notation-list > summary {
541
581
  padding: 0.8rem 1rem;
582
+ padding-inline-end: 2.2rem;
542
583
  cursor: pointer;
543
584
  font-weight: 600;
585
+ font-size: var(--sl-text-lg);
586
+ }
587
+
588
+ .notation-list-anchor {
589
+ position: absolute;
590
+ top: 0.9rem;
591
+ inset-inline-end: 1rem;
592
+ display: inline-flex;
593
+ align-items: center;
594
+ justify-content: center;
595
+ width: 1em;
596
+ height: 1em;
597
+ font-size: var(--sl-text-lg);
598
+ color: var(--sl-color-gray-3);
599
+ text-decoration: none;
600
+ -webkit-user-select: none;
601
+ user-select: none;
602
+ }
603
+
604
+ .notation-list-anchor svg {
605
+ width: 0.7em;
606
+ height: 0.7em;
607
+ }
608
+
609
+ .notation-list-anchor:hover,
610
+ .notation-list-anchor:focus-visible {
611
+ color: var(--sl-color-text-accent);
612
+ }
613
+
614
+ .notation-list-anchor:focus-visible {
615
+ outline: 2px solid var(--sl-color-accent);
616
+ outline-offset: 0.15rem;
617
+ }
618
+
619
+ @media (hover: hover) {
620
+ .notation-list-anchor {
621
+ opacity: 0;
622
+ }
623
+
624
+ .notation-list-anchor:focus-visible,
625
+ .notation-list-titlebar:hover .notation-list-anchor {
626
+ opacity: 1;
627
+ }
544
628
  }
545
629
 
546
630
  .notation-count,
@@ -594,14 +678,28 @@ for (const gloss of glossItems) {
594
678
  cursor: help;
595
679
  }
596
680
 
681
+ /* Entries in the visible list are keyboard-operable (Enter/Space pins
682
+ the same card a click would) now that the list has no separate "Pin
683
+ explanation" button of its own — see the setup script below. */
684
+ .notation-entry .notation-symbol-trigger {
685
+ cursor: pointer;
686
+ }
687
+
597
688
  .notation-symbol-trigger:hover,
689
+ .notation-symbol-trigger:focus-visible,
598
690
  .notation-symbol-trigger.is-notation-active {
599
691
  background: var(--sl-color-accent-low);
600
692
  box-shadow: 0 0 0 0.12em var(--sl-color-accent-low);
601
693
  }
602
694
 
695
+ .notation-symbol-trigger:focus-visible {
696
+ outline: 2px solid var(--sl-color-accent);
697
+ outline-offset: 0.1em;
698
+ }
699
+
603
700
  @media (forced-colors: active) {
604
701
  .notation-symbol-trigger:hover,
702
+ .notation-symbol-trigger:focus-visible,
605
703
  .notation-symbol-trigger.is-notation-active {
606
704
  outline: 2px solid Highlight;
607
705
  color: HighlightText;
@@ -634,24 +732,6 @@ for (const gloss of glossItems) {
634
732
  color: var(--sl-color-text);
635
733
  }
636
734
 
637
- .notation-pin {
638
- justify-self: start;
639
- padding: 0.25rem 0.55rem;
640
- border: 1px solid var(--sl-color-gray-5);
641
- border-radius: 0.35rem;
642
- color: var(--sl-color-text-accent);
643
- background: var(--sl-color-bg);
644
- font: inherit;
645
- font-size: var(--sl-text-sm);
646
- cursor: pointer;
647
- }
648
-
649
- .notation-pin:hover,
650
- .notation-pin[aria-pressed='true'] {
651
- border-color: var(--sl-color-accent);
652
- background: var(--sl-color-accent-low);
653
- }
654
-
655
735
  .notation-enhancement-help {
656
736
  margin: 0.55rem 0 0;
657
737
  font-size: var(--sl-text-xs);
@@ -40,8 +40,10 @@ const lessonId = isLessonSlug(routeSlug)
40
40
  const glossaryBase = `${import.meta.env.BASE_URL.replace(/\/$/, '')}/glossary/`;
41
41
 
42
42
  const definitions: NotationLayerDefinition[] = [];
43
+ let notationDefaultOpen = false;
43
44
  if (lessonId) {
44
45
  const manifest = await loadManifest();
46
+ notationDefaultOpen = manifest.disclosureDefaults.notation;
45
47
  const lesson = manifest.lessons.find((entry) => entry.id === lessonId);
46
48
  const routeData = Astro.locals.starlightRoute.entry.data as {
47
49
  notation?: { local?: readonly { key?: unknown }[] };
@@ -80,7 +82,7 @@ if (lessonId) {
80
82
  }
81
83
  ---
82
84
 
83
- <NotationLayer definitions={definitions} />
85
+ <NotationLayer definitions={definitions} defaultOpen={notationDefaultOpen} />
84
86
  {lessonId && <CitationLayer />}
85
87
  {lessonId && <AbbreviationLayer />}
86
88
  {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(
@@ -0,0 +1,131 @@
1
+ /**
2
+ * The heading/title-length gate (rule S1): no Markdown heading (`##`
3
+ * through `######`) and no `<Figure title>`, `<Diagram label>`, `<Memo
4
+ * title>`, or `<ShortNote title>` may run past `HEADING_LENGTH_LIMIT`
5
+ * characters. A long heading or float title is usually a full sentence that
6
+ * belongs in the body paragraph (for a heading) or the `caption` (for a
7
+ * float) instead — see docs/authoring/structure-rules.md#s1.
8
+ *
9
+ * `<Table>` is deliberately not covered: it has only a `caption`, no
10
+ * separate `title`, and that caption is the LaTeX-convention full sentence
11
+ * shown above the table — capping it would fight the rule that puts it
12
+ * there. `<Figure>`'s `caption` is the same full sentence; only its short
13
+ * `title` (the text on the collapsible title bar) is in scope.
14
+ *
15
+ * A lesson's own `#` (its `frontmatter.title`, rendered as the page H1 by
16
+ * Starlight) is Review-only — it never appears in `lesson.body`, so this
17
+ * build-time gate, which only scans body text, cannot see it.
18
+ *
19
+ * Ships straight as an error, same as N9/N14: no partial corpus to absorb,
20
+ * no `lint-ignore.yml` entry.
21
+ */
22
+ import { stripFencedCode } from './equations.mjs';
23
+ import type { MathGateDiagnostic } from './gate-math';
24
+ import { lineOf } from './gate-prose-notation';
25
+ import type { NotationLessonInput } from './types';
26
+
27
+ export const HEADING_LENGTH_LIMIT = 65;
28
+
29
+ // ATX heading: 1-6 `#`, then the text, with any trailing closing `#`s
30
+ // trimmed off (the `# text #` form CommonMark also allows).
31
+ const HEADING_LINE = /^[ \t]{0,3}(#{1,6})[ \t]+(.+?)[ \t]*#*[ \t]*$/gm;
32
+
33
+ // The JSX attribute that reads as a float's own short title, per component.
34
+ // `Table` has no entry — see the module doc comment.
35
+ const TITLE_ATTRIBUTE_BY_TAG: Record<string, string> = {
36
+ Figure: 'title',
37
+ Diagram: 'label',
38
+ Memo: 'title',
39
+ ShortNote: 'title',
40
+ };
41
+
42
+ interface Finding {
43
+ readonly index: number;
44
+ readonly token: string;
45
+ readonly message: string;
46
+ }
47
+
48
+ function readAttribute(attributesSource: string, name: string): string | undefined {
49
+ const pattern = new RegExp(`\\b${name}\\s*=\\s*(["'])([^"']*)\\1`);
50
+ const match = pattern.exec(attributesSource);
51
+ return match ? match[2] : undefined;
52
+ }
53
+
54
+ function overLimitMessage(kind: string, text: string): string {
55
+ return (
56
+ `${kind} ${JSON.stringify(text)} is ${text.length} characters, over the ` +
57
+ `${HEADING_LENGTH_LIMIT}-character limit (rule S1). Shorten it and move ` +
58
+ 'the rest into the paragraph or caption that follows.'
59
+ );
60
+ }
61
+
62
+ function headingFindings(text: string): Finding[] {
63
+ const out: Finding[] = [];
64
+ const re = new RegExp(HEADING_LINE.source, HEADING_LINE.flags);
65
+ let match: RegExpExecArray | null;
66
+ while ((match = re.exec(text))) {
67
+ const level = match[1]?.length ?? 1;
68
+ const title = (match[2] ?? '').trim();
69
+ if (title.length > HEADING_LENGTH_LIMIT) {
70
+ out.push({
71
+ index: match.index,
72
+ token: title,
73
+ message: overLimitMessage(`${'#'.repeat(level)} heading`, title),
74
+ });
75
+ }
76
+ }
77
+ return out;
78
+ }
79
+
80
+ function floatTitleFindings(text: string): Finding[] {
81
+ const out: Finding[] = [];
82
+ for (const [tagName, attribute] of Object.entries(TITLE_ATTRIBUTE_BY_TAG)) {
83
+ const opening = new RegExp(`<${tagName}\\b([^>]*)>`, 'g');
84
+ let match: RegExpExecArray | null;
85
+ while ((match = opening.exec(text))) {
86
+ const value = readAttribute(match[1] ?? '', attribute)?.trim();
87
+ if (value && value.length > HEADING_LENGTH_LIMIT) {
88
+ out.push({
89
+ index: match.index,
90
+ token: value,
91
+ message: overLimitMessage(`<${tagName} ${attribute}>`, value),
92
+ });
93
+ }
94
+ }
95
+ }
96
+ return out;
97
+ }
98
+
99
+ /** Every S1 finding in one lesson body, in source order. */
100
+ function findingsIn(text: string): Finding[] {
101
+ return [...headingFindings(text), ...floatTitleFindings(text)].sort(
102
+ (a, b) => a.index - b.index,
103
+ );
104
+ }
105
+
106
+ /**
107
+ * Scan every lesson body for a heading or a keyed float's title running past
108
+ * `HEADING_LENGTH_LIMIT` characters. Returns one `MathGateDiagnostic`
109
+ * (context `'structure'`, always an error) per finding.
110
+ */
111
+ export function gateHeadingLength(
112
+ lessons: readonly NotationLessonInput[],
113
+ ): MathGateDiagnostic[] {
114
+ const diagnostics: MathGateDiagnostic[] = [];
115
+
116
+ for (const lesson of lessons) {
117
+ const text = stripFencedCode(lesson.body);
118
+ for (const finding of findingsIn(text)) {
119
+ diagnostics.push({
120
+ context: 'structure',
121
+ severity: 'error',
122
+ file: lesson.source.file,
123
+ line: lineOf(lesson.body, finding.index),
124
+ token: finding.token,
125
+ message: finding.message,
126
+ });
127
+ }
128
+ }
129
+
130
+ return diagnostics;
131
+ }
@@ -20,7 +20,7 @@ import type {
20
20
  } from './types';
21
21
 
22
22
  export interface MathGateDiagnostic {
23
- readonly context: 'body' | 'formula' | 'assessment' | 'prose' | 'abbreviation';
23
+ readonly context: 'body' | 'formula' | 'assessment' | 'prose' | 'abbreviation' | 'structure';
24
24
  /**
25
25
  * Body / component-slot math blocks the build (the render path needs every
26
26
  * glyph resolved for the KaTeX trust callback). `formula` joined it at D15:
@@ -38,7 +38,11 @@ export interface MathGateDiagnostic {
38
38
  * same change that introduced the check. `abbreviation` (N14,
39
39
  * `gate-abbreviations.ts`) is the same "ships straight as an error" story
40
40
  * for a different shape of plain-text token — an unexplained acronym or
41
- * dotted abbreviation, rather than a mis-typed quantity/unit.
41
+ * dotted abbreviation, rather than a mis-typed quantity/unit. `structure`
42
+ * (S1, `gate-heading-length.ts`) is unrelated to notation — it caps how
43
+ * long a heading or a keyed float's title may run — but reuses this same
44
+ * diagnostic shape and the one `manifest.diagnostics.math` array rather
45
+ * than adding a second array the CLI has to know about.
42
46
  */
43
47
  readonly severity: 'error' | 'warning';
44
48
  readonly file: string;
@@ -53,6 +57,7 @@ const SEVERITY: Record<MathGateDiagnostic['context'], 'error' | 'warning'> = {
53
57
  assessment: 'error',
54
58
  prose: 'error',
55
59
  abbreviation: 'error',
60
+ structure: 'error',
56
61
  };
57
62
 
58
63
  interface AssessmentItemLike {