@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 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 — the theme ships Northwestern's official favicon and applies it without touching `public/`. Consumers can still override it with their own `favicon` in Starlight config.
15
- - `homepage.imageWidth` option to control hero image size. Defaults to `500px`, useful for wide lockup images that need more space.
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 now uses the Northwestern purple treatment instead of the browser's default blue outline.
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.1.0...HEAD
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+-BC52EE?style=flat&logo=astro&logoColor=white" alt="Astro Version"></a>
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nu-appdev/northwestern-starlight-theme",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "A Northwestern-branded theme for Astro Starlight",
5
5
  "license": "MIT",
6
6
  "author": "Danny Foster <danny@northwestern.edu>",
@@ -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, zoom indicator, diagram title
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
- const source = pre.textContent?.trim() || "";
122
- if (source) mermaidSources.set(pre, source);
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 = mermaidSources.get(container) ?? container.textContent?.trim();
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
- // ---- Fullscreen overlay ----
256
- function openFullscreen(svg: SVGElement, container: HTMLElement, index: number) {
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(mode: "both" | "width" | "height" = "both"): number {
320
+ function calcFitScale(): number {
311
321
  const maxW = window.innerWidth * 0.92;
312
- const maxH = (window.innerHeight - 120) * 0.92; // account for title + controls
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 delta = e.deltaY > 0 ? 0.9 : 1.1;
365
- zoomTo(scale * delta);
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
- // Double-click to zoom in
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
- zoomTo(scale * 1.5);
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
- }, 150);
572
+ triggerBtn?.focus();
573
+ }, 200);
454
574
  }
455
575
  }
456
576
 
457
- // ---- Download SVG ----
458
- function downloadSvg(svg: SVGElement, index: number) {
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 = `diagram-${index + 1}.svg`;
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)
@@ -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 a:not(.anchor-link, [role="tab"], .not-content):hover {
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: 3px;
133
+ height: 1px;
59
134
  margin: 2.5rem 0;
60
- background: var(--nu-purple-10);
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: 0.25rem;
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: var(--nu-card-bg);
40
- border: 1px solid var(--nu-border-color);
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: #ebedf0;
84
+ background: var(--nu-hover-bg);
57
85
  }
58
86
 
59
87
  :root:not([data-theme="light"]) .nu-mermaid-btn:hover {
60
- background: #3a3a3a;
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: var(--nu-color-success) !important;
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 150ms ease;
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: 1.75rem;
103
- height: 1.75rem;
104
- color: var(--nu-color-text-muted);
105
- background: var(--nu-card-bg);
106
- border: 1px solid var(--nu-border-color);
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: var(--nu-color-text);
111
- background: #ebedf0;
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: #3a3a3a;
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: var(--nu-color-danger);
132
- border-color: var(--nu-color-danger);
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: var(--nu-dark-orange);
137
- border-color: var(--nu-dark-orange);
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;
@@ -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
  }