@nu-appdev/northwestern-starlight-theme 1.1.0 → 1.1.1

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,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.1.1] - 2026-03-25
11
+
12
+ ### Fixed
13
+
14
+ - Scrollbar gutter reserved space on splash pages, leaving a gap next to the topbar. Now only applied on pages with a scrollbar.
15
+ - Mermaid toolbar buttons use Northwestern purple focus rings instead of the browser default.
16
+ - Mermaid fullscreen close button dropped the red danger style and matches the other overlay buttons.
17
+
18
+ ### Changed
19
+
20
+ - Mermaid overlay buttons sized and styled to match the header theme toggle.
21
+
22
+ ### Added
23
+
24
+ - Fullscreen mermaid viewer supports touch: one-finger pan, pinch-to-zoom, two-finger pan.
25
+ - Mermaid toolbar stays visible on touch devices (`hover: none`) since hover is unavailable.
26
+ - SVG downloads use descriptive filenames derived from the site title, page slug, and diagram type (e.g., `northwestern-starlight-theme_examples-mermaid-flowchart.svg`).
27
+
10
28
  ## [1.1.0] - 2026-03-25
11
29
 
12
30
  ### Added
13
31
 
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.
32
+ - Automatic favicon shipped with the theme, applied without touching `public/`. Override with `favicon` in Starlight config.
33
+ - `homepage.imageWidth` option for hero image size. Defaults to `500px`; set higher for wide lockup images.
16
34
 
17
35
  ### Fixed
18
36
 
19
37
  - `showTitle: false` now works in the centered hero layout (previously only worked in split layout).
20
38
  - 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.
39
+ - Search input focus ring uses Northwestern purple instead of the browser default.
22
40
 
23
41
  ### Changed
24
42
 
@@ -34,6 +52,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
34
52
  - OpenAPI plugin compatibility with method badge preservation
35
53
  - Reduced motion support for transitions
36
54
 
37
- [Unreleased]: https://github.com/NIT-Administrative-Systems/northwestern-starlight-theme/compare/v1.1.0...HEAD
55
+ [Unreleased]: https://github.com/NIT-Administrative-Systems/northwestern-starlight-theme/compare/v1.1.1...HEAD
56
+ [1.1.1]: https://github.com/NIT-Administrative-Systems/northwestern-starlight-theme/compare/v1.1.0...v1.1.1
38
57
  [1.1.0]: https://github.com/NIT-Administrative-Systems/northwestern-starlight-theme/compare/v1.0.0...v1.1.0
39
58
  [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.1.1",
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"],
@@ -97,7 +96,6 @@ function createZoomButton(
97
96
 
98
97
  type MermaidThemeMode = "light" | "dark";
99
98
 
100
- // ---- Store original mermaid source before rendering ----
101
99
  const mermaidSources = new Map<Element, string>();
102
100
  const renderedThemes = new WeakMap<HTMLElement, MermaidThemeMode>();
103
101
  let mermaidRuntimePromise: Promise<typeof import("mermaid")["default"]> | undefined;
@@ -176,7 +174,6 @@ async function renderMermaidDiagrams(force = false) {
176
174
  }
177
175
  }
178
176
 
179
- // ---- Success feedback (green check) ----
180
177
  function showSuccess(btn: HTMLButtonElement, label = "Copied!") {
181
178
  const originalTitle = btn.title;
182
179
  const originalIcon = btn.querySelector("svg");
@@ -197,7 +194,6 @@ function showSuccess(btn: HTMLButtonElement, label = "Copied!") {
197
194
  }, 1500);
198
195
  }
199
196
 
