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.
- package/CHANGELOG.md +344 -1
- package/README.md +117 -3
- package/dist/compiler/astro.d.ts +38 -0
- package/dist/compiler/astro.js +258 -0
- package/dist/compiler/cli.d.ts +13 -0
- package/dist/compiler/cli.js +32 -12
- package/dist/compiler/collections.d.ts +3 -2
- package/dist/compiler/collections.js +28 -1
- package/dist/compiler/course-config.d.ts +86 -4
- package/dist/compiler/course-config.js +39 -0
- package/dist/compiler/exports/standalone-html.d.ts +34 -0
- package/dist/compiler/exports/standalone-html.js +803 -0
- package/dist/compiler/lesson-derivation.d.ts +1 -1
- package/dist/compiler/lesson-derivation.js +2 -2
- package/dist/compiler/manifest.d.ts +112 -7
- package/dist/compiler/manifest.js +205 -40
- package/dist/compiler/resolution-report.d.ts +4 -0
- package/dist/reference/base-abbreviations.d.mts +30 -0
- package/dist/reference/base-abbreviations.mjs +60 -0
- package/dist/reference/base-operators.d.mts +15 -0
- package/dist/reference/base-operators.mjs +40 -0
- package/dist/reference/base-roman-labels.d.mts +35 -0
- package/dist/reference/base-roman-labels.mjs +43 -0
- package/dist/reference/consistency.d.ts +8 -2
- package/dist/reference/consistency.js +50 -3
- package/dist/reference/diagrams-validate.d.ts +21 -0
- package/dist/reference/diagrams-validate.js +13 -0
- package/dist/reference/equations-validate.d.ts +8 -3
- package/dist/reference/equations-validate.js +1 -1
- package/dist/reference/equations.d.mts +66 -35
- package/dist/reference/equations.mjs +142 -40
- package/dist/reference/gate-abbreviations.d.ts +17 -0
- package/dist/reference/gate-abbreviations.js +64 -0
- package/dist/reference/gate-math.d.ts +28 -5
- package/dist/reference/gate-math.js +5 -2
- package/dist/reference/gate-prose-notation.d.ts +60 -0
- package/dist/reference/gate-prose-notation.js +125 -0
- package/dist/reference/keyed-float-validate.d.ts +14 -8
- package/dist/reference/keyed-float-validate.js +1 -1
- package/dist/reference/keyed-jsx-floats.d.mts +24 -21
- package/dist/reference/keyed-jsx-floats.mjs +90 -41
- package/dist/reference/knowledge-checks-validate.d.ts +35 -0
- package/dist/reference/knowledge-checks-validate.js +78 -0
- package/dist/reference/knowledge-checks.d.mts +25 -0
- package/dist/reference/knowledge-checks.mjs +45 -0
- package/dist/reference/lab-math-scope.d.ts +9 -1
- package/dist/reference/lab-math-scope.js +32 -4
- package/dist/reference/math-glyphs.d.mts +30 -0
- package/dist/reference/math-glyphs.mjs +367 -0
- package/dist/reference/mdx-attributes.d.mts +13 -0
- package/dist/reference/mdx-attributes.mjs +38 -0
- package/dist/reference/mixed-content.d.ts +17 -0
- package/dist/reference/mixed-content.js +29 -0
- package/dist/reference/page-notation.d.ts +52 -0
- package/dist/reference/page-notation.js +171 -0
- package/dist/reference/raw-floats-validate.d.ts +18 -0
- package/dist/reference/raw-floats-validate.js +53 -0
- package/dist/reference/raw-floats.d.mts +18 -0
- package/dist/reference/raw-floats.mjs +129 -0
- package/dist/reference/rehype-heading-anchors.d.mts +1 -1
- package/dist/reference/rehype-heading-anchors.mjs +107 -1
- package/dist/reference/rehype-table-header-labels.d.mts +26 -0
- package/dist/reference/rehype-table-header-labels.mjs +152 -0
- package/dist/reference/remark-abbreviations.d.mts +7 -0
- package/dist/reference/remark-abbreviations.mjs +174 -0
- package/dist/reference/remark-memo.d.mts +16 -0
- package/dist/reference/remark-memo.mjs +339 -0
- package/dist/reference/remark-notation.d.mts +102 -5
- package/dist/reference/remark-notation.mjs +506 -50
- package/dist/reference/remark-short-note.d.mts +4 -0
- package/dist/reference/remark-short-note.mjs +237 -0
- package/dist/reference/types.d.ts +16 -1
- package/dist/reference/units-vocab.js +4 -0
- package/docs/VERSIONING.md +33 -4
- package/docs/authoring/README.md +12 -0
- package/docs/authoring/agents.md +17 -2
- package/docs/authoring/components.md +135 -0
- package/docs/authoring/migrating.md +154 -7
- package/docs/authoring/notation.md +568 -8
- package/docs/authoring/structure.md +251 -0
- package/docs/authoring/visualizations.md +36 -3
- package/package.json +5 -2
- package/src/compiler/astro.ts +332 -0
- package/src/compiler/cli.ts +33 -10
- package/src/compiler/collections.ts +45 -4
- package/src/compiler/course-config.ts +131 -3
- package/src/compiler/exports/standalone-html.ts +963 -0
- package/src/compiler/lesson-derivation.ts +2 -2
- package/src/compiler/manifest.ts +367 -49
- package/src/components/HoverPanel.astro +45 -4
- package/src/components/abbreviation/AbbreviationGlossary.astro +70 -0
- package/src/components/abbreviation/AbbreviationLayer.astro +127 -0
- package/src/components/assessments/AssessmentRunner.tsx +52 -17
- package/src/components/assessments/AssessmentSet.astro +98 -14
- package/src/components/assessments/RenderedContent.tsx +55 -0
- package/src/components/assessments/assessment-randomization.ts +16 -0
- package/src/components/diagram/Diagram.astro +444 -83
- package/src/components/disclosure/hover-preview.ts +202 -0
- package/src/components/disclosure/persisted-disclosure.ts +66 -0
- package/src/components/examples/CompactExamples.astro +2 -0
- package/src/components/figure/Figure.astro +279 -10
- package/src/components/memo/Memo.astro +453 -0
- package/src/components/memo/ShortNote.astro +223 -0
- package/src/components/notation/NotationFormula.astro +18 -1
- package/src/components/notation/NotationLayer.astro +116 -8
- package/src/components/notation/render-notation-math.ts +39 -2
- package/src/components/notation/safe-math-to-react.ts +50 -0
- package/src/components/reference/FloatRefLayer.astro +233 -15
- package/src/components/starlight/CollapsibleSections.astro +461 -0
- package/src/components/starlight/DiagramEnhancer.astro +385 -0
- package/src/components/starlight/HeadingAnchorEnhancer.astro +5 -17
- package/src/components/starlight/KeyedFloatEnhancer.astro +81 -3
- package/src/components/starlight/LayoutHeader.astro +50 -0
- package/src/components/starlight/LayoutSidebar.astro +62 -1
- package/src/components/starlight/LayoutSocialIcons.astro +41 -2
- package/src/components/starlight/LessonExportActions.astro +436 -0
- package/src/components/starlight/LessonFooter.astro +38 -2
- package/src/components/starlight/section-parts.ts +31 -0
- package/src/components/table/Table.astro +296 -9
- package/src/components/table/TableOverflowExpander.astro +489 -0
- package/src/content-config.ts +20 -0
- package/src/reference/base-abbreviations.mjs +60 -0
- package/src/reference/base-operators.mjs +40 -0
- package/src/reference/base-roman-labels.mjs +43 -0
- package/src/reference/consistency.ts +73 -2
- package/src/reference/diagrams-validate.ts +40 -0
- package/src/reference/equations-validate.ts +6 -4
- package/src/reference/equations.mjs +142 -40
- package/src/reference/gate-abbreviations.ts +128 -0
- package/src/reference/gate-math.ts +27 -7
- package/src/reference/gate-prose-notation.ts +224 -0
- package/src/reference/keyed-float-validate.ts +12 -9
- package/src/reference/keyed-jsx-floats.mjs +90 -41
- package/src/reference/knowledge-checks-validate.ts +130 -0
- package/src/reference/knowledge-checks.mjs +45 -0
- package/src/reference/lab-math-scope.ts +45 -3
- package/src/reference/math-glyphs.mjs +367 -0
- package/src/reference/mdx-attributes.mjs +38 -0
- package/src/reference/mixed-content.ts +69 -0
- package/src/reference/page-notation.ts +252 -0
- package/src/reference/raw-floats-validate.ts +103 -0
- package/src/reference/raw-floats.mjs +129 -0
- package/src/reference/rehype-heading-anchors.mjs +107 -1
- package/src/reference/rehype-table-header-labels.mjs +152 -0
- package/src/reference/remark-abbreviations.mjs +174 -0
- package/src/reference/remark-memo.mjs +339 -0
- package/src/reference/remark-notation.mjs +506 -50
- package/src/reference/remark-short-note.mjs +237 -0
- package/src/reference/types.ts +16 -0
- package/src/reference/units-vocab.ts +4 -0
- 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
|
-
/**
|
|
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 {
|
|
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-
|
|
50
|
-
<
|
|
51
|
-
|
|
52
|
-
|
|
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-
|
|
127
|
+
class="keyed-figure__title-anchor"
|
|
55
128
|
href={`#${anchorId}`}
|
|
56
|
-
aria-label={`Figure ${number}
|
|
129
|
+
aria-label={`Figure ${number} titled “${titleText}”`}
|
|
57
130
|
>
|
|
58
|
-
|
|
59
|
-
|
|
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>
|