explico 0.3.0 → 0.5.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 (151) hide show
  1. package/CHANGELOG.md +344 -1
  2. package/README.md +117 -3
  3. package/dist/compiler/astro.d.ts +38 -0
  4. package/dist/compiler/astro.js +258 -0
  5. package/dist/compiler/cli.d.ts +13 -0
  6. package/dist/compiler/cli.js +32 -12
  7. package/dist/compiler/collections.d.ts +3 -2
  8. package/dist/compiler/collections.js +28 -1
  9. package/dist/compiler/course-config.d.ts +86 -4
  10. package/dist/compiler/course-config.js +39 -0
  11. package/dist/compiler/exports/standalone-html.d.ts +34 -0
  12. package/dist/compiler/exports/standalone-html.js +803 -0
  13. package/dist/compiler/lesson-derivation.d.ts +1 -1
  14. package/dist/compiler/lesson-derivation.js +2 -2
  15. package/dist/compiler/manifest.d.ts +112 -7
  16. package/dist/compiler/manifest.js +205 -40
  17. package/dist/compiler/resolution-report.d.ts +4 -0
  18. package/dist/reference/base-abbreviations.d.mts +30 -0
  19. package/dist/reference/base-abbreviations.mjs +60 -0
  20. package/dist/reference/base-operators.d.mts +15 -0
  21. package/dist/reference/base-operators.mjs +40 -0
  22. package/dist/reference/base-roman-labels.d.mts +35 -0
  23. package/dist/reference/base-roman-labels.mjs +43 -0
  24. package/dist/reference/consistency.d.ts +8 -2
  25. package/dist/reference/consistency.js +50 -3
  26. package/dist/reference/diagrams-validate.d.ts +21 -0
  27. package/dist/reference/diagrams-validate.js +13 -0
  28. package/dist/reference/equations-validate.d.ts +8 -3
  29. package/dist/reference/equations-validate.js +1 -1
  30. package/dist/reference/equations.d.mts +66 -35
  31. package/dist/reference/equations.mjs +142 -40
  32. package/dist/reference/gate-abbreviations.d.ts +17 -0
  33. package/dist/reference/gate-abbreviations.js +64 -0
  34. package/dist/reference/gate-math.d.ts +28 -5
  35. package/dist/reference/gate-math.js +5 -2
  36. package/dist/reference/gate-prose-notation.d.ts +60 -0
  37. package/dist/reference/gate-prose-notation.js +125 -0
  38. package/dist/reference/keyed-float-validate.d.ts +14 -8
  39. package/dist/reference/keyed-float-validate.js +1 -1
  40. package/dist/reference/keyed-jsx-floats.d.mts +24 -21
  41. package/dist/reference/keyed-jsx-floats.mjs +90 -41
  42. package/dist/reference/knowledge-checks-validate.d.ts +35 -0
  43. package/dist/reference/knowledge-checks-validate.js +78 -0
  44. package/dist/reference/knowledge-checks.d.mts +25 -0
  45. package/dist/reference/knowledge-checks.mjs +45 -0
  46. package/dist/reference/lab-math-scope.d.ts +9 -1
  47. package/dist/reference/lab-math-scope.js +32 -4
  48. package/dist/reference/math-glyphs.d.mts +30 -0
  49. package/dist/reference/math-glyphs.mjs +367 -0
  50. package/dist/reference/mdx-attributes.d.mts +13 -0
  51. package/dist/reference/mdx-attributes.mjs +38 -0
  52. package/dist/reference/mixed-content.d.ts +17 -0
  53. package/dist/reference/mixed-content.js +29 -0
  54. package/dist/reference/page-notation.d.ts +52 -0
  55. package/dist/reference/page-notation.js +171 -0
  56. package/dist/reference/raw-floats-validate.d.ts +18 -0
  57. package/dist/reference/raw-floats-validate.js +53 -0
  58. package/dist/reference/raw-floats.d.mts +18 -0
  59. package/dist/reference/raw-floats.mjs +129 -0
  60. package/dist/reference/rehype-heading-anchors.d.mts +1 -1
  61. package/dist/reference/rehype-heading-anchors.mjs +107 -1
  62. package/dist/reference/rehype-table-header-labels.d.mts +26 -0
  63. package/dist/reference/rehype-table-header-labels.mjs +152 -0
  64. package/dist/reference/remark-abbreviations.d.mts +7 -0
  65. package/dist/reference/remark-abbreviations.mjs +174 -0
  66. package/dist/reference/remark-memo.d.mts +16 -0
  67. package/dist/reference/remark-memo.mjs +339 -0
  68. package/dist/reference/remark-notation.d.mts +102 -5
  69. package/dist/reference/remark-notation.mjs +506 -50
  70. package/dist/reference/remark-short-note.d.mts +4 -0
  71. package/dist/reference/remark-short-note.mjs +237 -0
  72. package/dist/reference/types.d.ts +16 -1
  73. package/dist/reference/units-vocab.js +4 -0
  74. package/docs/VERSIONING.md +33 -4
  75. package/docs/authoring/README.md +12 -0
  76. package/docs/authoring/agents.md +17 -2
  77. package/docs/authoring/components.md +135 -0
  78. package/docs/authoring/migrating.md +154 -7
  79. package/docs/authoring/notation.md +568 -8
  80. package/docs/authoring/structure.md +251 -0
  81. package/docs/authoring/visualizations.md +36 -3
  82. package/package.json +5 -2
  83. package/src/compiler/astro.ts +332 -0
  84. package/src/compiler/cli.ts +33 -10
  85. package/src/compiler/collections.ts +45 -4
  86. package/src/compiler/course-config.ts +131 -3
  87. package/src/compiler/exports/standalone-html.ts +963 -0
  88. package/src/compiler/lesson-derivation.ts +2 -2
  89. package/src/compiler/manifest.ts +367 -49
  90. package/src/components/HoverPanel.astro +45 -4
  91. package/src/components/abbreviation/AbbreviationGlossary.astro +70 -0
  92. package/src/components/abbreviation/AbbreviationLayer.astro +127 -0
  93. package/src/components/assessments/AssessmentRunner.tsx +52 -17
  94. package/src/components/assessments/AssessmentSet.astro +98 -14
  95. package/src/components/assessments/RenderedContent.tsx +55 -0
  96. package/src/components/assessments/assessment-randomization.ts +16 -0
  97. package/src/components/diagram/Diagram.astro +444 -83
  98. package/src/components/disclosure/hover-preview.ts +202 -0
  99. package/src/components/disclosure/persisted-disclosure.ts +66 -0
  100. package/src/components/examples/CompactExamples.astro +2 -0
  101. package/src/components/figure/Figure.astro +279 -10
  102. package/src/components/memo/Memo.astro +453 -0
  103. package/src/components/memo/ShortNote.astro +223 -0
  104. package/src/components/notation/NotationFormula.astro +18 -1
  105. package/src/components/notation/NotationLayer.astro +116 -8
  106. package/src/components/notation/render-notation-math.ts +39 -2
  107. package/src/components/notation/safe-math-to-react.ts +50 -0
  108. package/src/components/reference/FloatRefLayer.astro +233 -15
  109. package/src/components/starlight/CollapsibleSections.astro +461 -0
  110. package/src/components/starlight/DiagramEnhancer.astro +385 -0
  111. package/src/components/starlight/HeadingAnchorEnhancer.astro +5 -17
  112. package/src/components/starlight/KeyedFloatEnhancer.astro +81 -3
  113. package/src/components/starlight/LayoutHeader.astro +50 -0
  114. package/src/components/starlight/LayoutSidebar.astro +62 -1
  115. package/src/components/starlight/LayoutSocialIcons.astro +41 -2
  116. package/src/components/starlight/LessonExportActions.astro +436 -0
  117. package/src/components/starlight/LessonFooter.astro +38 -2
  118. package/src/components/starlight/section-parts.ts +31 -0
  119. package/src/components/table/Table.astro +296 -9
  120. package/src/components/table/TableOverflowExpander.astro +489 -0
  121. package/src/content-config.ts +20 -0
  122. package/src/reference/base-abbreviations.mjs +60 -0
  123. package/src/reference/base-operators.mjs +40 -0
  124. package/src/reference/base-roman-labels.mjs +43 -0
  125. package/src/reference/consistency.ts +73 -2
  126. package/src/reference/diagrams-validate.ts +40 -0
  127. package/src/reference/equations-validate.ts +6 -4
  128. package/src/reference/equations.mjs +142 -40
  129. package/src/reference/gate-abbreviations.ts +128 -0
  130. package/src/reference/gate-math.ts +27 -7
  131. package/src/reference/gate-prose-notation.ts +224 -0
  132. package/src/reference/keyed-float-validate.ts +12 -9
  133. package/src/reference/keyed-jsx-floats.mjs +90 -41
  134. package/src/reference/knowledge-checks-validate.ts +130 -0
  135. package/src/reference/knowledge-checks.mjs +45 -0
  136. package/src/reference/lab-math-scope.ts +45 -3
  137. package/src/reference/math-glyphs.mjs +367 -0
  138. package/src/reference/mdx-attributes.mjs +38 -0
  139. package/src/reference/mixed-content.ts +69 -0
  140. package/src/reference/page-notation.ts +252 -0
  141. package/src/reference/raw-floats-validate.ts +103 -0
  142. package/src/reference/raw-floats.mjs +129 -0
  143. package/src/reference/rehype-heading-anchors.mjs +107 -1
  144. package/src/reference/rehype-table-header-labels.mjs +152 -0
  145. package/src/reference/remark-abbreviations.mjs +174 -0
  146. package/src/reference/remark-memo.mjs +339 -0
  147. package/src/reference/remark-notation.mjs +506 -50
  148. package/src/reference/remark-short-note.mjs +237 -0
  149. package/src/reference/types.ts +16 -0
  150. package/src/reference/units-vocab.ts +4 -0
  151. package/src/styles/global.css +1125 -50
