@nu-appdev/northwestern-starlight-theme 1.1.0 → 1.2.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 +43 -4
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/scripts/mermaid-toolbar.ts +192 -36
- package/src/styles/content.css +79 -8
- package/src/styles/mermaid-toolbar.css +86 -19
- package/src/styles/navigation.css +7 -0
- package/src/styles/theme.css +4 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,18 +7,55 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.2.0] - 2026-03-25
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Fullscreen Mermaid viewer: wheel zoom and double-click zoom anchor to the pointer position.
|
|
15
|
+
- Zoom level badge (e.g. `84%`) in the fullscreen viewer.
|
|
16
|
+
- GFM footnote styles: superscript `[n]` reference markers, footnotes section with separator, styled `↩︎` back-references.
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
- Mermaid diagrams sometimes breaking on Firefox and Safari. The toolbar script read `textContent` after `astro-mermaid` had already rendered SVG into the element. Reads `data-diagram` first now.
|
|
21
|
+
- Horizontal rules in dark mode used `--nu-purple-140`, which was invisible against the background. Switched to `--nu-border-color`.
|
|
22
|
+
|
|
23
|
+
### Accessibility
|
|
24
|
+
|
|
25
|
+
- Fullscreen Mermaid overlay: `role="dialog"`, `aria-modal`, focus trap, focus restore on close.
|
|
26
|
+
- White focus rings on overlay controls, theme toggle, and menu button (all sit on dark backgrounds where the purple ring was invisible).
|
|
27
|
+
- Inline Mermaid toolbar buttons use a solid accent outline on focus instead of the near-invisible shadow ring.
|
|
28
|
+
|
|
29
|
+
## [1.1.1] - 2026-03-25
|
|
30
|
+
|
|
31
|
+
### Fixed
|
|
32
|
+
|
|
33
|
+
- Scrollbar gutter reserved space on splash pages, leaving a gap next to the topbar. Now only applied on pages with a scrollbar.
|
|
34
|
+
- Mermaid toolbar buttons use Northwestern purple focus rings instead of the browser default.
|
|
35
|
+
- Mermaid fullscreen close button dropped the red danger style and matches the other overlay buttons.
|
|
36
|
+
|
|
37
|
+
### Changed
|
|
38
|
+
|
|
39
|
+
- Mermaid overlay buttons sized and styled to match the header theme toggle.
|
|
40
|
+
|
|
41
|
+
### Added
|
|
42
|
+
|
|
43
|
+
- Fullscreen mermaid viewer supports touch: one-finger pan, pinch-to-zoom, two-finger pan.
|
|
44
|
+
- Mermaid toolbar stays visible on touch devices (`hover: none`) since hover is unavailable.
|
|
45
|
+
- SVG downloads use descriptive filenames derived from the site title, page slug, and diagram type (e.g., `northwestern-starlight-theme_examples-mermaid-flowchart.svg`).
|
|
46
|
+
|
|
10
47
|
## [1.1.0] - 2026-03-25
|
|
11
48
|
|
|
12
49
|
### Added
|
|
13
50
|
|
|
14
|
-
- Automatic favicon
|
|
15
|
-
- `homepage.imageWidth` option
|
|
51
|
+
- Automatic favicon shipped with the theme, applied without touching `public/`. Override with `favicon` in Starlight config.
|
|
52
|
+
- `homepage.imageWidth` option for hero image size. Defaults to `500px`; set higher for wide lockup images.
|
|
16
53
|
|
|
17
54
|
### Fixed
|
|
18
55
|
|
|
19
56
|
- `showTitle: false` now works in the centered hero layout (previously only worked in split layout).
|
|
20
57
|
- Search result titles and headings no longer appear white-on-white in light mode. The `.header a` selector was too broad and bled into Pagefind's search modal.
|
|
21
|
-
- Search input focus ring
|
|
58
|
+
- Search input focus ring uses Northwestern purple instead of the browser default.
|
|
22
59
|
|
|
23
60
|
### Changed
|
|
24
61
|
|
|
@@ -34,6 +71,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
34
71
|
- OpenAPI plugin compatibility with method badge preservation
|
|
35
72
|
- Reduced motion support for transitions
|
|
36
73
|
|
|
37
|
-
[Unreleased]: https://github.com/NIT-Administrative-Systems/northwestern-starlight-theme/compare/v1.
|
|
74
|
+
[Unreleased]: https://github.com/NIT-Administrative-Systems/northwestern-starlight-theme/compare/v1.2.0...HEAD
|
|
75
|
+
[1.2.0]: https://github.com/NIT-Administrative-Systems/northwestern-starlight-theme/compare/v1.1.1...v1.2.0
|
|
76
|
+
[1.1.1]: https://github.com/NIT-Administrative-Systems/northwestern-starlight-theme/compare/v1.1.0...v1.1.1
|
|
38
77
|
[1.1.0]: https://github.com/NIT-Administrative-Systems/northwestern-starlight-theme/compare/v1.0.0...v1.1.0
|
|
39
78
|
[1.0.0]: https://github.com/NIT-Administrative-Systems/northwestern-starlight-theme/releases/tag/v1.0.0
|
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
<p align="center">
|
|
7
7
|
<a href="https://nodejs.org"><img src="https://img.shields.io/badge/Node-22+-339933?style=flat&logo=node.js&logoColor=white" alt="Node Version"></a>
|
|
8
|
-
<a href="https://astro.build"><img src="https://img.shields.io/badge/Astro-5.x
|
|
8
|
+
<a href="https://astro.build"><img src="https://img.shields.io/badge/Astro-5.x%20%7C%206.x-BC52EE?style=flat&logo=astro&logoColor=white" alt="Astro Version"></a>
|
|
9
9
|
<a href="https://starlight.astro.build"><img src="https://img.shields.io/badge/Starlight-0.32+-FF5D01?style=flat&logo=astro&logoColor=white" alt="Starlight Version"></a>
|
|
10
10
|
</p>
|
|
11
11
|
|
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Features:
|
|
5
5
|
* - Hover toolbar: fullscreen, download SVG, copy mermaid source
|
|
6
|
-
* - Fullscreen overlay: pan/zoom, download, copy
|
|
6
|
+
* - Fullscreen overlay: pan/zoom, download, copy
|
|
7
7
|
* - Keyboard: +/- zoom, 0 reset, arrows pan, Escape close
|
|
8
8
|
* - Double-click to zoom in
|
|
9
9
|
* - Smooth open/close animation
|
|
@@ -22,7 +22,6 @@ declare global {
|
|
|
22
22
|
}
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
// ---- Icon paths ----
|
|
26
25
|
const ICONS = {
|
|
27
26
|
fullscreen: ["M8 3H5a2 2 0 00-2 2v3m18 0V5a2 2 0 00-2-2h-3m0 18h3a2 2 0 002-2v-3M3 16v3a2 2 0 002 2h3"],
|
|
28
27
|
download: ["M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4", "M7 10l5 5 5-5", "M12 15V3"],
|
|
@@ -35,8 +34,6 @@ const ICONS = {
|
|
|
35
34
|
"M16 4h2a2 2 0 012 2v6a2 2 0 01-2 2h-8a2 2 0 01-2-2V6a2 2 0 012-2",
|
|
36
35
|
],
|
|
37
36
|
code: ["M16 18l6-6-6-6", "M8 6l-6 6 6 6"],
|
|
38
|
-
fitWidth: ["M21 3H3v18h18V3z", "M9 3v18", "M15 3v18"],
|
|
39
|
-
fitHeight: ["M21 3H3v18h18V3z", "M3 9h18", "M3 15h18"],
|
|
40
37
|
};
|
|
41
38
|
|
|
42
39
|
function createSvgIcon(paths: string[], size = 14): SVGElement {
|
|
@@ -97,7 +94,6 @@ function createZoomButton(
|
|
|
97
94
|
|
|
98
95
|
type MermaidThemeMode = "light" | "dark";
|
|
99
96
|
|
|
100
|
-
// ---- Store original mermaid source before rendering ----
|
|
101
97
|
const mermaidSources = new Map<Element, string>();
|
|
102
98
|
const renderedThemes = new WeakMap<HTMLElement, MermaidThemeMode>();
|
|
103
99
|
let mermaidRuntimePromise: Promise<typeof import("mermaid")["default"]> | undefined;
|
|
@@ -118,13 +114,24 @@ async function getMermaidRuntime() {
|
|
|
118
114
|
function captureSource() {
|
|
119
115
|
document.querySelectorAll<HTMLElement>("pre.mermaid").forEach((pre) => {
|
|
120
116
|
if (mermaidSources.has(pre)) return;
|
|
121
|
-
|
|
122
|
-
|
|
117
|
+
// Prefer data-diagram attribute (set by astro-mermaid before it renders SVG)
|
|
118
|
+
// over textContent, which may contain rendered SVG text nodes instead of source
|
|
119
|
+
const source = pre.getAttribute("data-diagram")?.trim() || pre.textContent?.trim() || "";
|
|
120
|
+
if (source) {
|
|
121
|
+
mermaidSources.set(pre, source);
|
|
122
|
+
// Persist source to data attribute so it survives across render cycles
|
|
123
|
+
if (!pre.hasAttribute("data-diagram")) {
|
|
124
|
+
pre.setAttribute("data-diagram", source);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
123
127
|
});
|
|
124
128
|
}
|
|
125
129
|
|
|
126
130
|
async function renderDiagram(container: HTMLElement, mode: MermaidThemeMode, index: number) {
|
|
127
|
-
const source =
|
|
131
|
+
const source =
|
|
132
|
+
mermaidSources.get(container) ??
|
|
133
|
+
container.getAttribute("data-diagram")?.trim() ??
|
|
134
|
+
container.textContent?.trim();
|
|
128
135
|
if (!source) return;
|
|
129
136
|
const mermaid = await getMermaidRuntime();
|
|
130
137
|
|
|
@@ -176,7 +183,6 @@ async function renderMermaidDiagrams(force = false) {
|
|
|
176
183
|
}
|
|
177
184
|
}
|
|
178
185
|
|
|
179
|
-
// ---- Success feedback (green check) ----
|
|
180
186
|
function showSuccess(btn: HTMLButtonElement, label = "Copied!") {
|
|
181
187
|
const originalTitle = btn.title;
|
|
182
188
|
const originalIcon = btn.querySelector("svg");
|
|
@@ -197,7 +203,6 @@ function showSuccess(btn: HTMLButtonElement, label = "Copied!") {
|
|
|
197
203
|
}, 1500);
|
|
198
204
|
}
|
|
199
205
|
|
|
200
|
-
// ---- Clipboard helper ----
|
|
201
206
|
async function copyToClipboard(text: string, btn: HTMLButtonElement) {
|
|
202
207
|
try {
|
|
203
208
|
await navigator.clipboard.writeText(text);
|
|
@@ -207,7 +212,6 @@ async function copyToClipboard(text: string, btn: HTMLButtonElement) {
|
|
|
207
212
|
}
|
|
208
213
|
}
|
|
209
214
|
|
|
210
|
-
// ---- Inline toolbar injection ----
|
|
211
215
|
function initMermaidToolbar(): number {
|
|
212
216
|
const diagrams = document.querySelectorAll<HTMLElement>(".mermaid");
|
|
213
217
|
if (!diagrams.length) return 0;
|
|
@@ -242,9 +246,9 @@ function initMermaidToolbar(): number {
|
|
|
242
246
|
if (!svg) return;
|
|
243
247
|
|
|
244
248
|
const action = btn.dataset.action;
|
|
245
|
-
if (action === "fullscreen") openFullscreen(svg, container, index);
|
|
249
|
+
if (action === "fullscreen") openFullscreen(svg, container, index, btn, e.detail === 0);
|
|
246
250
|
else if (action === "download-svg") {
|
|
247
|
-
downloadSvg(svg, index);
|
|
251
|
+
downloadSvg(svg, container, index);
|
|
248
252
|
showSuccess(btn, "Downloaded!");
|
|
249
253
|
} else if (action === "copy-source" && source) copyToClipboard(source, btn);
|
|
250
254
|
});
|
|
@@ -252,10 +256,18 @@ function initMermaidToolbar(): number {
|
|
|
252
256
|
return injected;
|
|
253
257
|
}
|
|
254
258
|
|
|
255
|
-
|
|
256
|
-
|
|
259
|
+
function openFullscreen(
|
|
260
|
+
svg: SVGElement,
|
|
261
|
+
container: HTMLElement,
|
|
262
|
+
index: number,
|
|
263
|
+
triggerBtn?: HTMLElement,
|
|
264
|
+
keyboardOpen = false,
|
|
265
|
+
) {
|
|
257
266
|
const overlay = document.createElement("div");
|
|
258
267
|
overlay.className = "nu-mermaid-overlay";
|
|
268
|
+
overlay.setAttribute("role", "dialog");
|
|
269
|
+
overlay.setAttribute("aria-modal", "true");
|
|
270
|
+
overlay.setAttribute("aria-label", "Mermaid diagram fullscreen viewer");
|
|
259
271
|
|
|
260
272
|
// Controls
|
|
261
273
|
const controls = document.createElement("div");
|
|
@@ -287,7 +299,6 @@ function openFullscreen(svg: SVGElement, container: HTMLElement, index: number)
|
|
|
287
299
|
|
|
288
300
|
overlay.appendChild(controls);
|
|
289
301
|
|
|
290
|
-
// Viewport
|
|
291
302
|
const viewport = document.createElement("div");
|
|
292
303
|
viewport.className = "nu-mermaid-viewport";
|
|
293
304
|
|
|
@@ -297,7 +308,6 @@ function openFullscreen(svg: SVGElement, container: HTMLElement, index: number)
|
|
|
297
308
|
const cloned = svg.cloneNode(true) as SVGElement;
|
|
298
309
|
cloned.removeAttribute("style");
|
|
299
310
|
|
|
300
|
-
// Calculate initial fit
|
|
301
311
|
const viewBox = svg.getAttribute("viewBox");
|
|
302
312
|
let vbWidth = 800;
|
|
303
313
|
let vbHeight = 600;
|
|
@@ -307,11 +317,9 @@ function openFullscreen(svg: SVGElement, container: HTMLElement, index: number)
|
|
|
307
317
|
vbHeight = parts[3] || 600;
|
|
308
318
|
}
|
|
309
319
|
|
|
310
|
-
function calcFitScale(
|
|
320
|
+
function calcFitScale(): number {
|
|
311
321
|
const maxW = window.innerWidth * 0.92;
|
|
312
|
-
const maxH = (window.innerHeight - 120) * 0.92; // account for
|
|
313
|
-
if (mode === "width") return maxW / vbWidth;
|
|
314
|
-
if (mode === "height") return maxH / vbHeight;
|
|
322
|
+
const maxH = (window.innerHeight - 120) * 0.92; // account for controls bar
|
|
315
323
|
return Math.min(maxW / vbWidth, maxH / vbHeight);
|
|
316
324
|
}
|
|
317
325
|
|
|
@@ -319,11 +327,15 @@ function openFullscreen(svg: SVGElement, container: HTMLElement, index: number)
|
|
|
319
327
|
cloned.setAttribute("width", String(Math.round(vbWidth * initialScale)));
|
|
320
328
|
cloned.setAttribute("height", String(Math.round(vbHeight * initialScale)));
|
|
321
329
|
|
|
330
|
+
const zoomBadge = document.createElement("span");
|
|
331
|
+
zoomBadge.className = "nu-mermaid-zoom-badge";
|
|
332
|
+
zoomBadge.textContent = "100%";
|
|
333
|
+
|
|
322
334
|
wrapper.appendChild(cloned);
|
|
323
335
|
viewport.appendChild(wrapper);
|
|
336
|
+
viewport.appendChild(zoomBadge);
|
|
324
337
|
overlay.appendChild(viewport);
|
|
325
338
|
|
|
326
|
-
// Add to DOM with animation
|
|
327
339
|
overlay.style.opacity = "0";
|
|
328
340
|
document.body.appendChild(overlay);
|
|
329
341
|
document.body.style.overflow = "hidden";
|
|
@@ -342,6 +354,7 @@ function openFullscreen(svg: SVGElement, container: HTMLElement, index: number)
|
|
|
342
354
|
function updateTransform() {
|
|
343
355
|
wrapper.style.transform = `translate(${panX}px, ${panY}px) scale(${scale})`;
|
|
344
356
|
wrapper.style.transformOrigin = "center center";
|
|
357
|
+
zoomBadge.textContent = `${Math.round(scale * 100)}%`;
|
|
345
358
|
}
|
|
346
359
|
|
|
347
360
|
function zoomTo(newScale: number) {
|
|
@@ -356,18 +369,25 @@ function openFullscreen(svg: SVGElement, container: HTMLElement, index: number)
|
|
|
356
369
|
updateTransform();
|
|
357
370
|
}
|
|
358
371
|
|
|
359
|
-
// Mouse wheel zoom
|
|
360
372
|
viewport.addEventListener(
|
|
361
373
|
"wheel",
|
|
362
374
|
(e) => {
|
|
363
375
|
e.preventDefault();
|
|
364
|
-
const
|
|
365
|
-
|
|
376
|
+
const factor = e.deltaY > 0 ? 0.9 : 1.1;
|
|
377
|
+
const newScale = Math.min(Math.max(0.1, scale * factor), 20);
|
|
378
|
+
// Anchor zoom to cursor: keep the point under the cursor fixed
|
|
379
|
+
const rect = viewport.getBoundingClientRect();
|
|
380
|
+
const cx = e.clientX - rect.left - rect.width / 2;
|
|
381
|
+
const cy = e.clientY - rect.top - rect.height / 2;
|
|
382
|
+
const ratio = 1 - newScale / scale;
|
|
383
|
+
panX += (cx - panX) * ratio;
|
|
384
|
+
panY += (cy - panY) * ratio;
|
|
385
|
+
scale = newScale;
|
|
386
|
+
updateTransform();
|
|
366
387
|
},
|
|
367
388
|
{ passive: false },
|
|
368
389
|
);
|
|
369
390
|
|
|
370
|
-
// Pan with mouse drag
|
|
371
391
|
viewport.addEventListener("mousedown", (e) => {
|
|
372
392
|
isPanning = true;
|
|
373
393
|
startX = e.clientX - panX;
|
|
@@ -391,13 +411,83 @@ function openFullscreen(svg: SVGElement, container: HTMLElement, index: number)
|
|
|
391
411
|
window.addEventListener("mousemove", onMouseMove);
|
|
392
412
|
window.addEventListener("mouseup", onMouseUp);
|
|
393
413
|
|
|
394
|
-
|
|
414
|
+
let lastTouchDist = 0;
|
|
415
|
+
let lastTouchX = 0;
|
|
416
|
+
let lastTouchY = 0;
|
|
417
|
+
let isTouchPanning = false;
|
|
418
|
+
|
|
419
|
+
viewport.addEventListener(
|
|
420
|
+
"touchstart",
|
|
421
|
+
(e) => {
|
|
422
|
+
if (e.touches.length === 1) {
|
|
423
|
+
isTouchPanning = true;
|
|
424
|
+
lastTouchX = e.touches[0].clientX;
|
|
425
|
+
lastTouchY = e.touches[0].clientY;
|
|
426
|
+
} else if (e.touches.length === 2) {
|
|
427
|
+
isTouchPanning = false;
|
|
428
|
+
lastTouchDist = Math.hypot(
|
|
429
|
+
e.touches[0].clientX - e.touches[1].clientX,
|
|
430
|
+
e.touches[0].clientY - e.touches[1].clientY,
|
|
431
|
+
);
|
|
432
|
+
lastTouchX = (e.touches[0].clientX + e.touches[1].clientX) / 2;
|
|
433
|
+
lastTouchY = (e.touches[0].clientY + e.touches[1].clientY) / 2;
|
|
434
|
+
}
|
|
435
|
+
e.preventDefault();
|
|
436
|
+
},
|
|
437
|
+
{ passive: false },
|
|
438
|
+
);
|
|
439
|
+
|
|
440
|
+
viewport.addEventListener(
|
|
441
|
+
"touchmove",
|
|
442
|
+
(e) => {
|
|
443
|
+
if (e.touches.length === 1 && isTouchPanning) {
|
|
444
|
+
panX += e.touches[0].clientX - lastTouchX;
|
|
445
|
+
panY += e.touches[0].clientY - lastTouchY;
|
|
446
|
+
lastTouchX = e.touches[0].clientX;
|
|
447
|
+
lastTouchY = e.touches[0].clientY;
|
|
448
|
+
updateTransform();
|
|
449
|
+
} else if (e.touches.length === 2) {
|
|
450
|
+
const dist = Math.hypot(
|
|
451
|
+
e.touches[0].clientX - e.touches[1].clientX,
|
|
452
|
+
e.touches[0].clientY - e.touches[1].clientY,
|
|
453
|
+
);
|
|
454
|
+
const midX = (e.touches[0].clientX + e.touches[1].clientX) / 2;
|
|
455
|
+
const midY = (e.touches[0].clientY + e.touches[1].clientY) / 2;
|
|
456
|
+
|
|
457
|
+
if (lastTouchDist > 0) {
|
|
458
|
+
zoomTo(scale * (dist / lastTouchDist));
|
|
459
|
+
panX += midX - lastTouchX;
|
|
460
|
+
panY += midY - lastTouchY;
|
|
461
|
+
updateTransform();
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
lastTouchDist = dist;
|
|
465
|
+
lastTouchX = midX;
|
|
466
|
+
lastTouchY = midY;
|
|
467
|
+
}
|
|
468
|
+
e.preventDefault();
|
|
469
|
+
},
|
|
470
|
+
{ passive: false },
|
|
471
|
+
);
|
|
472
|
+
|
|
473
|
+
viewport.addEventListener("touchend", () => {
|
|
474
|
+
isTouchPanning = false;
|
|
475
|
+
lastTouchDist = 0;
|
|
476
|
+
});
|
|
477
|
+
|
|
395
478
|
viewport.addEventListener("dblclick", (e) => {
|
|
396
479
|
e.preventDefault();
|
|
397
|
-
|
|
480
|
+
const newScale = Math.min(Math.max(0.1, scale * 1.5), 20);
|
|
481
|
+
const rect = viewport.getBoundingClientRect();
|
|
482
|
+
const cx = e.clientX - rect.left - rect.width / 2;
|
|
483
|
+
const cy = e.clientY - rect.top - rect.height / 2;
|
|
484
|
+
const ratio = 1 - newScale / scale;
|
|
485
|
+
panX += (cx - panX) * ratio;
|
|
486
|
+
panY += (cy - panY) * ratio;
|
|
487
|
+
scale = newScale;
|
|
488
|
+
updateTransform();
|
|
398
489
|
});
|
|
399
490
|
|
|
400
|
-
// Control buttons
|
|
401
491
|
controls.addEventListener("click", (e) => {
|
|
402
492
|
const btn = (e.target as HTMLElement).closest<HTMLButtonElement>(".nu-mermaid-overlay-btn");
|
|
403
493
|
if (!btn) return;
|
|
@@ -408,14 +498,34 @@ function openFullscreen(svg: SVGElement, container: HTMLElement, index: number)
|
|
|
408
498
|
else if (action === "zoom-out") zoomTo(scale * 0.7);
|
|
409
499
|
else if (action === "zoom-reset") resetView();
|
|
410
500
|
else if (action === "download-svg") {
|
|
411
|
-
downloadSvg(svg, index);
|
|
501
|
+
downloadSvg(svg, container, index);
|
|
412
502
|
showSuccess(btn, "Downloaded!");
|
|
413
503
|
} else if (action === "copy-svg") copyToClipboard(svg.outerHTML, btn);
|
|
414
504
|
else if (action === "copy-source" && source) copyToClipboard(source, btn);
|
|
415
505
|
});
|
|
416
506
|
|
|
417
|
-
// Keyboard shortcuts
|
|
418
507
|
const onKeydown = (e: KeyboardEvent) => {
|
|
508
|
+
if (e.key === "Tab") {
|
|
509
|
+
// Focus trap: cycle focus within the overlay
|
|
510
|
+
const focusable = overlay.querySelectorAll<HTMLElement>(
|
|
511
|
+
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
|
512
|
+
);
|
|
513
|
+
if (!focusable.length) return;
|
|
514
|
+
const first = focusable[0];
|
|
515
|
+
const last = focusable[focusable.length - 1];
|
|
516
|
+
if (e.shiftKey) {
|
|
517
|
+
if (document.activeElement === first) {
|
|
518
|
+
e.preventDefault();
|
|
519
|
+
last.focus();
|
|
520
|
+
}
|
|
521
|
+
} else {
|
|
522
|
+
if (document.activeElement === last) {
|
|
523
|
+
e.preventDefault();
|
|
524
|
+
first.focus();
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
419
529
|
if (e.key === "Escape") close();
|
|
420
530
|
else if (e.key === "+" || e.key === "=") zoomTo(scale * 1.2);
|
|
421
531
|
else if (e.key === "-" || e.key === "_") zoomTo(scale * 0.8);
|
|
@@ -442,6 +552,15 @@ function openFullscreen(svg: SVGElement, container: HTMLElement, index: number)
|
|
|
442
552
|
if (e.target === overlay) close();
|
|
443
553
|
});
|
|
444
554
|
|
|
555
|
+
// Keyboard: focus first button (shows focus ring). Mouse: focus overlay container (no ring, but Tab works).
|
|
556
|
+
if (keyboardOpen) {
|
|
557
|
+
const firstBtn = controls.querySelector<HTMLElement>(".nu-mermaid-btn");
|
|
558
|
+
firstBtn?.focus();
|
|
559
|
+
} else {
|
|
560
|
+
overlay.setAttribute("tabindex", "-1");
|
|
561
|
+
overlay.focus();
|
|
562
|
+
}
|
|
563
|
+
|
|
445
564
|
function close() {
|
|
446
565
|
overlay.style.opacity = "0";
|
|
447
566
|
setTimeout(() => {
|
|
@@ -450,24 +569,61 @@ function openFullscreen(svg: SVGElement, container: HTMLElement, index: number)
|
|
|
450
569
|
window.removeEventListener("mouseup", onMouseUp);
|
|
451
570
|
document.body.style.overflow = "";
|
|
452
571
|
overlay.remove();
|
|
453
|
-
|
|
572
|
+
triggerBtn?.focus();
|
|
573
|
+
}, 200);
|
|
454
574
|
}
|
|
455
575
|
}
|
|
456
576
|
|
|
457
|
-
|
|
458
|
-
|
|
577
|
+
function slugify(text: string): string {
|
|
578
|
+
return text
|
|
579
|
+
.toLowerCase()
|
|
580
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
581
|
+
.replace(/^-|-$/g, "");
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function getSiteSlug(): string {
|
|
585
|
+
const parts = document.title.split("|");
|
|
586
|
+
const site = (parts.length > 1 ? parts[parts.length - 1] : parts[0]).trim();
|
|
587
|
+
return slugify(site);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function getPageSlug(): string {
|
|
591
|
+
const path = window.location.pathname.replace(/^\/|\/$/g, "");
|
|
592
|
+
return slugify(path) || "index";
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function getDiagramType(container: Element): string {
|
|
596
|
+
const source = mermaidSources.get(container) ?? "";
|
|
597
|
+
const raw = source.trim().split(/[\s\n]/)[0];
|
|
598
|
+
// Strip version suffixes (-v2, -beta), then common trailing words (Diagram, Chart)
|
|
599
|
+
return slugify(raw.replace(/[-_](v\d+|beta)$/i, "").replace(/(diagram|chart)$/i, "")) || "diagram";
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
function buildFilename(container: Element): string {
|
|
603
|
+
const site = getSiteSlug();
|
|
604
|
+
const page = getPageSlug();
|
|
605
|
+
const type = getDiagramType(container);
|
|
606
|
+
|
|
607
|
+
// Count diagrams of same type on page to add suffix if needed
|
|
608
|
+
const allDiagrams = [...document.querySelectorAll<HTMLElement>(".mermaid:not(.nu-mermaid-fullscreen)")];
|
|
609
|
+
const sameType = allDiagrams.filter((d) => getDiagramType(d) === type);
|
|
610
|
+
const suffix = sameType.length > 1 ? `-${sameType.indexOf(container as HTMLElement) + 1}` : "";
|
|
611
|
+
|
|
612
|
+
return `${site}_${page}-${type}${suffix}.svg`;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function downloadSvg(svg: SVGElement, container: Element, index: number) {
|
|
459
616
|
const clone = svg.cloneNode(true) as SVGElement;
|
|
460
617
|
clone.setAttribute("xmlns", "http://www.w3.org/2000/svg");
|
|
461
618
|
const blob = new Blob([clone.outerHTML], { type: "image/svg+xml" });
|
|
462
619
|
const url = URL.createObjectURL(blob);
|
|
463
620
|
const a = document.createElement("a");
|
|
464
621
|
a.href = url;
|
|
465
|
-
a.download =
|
|
622
|
+
a.download = buildFilename(container);
|
|
466
623
|
a.click();
|
|
467
624
|
URL.revokeObjectURL(url);
|
|
468
625
|
}
|
|
469
626
|
|
|
470
|
-
// ---- Watch for mermaid renders ----
|
|
471
627
|
function watchForMermaidRender() {
|
|
472
628
|
const toolbarEnabled = window.__NU_MERMAID_TOOLBAR__ !== false;
|
|
473
629
|
// Initial load: lazy render (only diagrams in/near viewport)
|
package/src/styles/content.css
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
========================================================================== */
|
|
7
7
|
|
|
8
8
|
@layer northwestern {
|
|
9
|
-
.sl-markdown-content a:not(.anchor-link, [role="tab"], .not-content) {
|
|
9
|
+
.sl-markdown-content a:not(.anchor-link, [role="tab"], .not-content, [data-footnote-ref], [data-footnote-backref]) {
|
|
10
10
|
padding-bottom: 2px;
|
|
11
11
|
font-weight: 700;
|
|
12
12
|
color: var(--sl-color-accent);
|
|
@@ -15,11 +15,86 @@
|
|
|
15
15
|
transition: all var(--nu-transition-default);
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
.sl-markdown-content
|
|
18
|
+
.sl-markdown-content
|
|
19
|
+
a:not(.anchor-link, [role="tab"], .not-content, [data-footnote-ref], [data-footnote-backref]):hover {
|
|
19
20
|
text-decoration: none;
|
|
20
21
|
border-bottom-style: solid;
|
|
21
22
|
}
|
|
22
23
|
|
|
24
|
+
/* Footnote references — superscript markers with brackets */
|
|
25
|
+
.sl-markdown-content a[data-footnote-ref] {
|
|
26
|
+
font-weight: 600;
|
|
27
|
+
line-height: 1;
|
|
28
|
+
vertical-align: super;
|
|
29
|
+
color: var(--sl-color-accent);
|
|
30
|
+
text-decoration: none;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.sl-markdown-content a[data-footnote-ref]::before {
|
|
34
|
+
content: "[";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.sl-markdown-content a[data-footnote-ref]::after {
|
|
38
|
+
content: "]";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
.sl-markdown-content a[data-footnote-ref]:hover {
|
|
42
|
+
text-decoration: underline;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/* Footnotes section */
|
|
46
|
+
.sl-markdown-content section[data-footnotes] {
|
|
47
|
+
padding-top: 1.5rem;
|
|
48
|
+
margin-top: 2.5rem;
|
|
49
|
+
font-size: 0.875rem;
|
|
50
|
+
color: var(--nu-color-text-muted);
|
|
51
|
+
border-top: 1px solid var(--nu-border-color);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/* Hide the default <hr> that remark-gfm inserts before footnotes */
|
|
55
|
+
.sl-markdown-content section[data-footnotes] > :first-child:is(hr) {
|
|
56
|
+
display: none;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/* Collapse the heading wrapper around the sr-only "Footnotes" label */
|
|
60
|
+
.sl-markdown-content section[data-footnotes] > .sl-heading-wrapper {
|
|
61
|
+
position: absolute;
|
|
62
|
+
width: 1px;
|
|
63
|
+
height: 1px;
|
|
64
|
+
padding: 0;
|
|
65
|
+
margin: -1px;
|
|
66
|
+
overflow: hidden;
|
|
67
|
+
white-space: nowrap;
|
|
68
|
+
border: 0;
|
|
69
|
+
clip-path: inset(50%);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.sl-markdown-content section[data-footnotes] ol {
|
|
73
|
+
padding-left: 1.5rem;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
.sl-markdown-content section[data-footnotes] li {
|
|
77
|
+
padding-left: 0.25rem;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
.sl-markdown-content section[data-footnotes] li + li {
|
|
81
|
+
margin-top: 0.5rem;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
.sl-markdown-content section[data-footnotes] a[data-footnote-backref] {
|
|
85
|
+
margin-left: 0.25em;
|
|
86
|
+
font-family: "Segoe UI Symbol", "Noto Sans Symbols 2", sans-serif;
|
|
87
|
+
font-size: 1em;
|
|
88
|
+
font-weight: 700;
|
|
89
|
+
color: var(--sl-color-accent);
|
|
90
|
+
text-decoration: none;
|
|
91
|
+
font-variant-emoji: text;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
.sl-markdown-content section[data-footnotes] a[data-footnote-backref]:hover {
|
|
95
|
+
border-bottom: 1px solid var(--sl-color-accent);
|
|
96
|
+
}
|
|
97
|
+
|
|
23
98
|
.sl-markdown-content ul:not(:where(.not-content *)) {
|
|
24
99
|
padding-left: 1.25rem;
|
|
25
100
|
list-style: none;
|
|
@@ -55,16 +130,12 @@
|
|
|
55
130
|
}
|
|
56
131
|
|
|
57
132
|
.sl-markdown-content hr:not(:where(.not-content *)) {
|
|
58
|
-
height:
|
|
133
|
+
height: 1px;
|
|
59
134
|
margin: 2.5rem 0;
|
|
60
|
-
background: var(--nu-
|
|
135
|
+
background: var(--nu-border-color);
|
|
61
136
|
border: 0;
|
|
62
137
|
}
|
|
63
138
|
|
|
64
|
-
:root:not([data-theme="light"]) .sl-markdown-content hr:not(:where(.not-content *)) {
|
|
65
|
-
background: var(--nu-purple-140);
|
|
66
|
-
}
|
|
67
|
-
|
|
68
139
|
.sl-markdown-content blockquote:not(:where(.not-content *)) {
|
|
69
140
|
padding: 1rem 1.5rem;
|
|
70
141
|
margin: 2rem 0;
|
|
@@ -12,9 +12,17 @@
|
|
|
12
12
|
right: 0.5rem;
|
|
13
13
|
z-index: 2;
|
|
14
14
|
display: flex;
|
|
15
|
-
gap:
|
|
15
|
+
gap: 1px;
|
|
16
16
|
align-items: center;
|
|
17
|
+
padding: 0;
|
|
18
|
+
overflow: hidden;
|
|
19
|
+
background: var(--nu-border-color);
|
|
20
|
+
border: 1px solid var(--nu-border-color);
|
|
17
21
|
opacity: 0;
|
|
22
|
+
|
|
23
|
+
/* stylelint-disable-next-line property-no-vendor-prefix -- Safari requires prefix */
|
|
24
|
+
-webkit-backdrop-filter: blur(12px) saturate(1.2);
|
|
25
|
+
backdrop-filter: blur(12px) saturate(1.2);
|
|
18
26
|
transition: opacity var(--nu-transition-fast);
|
|
19
27
|
}
|
|
20
28
|
|
|
@@ -23,6 +31,17 @@
|
|
|
23
31
|
opacity: 1;
|
|
24
32
|
}
|
|
25
33
|
|
|
34
|
+
/* Touch devices can't hover — keep toolbar visible */
|
|
35
|
+
@media (hover: none) {
|
|
36
|
+
.nu-mermaid-toolbar {
|
|
37
|
+
opacity: 0.8;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.mermaid:active .nu-mermaid-toolbar {
|
|
41
|
+
opacity: 1;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
26
45
|
.nu-mermaid-btn {
|
|
27
46
|
box-sizing: border-box;
|
|
28
47
|
display: inline-flex;
|
|
@@ -36,13 +55,17 @@
|
|
|
36
55
|
line-height: 1;
|
|
37
56
|
color: var(--nu-color-text-muted);
|
|
38
57
|
cursor: pointer;
|
|
39
|
-
background:
|
|
40
|
-
border:
|
|
58
|
+
background: rgb(255 255 255 / 85%);
|
|
59
|
+
border: none;
|
|
41
60
|
transition:
|
|
42
61
|
color var(--nu-transition-fast),
|
|
43
62
|
background var(--nu-transition-fast);
|
|
44
63
|
}
|
|
45
64
|
|
|
65
|
+
:root:not([data-theme="light"]) .nu-mermaid-btn {
|
|
66
|
+
background: rgb(30 30 30 / 85%);
|
|
67
|
+
}
|
|
68
|
+
|
|
46
69
|
.nu-mermaid-btn svg {
|
|
47
70
|
display: block;
|
|
48
71
|
flex-shrink: 0;
|
|
@@ -51,13 +74,18 @@
|
|
|
51
74
|
pointer-events: none;
|
|
52
75
|
}
|
|
53
76
|
|
|
77
|
+
.nu-mermaid-btn:focus-visible {
|
|
78
|
+
outline: 2px solid var(--sl-color-accent);
|
|
79
|
+
outline-offset: -2px;
|
|
80
|
+
}
|
|
81
|
+
|
|
54
82
|
.nu-mermaid-btn:hover {
|
|
55
83
|
color: var(--nu-color-text);
|
|
56
|
-
background:
|
|
84
|
+
background: var(--nu-hover-bg);
|
|
57
85
|
}
|
|
58
86
|
|
|
59
87
|
:root:not([data-theme="light"]) .nu-mermaid-btn:hover {
|
|
60
|
-
background:
|
|
88
|
+
background: var(--nu-hover-bg);
|
|
61
89
|
}
|
|
62
90
|
|
|
63
91
|
.nu-mermaid-btn.nu-mermaid-success {
|
|
@@ -72,7 +100,8 @@
|
|
|
72
100
|
|
|
73
101
|
.nu-mermaid-overlay-controls .nu-mermaid-btn.nu-mermaid-success {
|
|
74
102
|
color: #fff !important;
|
|
75
|
-
background:
|
|
103
|
+
background: rgb(0 134 86 / 60%) !important;
|
|
104
|
+
border-color: rgb(0 134 86 / 80%) !important;
|
|
76
105
|
}
|
|
77
106
|
|
|
78
107
|
.nu-mermaid-overlay {
|
|
@@ -82,7 +111,7 @@
|
|
|
82
111
|
display: flex;
|
|
83
112
|
flex-direction: column;
|
|
84
113
|
background: #f3f5f7;
|
|
85
|
-
transition: opacity
|
|
114
|
+
transition: opacity 200ms ease;
|
|
86
115
|
}
|
|
87
116
|
|
|
88
117
|
:root:not([data-theme="light"]) .nu-mermaid-overlay {
|
|
@@ -90,29 +119,43 @@
|
|
|
90
119
|
}
|
|
91
120
|
|
|
92
121
|
.nu-mermaid-overlay-controls {
|
|
122
|
+
box-sizing: border-box;
|
|
93
123
|
display: flex;
|
|
124
|
+
flex-shrink: 0;
|
|
94
125
|
gap: 0.25rem;
|
|
95
126
|
align-items: center;
|
|
96
127
|
height: var(--sl-nav-height);
|
|
97
128
|
padding: 0 1.5rem;
|
|
98
129
|
background: var(--nu-topbar-bg);
|
|
130
|
+
border-bottom: 1px solid var(--nu-border-color);
|
|
99
131
|
}
|
|
100
132
|
|
|
101
133
|
.nu-mermaid-overlay-controls .nu-mermaid-btn {
|
|
102
|
-
width:
|
|
103
|
-
height:
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
134
|
+
width: auto;
|
|
135
|
+
height: auto;
|
|
136
|
+
padding: 0.5rem;
|
|
137
|
+
color: #fff;
|
|
138
|
+
background: rgb(255 255 255 / 15%);
|
|
139
|
+
border: 1px solid rgb(255 255 255 / 25%);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
.nu-mermaid-overlay-controls .nu-mermaid-btn svg {
|
|
143
|
+
width: 16px;
|
|
144
|
+
height: 16px;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
.nu-mermaid-overlay-controls .nu-mermaid-btn:focus-visible {
|
|
148
|
+
outline: none;
|
|
149
|
+
box-shadow: 0 0 0 2px rgb(255 255 255 / 60%);
|
|
107
150
|
}
|
|
108
151
|
|
|
109
152
|
.nu-mermaid-overlay-controls .nu-mermaid-btn:hover {
|
|
110
|
-
color:
|
|
111
|
-
background:
|
|
153
|
+
color: #fff;
|
|
154
|
+
background: rgb(255 255 255 / 25%);
|
|
112
155
|
}
|
|
113
156
|
|
|
114
157
|
:root:not([data-theme="light"]) .nu-mermaid-overlay-controls .nu-mermaid-btn:hover {
|
|
115
|
-
background:
|
|
158
|
+
background: rgb(255 255 255 / 25%);
|
|
116
159
|
}
|
|
117
160
|
|
|
118
161
|
.nu-mermaid-separator {
|
|
@@ -128,13 +171,13 @@
|
|
|
128
171
|
|
|
129
172
|
.nu-mermaid-overlay-controls .nu-mermaid-close {
|
|
130
173
|
color: #fff;
|
|
131
|
-
background:
|
|
132
|
-
border-color:
|
|
174
|
+
background: rgb(255 255 255 / 20%);
|
|
175
|
+
border-color: rgb(255 255 255 / 30%);
|
|
133
176
|
}
|
|
134
177
|
|
|
135
178
|
.nu-mermaid-overlay-controls .nu-mermaid-close:hover {
|
|
136
|
-
background:
|
|
137
|
-
border-color:
|
|
179
|
+
background: rgb(255 255 255 / 35%);
|
|
180
|
+
border-color: rgb(255 255 255 / 40%);
|
|
138
181
|
}
|
|
139
182
|
|
|
140
183
|
.nu-mermaid-viewport {
|
|
@@ -143,6 +186,7 @@
|
|
|
143
186
|
align-items: center;
|
|
144
187
|
justify-content: center;
|
|
145
188
|
overflow: hidden;
|
|
189
|
+
touch-action: none;
|
|
146
190
|
cursor: grab;
|
|
147
191
|
}
|
|
148
192
|
|
|
@@ -160,4 +204,27 @@
|
|
|
160
204
|
background: transparent !important;
|
|
161
205
|
border: none !important;
|
|
162
206
|
}
|
|
207
|
+
|
|
208
|
+
.nu-mermaid-zoom-badge {
|
|
209
|
+
position: absolute;
|
|
210
|
+
right: 1rem;
|
|
211
|
+
bottom: 1rem;
|
|
212
|
+
padding: 0.2rem 0.5rem;
|
|
213
|
+
font-family: var(--nu-font-mono, ui-monospace, monospace);
|
|
214
|
+
font-size: 0.75rem;
|
|
215
|
+
line-height: 1;
|
|
216
|
+
color: rgb(255 255 255 / 70%);
|
|
217
|
+
pointer-events: none;
|
|
218
|
+
background: rgb(0 0 0 / 35%);
|
|
219
|
+
border-radius: 0.25rem;
|
|
220
|
+
|
|
221
|
+
/* stylelint-disable-next-line property-no-vendor-prefix -- Safari requires prefix */
|
|
222
|
+
-webkit-backdrop-filter: blur(6px);
|
|
223
|
+
backdrop-filter: blur(6px);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
:root[data-theme="light"] .nu-mermaid-zoom-badge {
|
|
227
|
+
color: rgb(0 0 0 / 55%);
|
|
228
|
+
background: rgb(255 255 255 / 50%);
|
|
229
|
+
}
|
|
163
230
|
}
|
|
@@ -38,6 +38,13 @@
|
|
|
38
38
|
background: rgb(255 255 255 / 25%) !important;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
starlight-menu-button button:focus-visible,
|
|
42
|
+
northwestern-theme-toggle button:focus-visible,
|
|
43
|
+
.header northwestern-theme-toggle button:focus-visible {
|
|
44
|
+
outline: none !important;
|
|
45
|
+
box-shadow: 0 0 0 2px rgb(255 255 255 / 60%) !important;
|
|
46
|
+
}
|
|
47
|
+
|
|
41
48
|
@media (width < 50rem) {
|
|
42
49
|
site-search button {
|
|
43
50
|
color: var(--nu-topbar-text) !important;
|
package/src/styles/theme.css
CHANGED
|
@@ -7,10 +7,13 @@
|
|
|
7
7
|
|
|
8
8
|
/* Unlayered — paints immediately to prevent white flash on load */
|
|
9
9
|
html {
|
|
10
|
-
scrollbar-gutter: stable;
|
|
11
10
|
background-color: #18181b;
|
|
12
11
|
}
|
|
13
12
|
|
|
13
|
+
html:not([data-has-hero]) {
|
|
14
|
+
scrollbar-gutter: stable;
|
|
15
|
+
}
|
|
16
|
+
|
|
14
17
|
html[data-theme="light"] {
|
|
15
18
|
background-color: #f8fafc;
|
|
16
19
|
}
|