200
- // ---- Clipboard helper ----
201
197
  async function copyToClipboard(text: string, btn: HTMLButtonElement) {
202
198
  try {
203
199
  await navigator.clipboard.writeText(text);
@@ -207,7 +203,6 @@ async function copyToClipboard(text: string, btn: HTMLButtonElement) {
207
203
  }
208
204
  }
209
205
 
210
- // ---- Inline toolbar injection ----
211
206
  function initMermaidToolbar(): number {
212
207
  const diagrams = document.querySelectorAll<HTMLElement>(".mermaid");
213
208
  if (!diagrams.length) return 0;
@@ -244,7 +239,7 @@ function initMermaidToolbar(): number {
244
239
  const action = btn.dataset.action;
245
240
  if (action === "fullscreen") openFullscreen(svg, container, index);
246
241
  else if (action === "download-svg") {
247
- downloadSvg(svg, index);
242
+ downloadSvg(svg, container, index);
248
243
  showSuccess(btn, "Downloaded!");
249
244
  } else if (action === "copy-source" && source) copyToClipboard(source, btn);
250
245
  });
@@ -252,7 +247,6 @@ function initMermaidToolbar(): number {
252
247
  return injected;
253
248
  }
254
249
 
255
- // ---- Fullscreen overlay ----
256
250
  function openFullscreen(svg: SVGElement, container: HTMLElement, index: number) {
257
251
  const overlay = document.createElement("div");
258
252
  overlay.className = "nu-mermaid-overlay";
@@ -287,7 +281,6 @@ function openFullscreen(svg: SVGElement, container: HTMLElement, index: number)
287
281
 
288
282
  overlay.appendChild(controls);
289
283
 
290
- // Viewport
291
284
  const viewport = document.createElement("div");
292
285
  viewport.className = "nu-mermaid-viewport";
293
286
 
@@ -297,7 +290,6 @@ function openFullscreen(svg: SVGElement, container: HTMLElement, index: number)
297
290
  const cloned = svg.cloneNode(true) as SVGElement;
298
291
  cloned.removeAttribute("style");
299
292
 
300
- // Calculate initial fit
301
293
  const viewBox = svg.getAttribute("viewBox");
302
294
  let vbWidth = 800;
303
295
  let vbHeight = 600;
@@ -309,7 +301,7 @@ function openFullscreen(svg: SVGElement, container: HTMLElement, index: number)
309
301
 
310
302
  function calcFitScale(mode: "both" | "width" | "height" = "both"): number {
311
303
  const maxW = window.innerWidth * 0.92;
312
- const maxH = (window.innerHeight - 120) * 0.92; // account for title + controls
304
+ const maxH = (window.innerHeight - 120) * 0.92; // account for controls bar
313
305
  if (mode === "width") return maxW / vbWidth;
314
306
  if (mode === "height") return maxH / vbHeight;
315
307
  return Math.min(maxW / vbWidth, maxH / vbHeight);
@@ -323,7 +315,6 @@ function openFullscreen(svg: SVGElement, container: HTMLElement, index: number)
323
315
  viewport.appendChild(wrapper);
324
316
  overlay.appendChild(viewport);
325
317
 
326
- // Add to DOM with animation
327
318
  overlay.style.opacity = "0";
328
319
  document.body.appendChild(overlay);
329
320
  document.body.style.overflow = "hidden";
@@ -356,7 +347,6 @@ function openFullscreen(svg: SVGElement, container: HTMLElement, index: number)
356
347
  updateTransform();
357
348
  }
358
349
 
359
- // Mouse wheel zoom
360
350
  viewport.addEventListener(
361
351
  "wheel",
362
352
  (e) => {
@@ -367,7 +357,6 @@ function openFullscreen(svg: SVGElement, container: HTMLElement, index: number)
367
357
  { passive: false },
368
358
  );
369
359
 
370
- // Pan with mouse drag
371
360
  viewport.addEventListener("mousedown", (e) => {
372
361
  isPanning = true;
373
362
  startX = e.clientX - panX;
@@ -391,13 +380,75 @@ function openFullscreen(svg: SVGElement, container: HTMLElement, index: number)
391
380
  window.addEventListener("mousemove", onMouseMove);
392
381
  window.addEventListener("mouseup", onMouseUp);
393
382
 
394
- // Double-click to zoom in
383
+ let lastTouchDist = 0;
384
+ let lastTouchX = 0;
385
+ let lastTouchY = 0;
386
+ let isTouchPanning = false;
387
+
388
+ viewport.addEventListener(
389
+ "touchstart",
390
+ (e) => {
391
+ if (e.touches.length === 1) {
392
+ isTouchPanning = true;
393
+ lastTouchX = e.touches[0].clientX;
394
+ lastTouchY = e.touches[0].clientY;
395
+ } else if (e.touches.length === 2) {
396
+ isTouchPanning = false;
397
+ lastTouchDist = Math.hypot(
398
+ e.touches[0].clientX - e.touches[1].clientX,
399
+ e.touches[0].clientY - e.touches[1].clientY,
400
+ );
401
+ lastTouchX = (e.touches[0].clientX + e.touches[1].clientX) / 2;
402
+ lastTouchY = (e.touches[0].clientY + e.touches[1].clientY) / 2;
403
+ }
404
+ e.preventDefault();
405
+ },
406
+ { passive: false },
407
+ );
408
+
409
+ viewport.addEventListener(
410
+ "touchmove",
411
+ (e) => {
412
+ if (e.touches.length === 1 && isTouchPanning) {
413
+ panX += e.touches[0].clientX - lastTouchX;
414
+ panY += e.touches[0].clientY - lastTouchY;
415
+ lastTouchX = e.touches[0].clientX;
416
+ lastTouchY = e.touches[0].clientY;
417
+ updateTransform();
418
+ } else if (e.touches.length === 2) {
419
+ const dist = Math.hypot(
420
+ e.touches[0].clientX - e.touches[1].clientX,
421
+ e.touches[0].clientY - e.touches[1].clientY,
422
+ );
423
+ const midX = (e.touches[0].clientX + e.touches[1].clientX) / 2;
424
+ const midY = (e.touches[0].clientY + e.touches[1].clientY) / 2;
425
+
426
+ if (lastTouchDist > 0) {
427
+ zoomTo(scale * (dist / lastTouchDist));
428
+ panX += midX - lastTouchX;
429
+ panY += midY - lastTouchY;
430
+ updateTransform();
431
+ }
432
+
433
+ lastTouchDist = dist;
434
+ lastTouchX = midX;
435
+ lastTouchY = midY;
436
+ }
437
+ e.preventDefault();
438
+ },
439
+ { passive: false },
440
+ );
441
+
442
+ viewport.addEventListener("touchend", () => {
443
+ isTouchPanning = false;
444
+ lastTouchDist = 0;
445
+ });
446
+
395
447
  viewport.addEventListener("dblclick", (e) => {
396
448
  e.preventDefault();
397
449
  zoomTo(scale * 1.5);
398
450
  });
399
451
 
400
- // Control buttons
401
452
  controls.addEventListener("click", (e) => {
402
453
  const btn = (e.target as HTMLElement).closest<HTMLButtonElement>(".nu-mermaid-overlay-btn");
403
454
  if (!btn) return;
@@ -408,13 +459,12 @@ function openFullscreen(svg: SVGElement, container: HTMLElement, index: number)
408
459
  else if (action === "zoom-out") zoomTo(scale * 0.7);
409
460
  else if (action === "zoom-reset") resetView();
410
461
  else if (action === "download-svg") {
411
- downloadSvg(svg, index);
462
+ downloadSvg(svg, container, index);
412
463
  showSuccess(btn, "Downloaded!");
413
464
  } else if (action === "copy-svg") copyToClipboard(svg.outerHTML, btn);
414
465
  else if (action === "copy-source" && source) copyToClipboard(source, btn);
415
466
  });
416
467
 
417
- // Keyboard shortcuts
418
468
  const onKeydown = (e: KeyboardEvent) => {
419
469
  if (e.key === "Escape") close();
420
470
  else if (e.key === "+" || e.key === "=") zoomTo(scale * 1.2);
@@ -450,24 +500,60 @@ function openFullscreen(svg: SVGElement, container: HTMLElement, index: number)
450
500
  window.removeEventListener("mouseup", onMouseUp);
451
501
  document.body.style.overflow = "";
452
502
  overlay.remove();
453
- }, 150);
503
+ }, 200);
454
504
  }
455
505
  }