@@ -0,0 +1,202 @@
1
+ /**
2
+ * A lightweight "peek" overlay: hovering or focusing a collapsed trigger
3
+ * shows a preview of its content floating over the page below it, dismissed
4
+ * once the pointer/focus leaves. See `docs/plans/collapsible-disclosure.md`
5
+ * Part 0.
6
+ *
7
+ * This is deliberately not built on `../hover-panel.ts`'s `createHoverPanel`
8
+ * — that engine is a single *reference* card with pin/jump semantics and a
9
+ * cross-group exclusivity arbiter (`claimActiveSurface`); a collapsed-content
10
+ * peek has no pin state and doesn't need to coordinate with the
11
+ * notation/citation/float-ref panel group. It does reuse that group's
12
+ * `requestAnimationFrame`-scheduled leave check (see `scheduleLeave` in
13
+ * `FloatRefLayer.astro`) so a pointer move from the trigger onto the peek
14
+ * itself doesn't cause a flicker-close.
15
+ *
16
+ * One peek panel is created lazily and shared across every trigger wired
17
+ * with `attachHoverPeek` — only one can be hovered at a time, so reusing a
18
+ * single element avoids paying DOM-churn cost per hover.
19
+ */
20
+
21
+ export interface HoverPeekOptions {
22
+ /**
23
+ * Produce the node to show in the peek. Called fresh each time the peek
24
+ * opens. Must return a node safe to insert into the peek panel — a clone —
25
+ * never a live node the document still needs in its original place, since
26
+ * inserting it here would move it out from there.
27
+ */
28
+ getContent(): Node;
29
+ /** Cap on the peek's own scroll height, in `em`. Default: 16. */
30
+ maxHeightEm?: number;
31
+ /** Accessible label for the peek region. Default: `"Preview"`. */
32
+ label?: string;
33
+ }
34
+
35
+ let panel: HTMLElement | null = null;
36
+ let scroller: HTMLElement | null = null;
37
+ let activeTrigger: HTMLElement | null = null;
38
+ let leaveTimer: number | null = null;
39
+ let globalListenersReady = false;
40
+
41
+ function cancelLeave(): void {
42
+ if (leaveTimer !== null) {
43
+ window.cancelAnimationFrame(leaveTimer);
44
+ leaveTimer = null;
45
+ }
46
+ }
47
+
48
+ function close(): void {
49
+ if (!panel) return;
50
+ panel.hidden = true;
51
+ scroller?.replaceChildren();
52
+ activeTrigger = null;
53
+ }
54
+
55
+ /**
56
+ * Force-close the peek if `trigger` is the one currently showing it.
57
+ * For callers whose trigger can become "not worth peeking at" without a
58
+ * pointer/focus leave — e.g. a collapsed `<details>` expanded by clicking
59
+ * its still-hovered `<summary>`, where the real content is now on screen
60
+ * and the peek would just be a stale duplicate floating over it.
61
+ */
62
+ export function closeHoverPeek(trigger: HTMLElement): void {
63
+ if (activeTrigger === trigger) close();
64
+ }
65
+
66
+ function scheduleLeave(): void {
67
+ cancelLeave();
68
+ leaveTimer = window.requestAnimationFrame(() => {
69
+ leaveTimer = null;
70
+ if (!activeTrigger || !panel) return;
71
+ // `:focus-visible`, not `=== document.activeElement` / `:focus`: the
72
+ // trigger is very often something like a `<summary>` that a *click*
73
+ // also focuses as a side effect (e.g. clicking it to toggle a
74
+ // `<details>`) — a plain focus check would then treat that leftover,
75
+ // non-keyboard focus as "still viewing it" forever, since nothing else
76
+ // ever takes focus to fire `blur`. `:focus-visible` is only true for
77
+ // focus a browser considers keyboard/keyboard-equivalent, so a mouse
78
+ // click doesn't leave the peek stuck open after the pointer leaves.
79
+ const stillActive =
80
+ activeTrigger.matches(':hover') ||
81
+ activeTrigger.matches(':focus-visible') ||
82
+ panel.matches(':hover') ||
83
+ panel.matches(':focus-within');
84
+ if (!stillActive) close();
85
+ });
86
+ }
87
+
88
+ function position(trigger: HTMLElement, target: HTMLElement): void {
89
+ const edge = 12;
90
+ const gap = 6;
91
+ const anchorRect = trigger.getBoundingClientRect();
92
+
93
+ target.style.visibility = 'hidden';
94
+ target.hidden = false;
95
+ const rect = target.getBoundingClientRect();
96
+
97
+ const spaceBelow = window.innerHeight - edge - anchorRect.bottom - gap;
98
+ const spaceAbove = anchorRect.top - edge - gap;
99
+ const placeBelow = spaceBelow >= rect.height || spaceBelow >= spaceAbove;
100
+
101
+ let top = placeBelow
102
+ ? anchorRect.bottom + gap
103
+ : anchorRect.top - gap - rect.height;
104
+ top = Math.max(edge, Math.min(top, window.innerHeight - edge - rect.height));
105
+
106
+ let left = anchorRect.left;
107
+ left = Math.max(edge, Math.min(left, window.innerWidth - edge - rect.width));
108
+
109
+ target.style.top = `${Math.round(top)}px`;
110
+ target.style.left = `${Math.round(left)}px`;
111
+ target.style.visibility = 'visible';
112
+ }
113
+
114
+ function ensurePanel(): { panel: HTMLElement; scroller: HTMLElement } {
115
+ if (panel && scroller) return { panel, scroller };
116
+
117
+ panel = document.createElement('div');
118
+ panel.dataset.hoverPeekPanel = 'true';
119
+ panel.hidden = true;
120
+ panel.style.cssText = [
121
+ 'position: fixed',
122
+ 'z-index: 95',
123
+ 'overflow: hidden',
124
+ 'width: min(26rem, calc(100vw - 1.5rem))',
125
+ 'border: 1px solid var(--sl-color-gray-5, #888)',
126
+ 'border-radius: 0.65rem',
127
+ 'background: var(--sl-color-bg, Canvas)',
128
+ 'box-shadow: var(--sl-shadow-lg, 0 10px 30px rgba(0, 0, 0, 0.25))',
129
+ 'color: var(--sl-color-text, CanvasText)',
130
+ 'font-size: var(--sl-text-sm, 0.875rem)',
131
+ 'line-height: 1.5',
132
+ ].join(';');
133
+
134
+ scroller = document.createElement('div');
135
+ scroller.style.cssText =
136
+ 'overflow: auto; max-height: var(--hover-peek-max-height, 16em); padding: 0.75rem 1rem;';
137
+ panel.append(scroller);
138
+ document.body.append(panel);
139
+
140
+ panel.addEventListener('pointerenter', cancelLeave);
141
+ panel.addEventListener('pointerleave', scheduleLeave);
142
+
143
+ if (!globalListenersReady) {
144
+ globalListenersReady = true;
145
+ document.addEventListener('keydown', (event) => {
146
+ if (event.key === 'Escape' && activeTrigger) close();
147
+ });
148
+ document.addEventListener('pointerdown', (event) => {
149
+ if (!activeTrigger || !panel) return;
150
+ const target = event.target instanceof Node ? event.target : null;
151
+ const insideTrigger = !!target && activeTrigger.contains(target);
152
+ const insidePanel = !!target && panel.contains(target);
153
+ if (!insideTrigger && !insidePanel) close();
154
+ });
155
+ }
156
+
157
+ return { panel, scroller };
158
+ }
159
+
160
+ /**
161
+ * Wire `trigger` so hovering or focusing it shows a peek of
162
+ * `options.getContent()`. Idempotent — calling it again on the same trigger
163
+ * (e.g. a re-run enhancer walking the DOM again) is a no-op.
164
+ */
165
+ export function attachHoverPeek(
166
+ trigger: HTMLElement,
167
+ options: HoverPeekOptions,
168
+ ): void {
169
+ if (trigger.dataset.hoverPeekWired === 'true') return;
170
+ trigger.dataset.hoverPeekWired = 'true';
171
+
172
+ const maxHeightEm = options.maxHeightEm ?? 16;
173
+ const label = options.label ?? 'Preview';
174
+
175
+ const show = () => {
176
+ const { panel: el, scroller: scrollEl } = ensurePanel();
177
+ if (activeTrigger === trigger) {
178
+ cancelLeave();
179
+ return;
180
+ }
181
+ activeTrigger = trigger;
182
+ el.setAttribute('aria-label', label);
183
+ el.style.setProperty('--hover-peek-max-height', `${maxHeightEm}em`);
184
+ scrollEl.replaceChildren(options.getContent());
185
+ position(trigger, el);
186
+ };
187
+
188
+ const leave = () => {
189
+ if (activeTrigger === trigger) scheduleLeave();
190
+ };
191
+
192
+ trigger.addEventListener('pointerenter', (event) => {
193
+ if (event.pointerType === 'touch') return;
194
+ show();
195
+ });
196
+ trigger.addEventListener('pointerleave', (event) => {
197
+ if (event.pointerType === 'touch') return;
198
+ leave();
199
+ });
200
+ trigger.addEventListener('focus', show);
201
+ trigger.addEventListener('blur', leave);
202
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Shared open/closed persistence for a native `<details>` disclosure.
3
+ *
4
+ * Every collapsible built on top of this (figures, sections, tables) wants
5
+ * the same three behaviors: restore the reader's last choice before the
6
+ * element paints, save reader-triggered native `toggle`s, and degrade to the
7
+ * author's `defaultOpen` when nothing is saved yet or storage is unavailable.
8
+ * This module is the one place that logic lives — see
9
+ * `docs/plans/collapsible-disclosure.md` Part 0.
10
+ *
11
+ * Storage goes through `PreferenceStore` only (never `localStorage`
12
+ * directly — `tests/unit/seams.test.ts` enforces that boundary). The key is
13
+ * scoped by `location.pathname` so the same figure/section/table `id` can
14
+ * repeat across pages without one page's saved state leaking into another's.
15
+ *
16
+ * **Wiring**: call this from a plain top-level `<script>` in the consuming
17
+ * Astro component — not from inside an `astro:page-load` listener — the same
18
+ * way `LayoutSidebar.astro` restores its sidebar-group compact state. A
19
+ * module script still runs deferred, but well before the reader can see the
20
+ * element; wrapping it in `astro:page-load` instead would visibly flash the
21
+ * `defaultOpen` state first on every navigation.
22
+ */
23
+ import { createBrowserPreferenceStore } from '../../session/PreferenceStore';
24
+
25
+ const store = createBrowserPreferenceStore();
26
+
27
+ function storageKey(key: string): string {
28
+ return `disclosure:${location.pathname}:${key}`;
29
+ }
30
+
31
+ /**
32
+ * Apply the reader's saved open/closed state to `details` (falling back to
33
+ * `defaultOpen`) and persist future changes.
34
+ *
35
+ * Callers that don't want persistence at all (an author-set
36
+ * `rememberState={false}`) should simply not call this — set
37
+ * `details.open = defaultOpen` directly instead.
38
+ */
39
+ export function wirePersistedDetails(
40
+ details: HTMLDetailsElement,
41
+ key: string,
42
+ defaultOpen: boolean,
43
+ ): void {
44
+ const fullKey = storageKey(key);
45
+ const saved = store.get(fullKey);
46
+ details.open = saved === null ? defaultOpen : saved === 'true';
47
+
48
+ details.addEventListener('toggle', () => {
49
+ if (details.hasAttribute('data-explico-temporary-open')) return;
50
+ store.set(fullKey, String(details.open));
51
+ });
52
+ }
53
+
54
+ /**
55
+ * Save an open/closed choice directly, bypassing the `toggle`-event
56
+ * listener above. For a caller that animates its own collapse (a custom
57
+ * shrink-then-`details.open = false`, say) and so can't rely on reading
58
+ * `details.open` at `toggle` time — the attribute has to stay `true` for
59
+ * the whole animation (the browser refuses to render a closed `<details>`'s
60
+ * content at all, animated or not), so the boolean this function is told to
61
+ * save is necessarily the caller's own *intended* end state, not whatever
62
+ * `details.open` happens to read at the moment of the call.
63
+ */
64
+ export function savePersistedOpenState(key: string, open: boolean): void {
65
+ store.set(storageKey(key), String(open));
66
+ }
@@ -227,11 +227,13 @@ const {
227
227
 
228
228
  let openBeforePrint: boolean | undefined;
229
229
  const enterPrintMode = () => {
230
+ if (document.querySelector('[data-explico-print-controller]')) return;
230
231
  if (openBeforePrint !== undefined) return;
231
232
  openBeforePrint = details.open;
232
233
  details.open = true;
233
234
  };
234
235
  const leavePrintMode = () => {
236
+ if (document.querySelector('[data-explico-print-controller]')) return;
235
237
  if (openBeforePrint === undefined) return;
236
238
  details.open = openBeforePrint;
237
239
  openBeforePrint = undefined;
@@ -13,21 +13,75 @@
13
13
  * interactive content — a hydrated `client:*` island cloned into a floating
14
14
  * card would be an inert, non-hydrated, id-colliding duplicate, not a
15
15
  * working preview.
16
+ *
17
+ * Collapsible + persistent (docs/plans/collapsible-disclosure.md Part 1): the
18
+ * figure body sits in a native `<details>` whose `<summary>` is a persistent
19
+ * title bar showing `title` (a short label, distinct from the longer
20
+ * `caption`) — reading as an OS-window minimize/restore control while
21
+ * staying entirely native `<details>` semantics (`aria-expanded`, keyboard
22
+ * toggle, no bespoke ARIA needed). The rotating chevron itself isn't
23
+ * authored here: `@astrojs/starlight`'s own `markdown.css` already gives
24
+ * every `.sl-markdown-content summary` a disclosure-triangle `::before`
25
+ * marker that rotates 90° on `[open]` — reproducing that ourselves would
26
+ * just draw two.
27
+ *
28
+ * The caption + permalink number live in a `<figcaption>` *inside*
29
+ * `<details>`, after `.keyed-figure__body` — the pre-collapsible reading
30
+ * position (caption below the figure), but now part of the disclosure
31
+ * content: a native closed `<details>` hides it along with the body, since
32
+ * hovering the (collapsed) summary already surfaces the caption via the
33
+ * hover-peek card — showing it twice would be redundant. `textContent`
34
+ * reads on a `display: none` element still work, so `FloatRefLayer`'s
35
+ * preview card (`captionFor` in `FloatRefLayer.astro`) can read it
36
+ * regardless of the source figure's collapse state.
37
+ *
38
+ * The title-bar anchor link (`.keyed-figure__title-anchor`) sits next to the
39
+ * title text but, like the permalink number, is a DOM sibling of `<summary>`
40
+ * rather than a child of it — positioned onto the same line with CSS
41
+ * (mirroring Starlight's own heading-wrapper anchor trick in
42
+ * `anchor-links.css`, adapted since our "heading" is an interactive
43
+ * `<summary>` rather than inert text). A focusable `<a>` nested *inside* a
44
+ * `<summary>` trips axe's `nested-interactive` check (two independently
45
+ * activatable controls, unclear which one wins on click/Enter) — the
46
+ * `figures.spec.ts` axe test enforces this. Keeping it a sibling, wrapped
47
+ * together in `.keyed-figure__titlebar` with `position: relative` +
48
+ * `position: absolute`, avoids that while still reading as "next to the
49
+ * title".
50
+ *
51
+ * `defaultOpen` sets the first-visit state; `rememberState` (default `true`)
52
+ * persists the reader's later choice per-page via
53
+ * `../disclosure/persisted-disclosure`.
16
54
  */
55
+
17
56
  export interface Props {
18
57
  /** Stable key, e.g. `id="projectile-trajectory"`. Omit for a positional-only figure. */
19
58
  id?: string;
20
- /** Visible caption, shown below the figure and in the hover-preview card. */
59
+ /** Short label for the title bar / anchor. Falls back to `caption` when omitted. */
60
+ title?: string;
61
+ /** Full caption, shown below the figure (with its number) and in the hover-preview card. */
21
62
  caption: string;
22
63
  /** `false` for interactive content the hover-preview must not clone. */
23
64
  previewable?: boolean;
65
+ /** Author-chosen state for a reader with nothing saved yet. Default: `true`. */
66
+ defaultOpen?: boolean;
67
+ /** Persist the reader's open/closed choice across visits, per page. Default: `true`. */
68
+ rememberState?: boolean;
24
69
  /** Computed by `remark-notation.mjs` — never author this directly. */
25
70
  number?: string;
26
71
  /** Computed by `remark-notation.mjs` — never author this directly. */
27
72
  anchorId?: string;
28
73
  }
29
74
 
30
- const { id, caption, previewable = true, number, anchorId } = Astro.props;
75
+ const {
76
+ id,
77
+ title,
78
+ caption,
79
+ previewable = true,
80
+ defaultOpen = true,
81
+ rememberState = true,
82
+ number,
83
+ anchorId,
84
+ } = Astro.props;
31
85
 
32
86
  if (typeof caption !== 'string' || !caption.trim()) {
33
87
  throw new Error('Figure requires a non-empty `caption` prop.');
@@ -37,6 +91,8 @@ if (!number || !anchorId) {
37
91
  'Figure is missing its computed `number`/`anchorId` — this page must run remark-notation with a `figures` cross-page table configured (see astro.config.mjs).',
38
92
  );
39
93
  }
94
+
95
+ const titleText = typeof title === 'string' && title.trim() ? title : caption;
40
96
  ---
41
97
 
42
98
  <figure
@@ -46,17 +102,44 @@ if (!number || !anchorId) {
46
102
  data-fig-key={id}
47
103
  data-fig-previewable={String(previewable)}
48
104
  >
49
- <div class="keyed-figure__body">
50
- <slot />
51
- </div>
52
- <figcaption class="keyed-figure__caption">
105
+ <div class="keyed-figure__titlebar">
106
+ <details
107
+ class="keyed-figure__details"
108
+ data-remember-state={String(rememberState)}
109
+ open={defaultOpen}
110
+ >
111
+ <summary class="keyed-figure__summary">
112
+ <span class="keyed-figure__title-text">{titleText}</span>
113
+ </summary>
114
+ <div class="keyed-figure__body">
115
+ <slot />
116
+ </div>
117
+ <figcaption class="keyed-figure__caption">
118
+ <a
119
+ class="keyed-figure__number"
120
+ href={`#${anchorId}`}
121
+ aria-label={`Figure ${number}`}>Figure {number}</a
122
+ >
123
+ <span class="keyed-figure__caption-text">{caption}</span>
124
+ </figcaption>
125
+ </details>
53
126
  <a
54
- class="keyed-figure__number"
127
+ class="keyed-figure__title-anchor"
55
128
  href={`#${anchorId}`}
56
- aria-label={`Figure ${number}`}>Figure {number}</a
129
+ aria-label={`Figure ${number} titled “${titleText}”`}
57
130
  >
58
- <span class="keyed-figure__caption-text">{caption}</span>
59
- </figcaption>
131
+ <svg
132
+ aria-hidden="true"
133
+ width="16"
134
+ height="16"
135
+ viewBox="0 0 24 24"
136
+ fill="currentColor"
137
+ ><path
138
+ 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"
139
+ ></path></svg
140
+ >
141
+ </a>
142
+ </div>
60
143
  </figure>
61
144
 
62
145
  <style>
@@ -65,6 +148,96 @@ if (!number || !anchorId) {
65
148
  padding: 0;
66
149
  }
67
150
 
151
+ /* One card for the whole figure — title bar and body together — rather
152
+ than a separate title strip on top of a second bordered box (the
153
+ `.lab-shell` a wrapped lab already draws around itself): the title now
154
+ lives *inside* this card, so `.lab-shell` gets its own border/background
155
+ stripped back to plain spacing when nested here (see `global.css`,
156
+ `.keyed-figure__body .lab-shell`) to avoid drawing that second box.
157
+ Same treatment as `.lab-shell` itself, for one consistent "card"
158
+ language sitewide — overrides Starlight's own default `<details>`
159
+ accent rule (`markdown.css`, a rule down the left side) entirely. */
160
+ .keyed-figure__details {
161
+ border: 1px solid var(--sl-color-gray-5);
162
+ border-block-start: 4px solid var(--sl-color-accent);
163
+ border-radius: 0.2rem;
164
+ background: color-mix(in srgb, var(--sl-color-bg) 94%, var(--sl-color-accent) 6%);
165
+ box-shadow: 0 1rem 2.6rem rgba(0, 0, 0, 0.13);
166
+ padding: clamp(1rem, 3vw, 1.5rem);
167
+ }
168
+
169
+ .keyed-figure__titlebar {
170
+ position: relative;
171
+ /* A custom property, not `font-size` itself: `font-size` is inherited
172
+ CSS, and `.keyed-figure__body` (the slotted figure content) is a
173
+ *descendant* of this wrapper too — setting `font-size` here directly
174
+ would inflate the figure's real content, not just the title. Read
175
+ this back explicitly on just the summary and the anchor icon below. */
176
+ --keyed-figure-title-size: var(--sl-text-h3);
177
+ }
178
+
179
+ .keyed-figure__summary {
180
+ /* This is a title, not a caption — size it like the headings labs
181
+ already use internally (e.g. LaunchAngleExplorer's own <h3>), not
182
+ like the muted `.keyed-figure__caption-text` below. */
183
+ font-size: var(--keyed-figure-title-size);
184
+ line-height: var(--sl-line-height-headings, 1.2);
185
+ /* Room for the absolutely-positioned anchor icon so title text never
186
+ runs under it. */
187
+ padding-inline-end: 1.5em;
188
+ }
189
+
190
+ .keyed-figure__title-text {
191
+ font-weight: 600;
192
+ color: var(--sl-color-white);
193
+ }
194
+
195
+ .keyed-figure__title-anchor {
196
+ position: absolute;
197
+ top: 0.15em;
198
+ inset-inline-end: 0;
199
+ display: inline-flex;
200
+ align-items: center;
201
+ justify-content: center;
202
+ font-size: var(--keyed-figure-title-size);
203
+ width: 1em;
204
+ height: 1em;
205
+ color: var(--sl-color-gray-3);
206
+ text-decoration: none;
207
+ /* Prevent double/triple clicks from selecting the icon's a11y text. */
208
+ -webkit-user-select: none;
209
+ user-select: none;
210
+ }
211
+
212
+ .keyed-figure__title-anchor svg {
213
+ width: 0.7em;
214
+ height: 0.7em;
215
+ }
216
+
217
+ .keyed-figure__title-anchor:hover,
218
+ .keyed-figure__title-anchor:focus-visible {
219
+ color: var(--sl-color-text-accent);
220
+ }
221
+
222
+ .keyed-figure__title-anchor:focus-visible {
223
+ outline: 2px solid var(--sl-color-accent);
224
+ outline-offset: 0.15rem;
225
+ }
226
+
227
+ /* Hidden at rest, revealed on hover/focus of the title bar — same
228
+ "hover/focus to reveal" treatment as Starlight's own heading anchors
229
+ (`anchor-links.css`). */
230
+ @media (hover: hover) {
231
+ .keyed-figure__title-anchor {
232
+ opacity: 0;
233
+ }
234
+
235
+ .keyed-figure__title-anchor:focus-visible,
236
+ .keyed-figure__titlebar:hover .keyed-figure__title-anchor {
237
+ opacity: 1;
238
+ }
239
+ }
240
+
68
241
  .keyed-figure__body {
69
242
  overflow-x: auto;
70
243
  }
@@ -100,5 +273,101 @@ if (!number || !anchorId) {
100
273
  .keyed-figure__number {
101
274
  color: inherit;
102
275
  }
276
+
277
+ .keyed-figure__title-anchor {
278
+ display: none;
279
+ }
280
+
281
+ /* The disclosure triangle (Starlight's summary::before) is meaningless
282
+ once printed content is forced open below. */
283
+ .keyed-figure__summary::before {
284
+ display: none !important;
285
+ }
286
+
287
+ /* A closed <details> hides its non-summary children via the UA
288
+ stylesheet; force the body and caption visible regardless of
289
+ on-screen state so a collapsed figure still prints its content. */
290
+ .keyed-figure__body,
291
+ .keyed-figure__caption {
292
+ display: block !important;
293
+ }
103
294
  }
104
295
  </style>
296
+
297
+ <script>
298
+ import { wirePersistedDetails } from '../disclosure/persisted-disclosure';
299
+ import { attachHoverPeek, closeHoverPeek } from '../disclosure/hover-preview';
300
+
301
+ document
302
+ .querySelectorAll<HTMLDetailsElement>(
303
+ '.keyed-figure__details:not([data-figure-wired])',
304
+ )
305
+ .forEach((details) => {
306
+ details.dataset.figureWired = 'true';
307
+ const figure = details.closest<HTMLElement>('.keyed-figure');
308
+ if (!figure || !figure.id) return;
309
+
310
+ // The SSR-rendered `open` attribute already carries the author's
311
+ // `defaultOpen` prop verbatim — read it before anything below mutates
312
+ // `.open`, rather than threading the prop value through a second data
313
+ // attribute.
314
+ const defaultOpen = details.open;
315
+ if (details.dataset.rememberState === 'true') {
316
+ wirePersistedDetails(details, `figure:${figure.id}`, defaultOpen);
317
+ }
318
+
319
+ const summary = details.querySelector<HTMLElement>(
320
+ ':scope > summary',
321
+ );
322
+
323
+ // Deep-linking to this figure (from a `[[fig-key]]` jump or a shared
324
+ // URL) must reveal it even if a reader had left it collapsed — the
325
+ // figure's id lives on the always-visible <figure>, not on the
326
+ // <details> itself, so the browser's native :target reveal doesn't
327
+ // apply here and this has to be done by hand.
328
+ const openIfTargeted = () => {
329
+ if (location.hash === `#${figure.id}`) details.open = true;
330
+ };
331
+ openIfTargeted();
332
+ window.addEventListener('hashchange', openIfTargeted);
333
+
334
+ if (!summary) return;
335
+
336
+ // The peek is only useful while collapsed — once expanded, the real
337
+ // content is already on screen right below the title bar, and duping
338
+ // it into a floating box would just be noise. `attachHoverPeek` itself
339
+ // doesn't know about "collapsed", so gate it here: a capture-order
340
+ // listener added *before* the wiring call runs first on the same
341
+ // element and can suppress the event before `attachHoverPeek`'s own
342
+ // handler sees it.
343
+ const suppressWhenOpen = (event: Event) => {
344
+ if (details.open) event.stopImmediatePropagation();
345
+ };
346
+ summary.addEventListener('pointerenter', suppressWhenOpen);
347
+ summary.addEventListener('focus', suppressWhenOpen);
348
+
349
+ // Expanding while the peek is still showing (hover, then click to
350
+ // open without the pointer ever leaving) would otherwise leave a now-
351
+ // redundant preview floating over the real content that just
352
+ // appeared underneath it.
353
+ details.addEventListener('toggle', () => {
354
+ if (details.open) closeHoverPeek(summary);
355
+ });
356
+
357
+ // The caption now lives in the <figcaption> after <details>, not
358
+ // inside the summary — query it from the figure, not the summary.
359
+ const captionText =
360
+ figure.querySelector('.keyed-figure__caption-text')?.textContent ??
361
+ '';
362
+ attachHoverPeek(summary, {
363
+ label: `Figure ${figure.dataset.figNumber ?? ''} preview`,
364
+ getContent: () => {
365
+ const wrap = document.createElement('div');
366
+ const strong = document.createElement('strong');
367
+ strong.textContent = `Figure ${figure.dataset.figNumber ?? ''}`;
368
+ wrap.append(strong, document.createTextNode(` — ${captionText}`));
369
+ return wrap;
370
+ },
371
+ });
372
+ });
373
+ </script>