456
506
 
457
- // ---- Download SVG ----
458
- function downloadSvg(svg: SVGElement, index: number) {
507
+ function slugify(text: string): string {
508
+ return text
509
+ .toLowerCase()
510
+ .replace(/[^a-z0-9]+/g, "-")
511
+ .replace(/^-|-$/g, "");
512
+ }
513
+
514
+ function getSiteSlug(): string {
515
+ const parts = document.title.split("|");
516
+ const site = (parts.length > 1 ? parts[parts.length - 1] : parts[0]).trim();
517
+ return slugify(site);
518
+ }
519
+
520
+ function getPageSlug(): string {
521
+ const path = window.location.pathname.replace(/^\/|\/$/g, "");
522
+ return slugify(path) || "index";
523
+ }
524
+
525
+ function getDiagramType(container: Element): string {
526
+ const source = mermaidSources.get(container) ?? "";
527
+ const raw = source.trim().split(/[\s\n]/)[0];
528
+ // Strip version suffixes (-v2, -beta), then common trailing words (Diagram, Chart)
529
+ return slugify(raw.replace(/[-_](v\d+|beta)$/i, "").replace(/(diagram|chart)$/i, "")) || "diagram";
530
+ }
531
+
532
+ function buildFilename(container: Element, index: number): string {
533
+ const site = getSiteSlug();
534
+ const page = getPageSlug();
535
+ const type = getDiagramType(container);
536
+
537
+ // Count diagrams of same type on page to add suffix if needed
538
+ const allDiagrams = [...document.querySelectorAll<HTMLElement>(".mermaid:not(.nu-mermaid-fullscreen)")];
539
+ const sameType = allDiagrams.filter((d) => getDiagramType(d) === type);
540
+ const suffix = sameType.length > 1 ? `-${sameType.indexOf(container as HTMLElement) + 1}` : "";
541
+
542
+ return `${site}_${page}-${type}${suffix}.svg`;
543
+ }
544
+
545
+ function downloadSvg(svg: SVGElement, container: Element, index: number) {
459
546
  const clone = svg.cloneNode(true) as SVGElement;
460
547
  clone.setAttribute("xmlns", "http://www.w3.org/2000/svg");
461
548
  const blob = new Blob([clone.outerHTML], { type: "image/svg+xml" });
462
549
  const url = URL.createObjectURL(blob);
463
550
  const a = document.createElement("a");
464
551
  a.href = url;
465
- a.download = `diagram-${index + 1}.svg`;
552
+ a.download = buildFilename(container, index);
466
553
  a.click();
467
554
  URL.revokeObjectURL(url);
468
555
  }
469
556
 
470
- // ---- Watch for mermaid renders ----
471
557
  function watchForMermaidRender() {
472
558
  const toolbarEnabled = window.__NU_MERMAID_TOOLBAR__ !== false;
473
559
  // Initial load: lazy render (only diagrams in/near viewport)
@@ -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: none;
79
+ box-shadow: 0 0 0 0.25rem var(--nu-focus-shadow-color);
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,38 @@
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;
107
145
  }
108
146
 
109
147
  .nu-mermaid-overlay-controls .nu-mermaid-btn:hover {
110
- color: var(--nu-color-text);
111
- background: #ebedf0;
148
+ color: #fff;
149
+ background: rgb(255 255 255 / 25%);
112
150
  }
113
151
 
114
152
  :root:not([data-theme="light"]) .nu-mermaid-overlay-controls .nu-mermaid-btn:hover {
115
- background: #3a3a3a;
153
+ background: rgb(255 255 255 / 25%);
116
154
  }
117
155
 
118
156
  .nu-mermaid-separator {
@@ -128,13 +166,13 @@
128
166
 
129
167
  .nu-mermaid-overlay-controls .nu-mermaid-close {
130
168
  color: #fff;
131
- background: var(--nu-color-danger);
132
- border-color: var(--nu-color-danger);
169
+ background: rgb(255 255 255 / 20%);
170
+ border-color: rgb(255 255 255 / 30%);
133
171
  }
134
172
 
135
173
  .nu-mermaid-overlay-controls .nu-mermaid-close:hover {
136
- background: var(--nu-dark-orange);
137
- border-color: var(--nu-dark-orange);
174
+ background: rgb(255 255 255 / 35%);
175
+ border-color: rgb(255 255 255 / 40%);
138
176
  }
139
177
 
140
178
  .nu-mermaid-viewport {
@@ -143,6 +181,7 @@
143
181
  align-items: center;
144
182
  justify-content: center;
145
183
  overflow: hidden;
184
+ touch-action: none;
146
185
  cursor: grab;
147
186
  }
148
187
 
@@ -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
  }