@nu-appdev/northwestern-starlight-theme 1.1.1 → 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,6 +7,25 @@ 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
+
10
29
  ## [1.1.1] - 2026-03-25
11
30
 
12
31
  ### Fixed
@@ -52,7 +71,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
52
71
  - OpenAPI plugin compatibility with method badge preservation
53
72
  - Reduced motion support for transitions
54
73
 
55
- [Unreleased]: https://github.com/NIT-Administrative-Systems/northwestern-starlight-theme/compare/v1.1.1...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
56
76
  [1.1.1]: https://github.com/NIT-Administrative-Systems/northwestern-starlight-theme/compare/v1.1.0...v1.1.1
57
77
  [1.1.0]: https://github.com/NIT-Administrative-Systems/northwestern-starlight-theme/compare/v1.0.0...v1.1.0
58
78
  [1.0.0]: https://github.com/NIT-Administrative-Systems/northwestern-starlight-theme/releases/tag/v1.0.0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nu-appdev/northwestern-starlight-theme",
3
- "version": "1.1.1",
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>",
@@ -34,8 +34,6 @@ const ICONS = {
34
34
  "M16 4h2a2 2 0 012 2v6a2 2 0 01-2 2h-8a2 2 0 01-2-2V6a2 2 0 012-2",
35
35
  ],
36
36
  code: ["M16 18l6-6-6-6", "M8 6l-6 6 6 6"],
37
- fitWidth: ["M21 3H3v18h18V3z", "M9 3v18", "M15 3v18"],
38
- fitHeight: ["M21 3H3v18h18V3z", "M3 9h18", "M3 15h18"],
39
37
  };
40
38
 
41
39
  function createSvgIcon(paths: string[], size = 14): SVGElement {
@@ -116,13 +114,24 @@ async function getMermaidRuntime() {
116
114
  function captureSource() {
117
115
  document.querySelectorAll<HTMLElement>("pre.mermaid").forEach((pre) => {
118
116
  if (mermaidSources.has(pre)) return;
119
- const source = pre.textContent?.trim() || "";
120
- 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
+ }
121
127
  });
122
128
  }
123
129
 
124
130
  async function renderDiagram(container: HTMLElement, mode: MermaidThemeMode, index: number) {
125
- 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();
126
135
  if (!source) return;
127
136
  const mermaid = await getMermaidRuntime();
128
137
 
@@ -237,7 +246,7 @@ function initMermaidToolbar(): number {
237
246
  if (!svg) return;
238
247
 
239
248
  const action = btn.dataset.action;
240
- if (action === "fullscreen") openFullscreen(svg, container, index);
249
+ if (action === "fullscreen") openFullscreen(svg, container, index, btn, e.detail === 0);
241
250
  else if (action === "download-svg") {
242
251
  downloadSvg(svg, container, index);
243
252
  showSuccess(btn, "Downloaded!");
@@ -247,9 +256,18 @@ function initMermaidToolbar(): number {
247
256
  return injected;
248
257
  }
249
258
 
250
- 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
+ ) {
251
266
  const overlay = document.createElement("div");
252
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");
253
271
 
254
272
  // Controls
255
273
  const controls = document.createElement("div");
@@ -299,11 +317,9 @@ function openFullscreen(svg: SVGElement, container: HTMLElement, index: number)
299
317
  vbHeight = parts[3] || 600;
300
318
  }
301
319
 
302
- function calcFitScale(mode: "both" | "width" | "height" = "both"): number {
320
+ function calcFitScale(): number {
303
321
  const maxW = window.innerWidth * 0.92;
304
322
  const maxH = (window.innerHeight - 120) * 0.92; // account for controls bar
305
- if (mode === "width") return maxW / vbWidth;
306
- if (mode === "height") return maxH / vbHeight;
307
323
  return Math.min(maxW / vbWidth, maxH / vbHeight);
308
324
  }
309
325
 
@@ -311,8 +327,13 @@ function openFullscreen(svg: SVGElement, container: HTMLElement, index: number)
311
327
  cloned.setAttribute("width", String(Math.round(vbWidth * initialScale)));
312
328
  cloned.setAttribute("height", String(Math.round(vbHeight * initialScale)));
313
329
 
330
+ const zoomBadge = document.createElement("span");
331
+ zoomBadge.className = "nu-mermaid-zoom-badge";
332
+ zoomBadge.textContent = "100%";
333
+
314
334
  wrapper.appendChild(cloned);
315
335
  viewport.appendChild(wrapper);
336
+ viewport.appendChild(zoomBadge);
316
337
  overlay.appendChild(viewport);
317
338
 
318
339
  overlay.style.opacity = "0";
@@ -333,6 +354,7 @@ function openFullscreen(svg: SVGElement, container: HTMLElement, index: number)
333
354
  function updateTransform() {
334
355
  wrapper.style.transform = `translate(${panX}px, ${panY}px) scale(${scale})`;
335
356
  wrapper.style.transformOrigin = "center center";
357
+ zoomBadge.textContent = `${Math.round(scale * 100)}%`;
336
358
  }
337
359
 
338
360
  function zoomTo(newScale: number) {
@@ -351,8 +373,17 @@ function openFullscreen(svg: SVGElement, container: HTMLElement, index: number)
351
373
  "wheel",
352
374
  (e) => {
353
375
  e.preventDefault();
354
- const delta = e.deltaY > 0 ? 0.9 : 1.1;
355
- 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();
356
387
  },
357
388
  { passive: false },
358
389
  );
@@ -446,7 +477,15 @@ function openFullscreen(svg: SVGElement, container: HTMLElement, index: number)
446
477
 
447
478
  viewport.addEventListener("dblclick", (e) => {
448
479
  e.preventDefault();
449
- 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();
450
489
  });
451
490
 
452
491
  controls.addEventListener("click", (e) => {
@@ -466,6 +505,27 @@ function openFullscreen(svg: SVGElement, container: HTMLElement, index: number)
466
505
  });
467
506
 
468
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
+ }
469
529
  if (e.key === "Escape") close();
470
530
  else if (e.key === "+" || e.key === "=") zoomTo(scale * 1.2);
471
531
  else if (e.key === "-" || e.key === "_") zoomTo(scale * 0.8);
@@ -492,6 +552,15 @@ function openFullscreen(svg: SVGElement, container: HTMLElement, index: number)
492
552
  if (e.target === overlay) close();
493
553
  });
494
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
+
495
564
  function close() {
496
565
  overlay.style.opacity = "0";
497
566
  setTimeout(() => {
@@ -500,6 +569,7 @@ function openFullscreen(svg: SVGElement, container: HTMLElement, index: number)
500
569
  window.removeEventListener("mouseup", onMouseUp);
501
570
  document.body.style.overflow = "";
502
571
  overlay.remove();
572
+ triggerBtn?.focus();
503
573
  }, 200);
504
574
  }
505
575
  }
@@ -529,7 +599,7 @@ function getDiagramType(container: Element): string {
529
599
  return slugify(raw.replace(/[-_](v\d+|beta)$/i, "").replace(/(diagram|chart)$/i, "")) || "diagram";
530
600
  }
531
601
 
532
- function buildFilename(container: Element, index: number): string {
602
+ function buildFilename(container: Element): string {
533
603
  const site = getSiteSlug();
534
604
  const page = getPageSlug();
535
605
  const type = getDiagramType(container);
@@ -549,7 +619,7 @@ function downloadSvg(svg: SVGElement, container: Element, index: number) {
549
619
  const url = URL.createObjectURL(blob);
550
620
  const a = document.createElement("a");
551
621
  a.href = url;
552
- a.download = buildFilename(container, index);
622
+ a.download = buildFilename(container);
553
623
  a.click();
554
624
  URL.revokeObjectURL(url);
555
625
  }
@@ -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;
@@ -75,8 +75,8 @@
75
75
  }
76
76
 
77
77
  .nu-mermaid-btn:focus-visible {
78
- outline: none;
79
- box-shadow: 0 0 0 0.25rem var(--nu-focus-shadow-color);
78
+ outline: 2px solid var(--sl-color-accent);
79
+ outline-offset: -2px;
80
80
  }
81
81
 
82
82
  .nu-mermaid-btn:hover {
@@ -144,6 +144,11 @@
144
144
  height: 16px;
145
145
  }
146
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%);
150
+ }
151
+
147
152
  .nu-mermaid-overlay-controls .nu-mermaid-btn:hover {
148
153
  color: #fff;
149
154
  background: rgb(255 255 255 / 25%);
@@ -199,4 +204,27 @@
199
204
  background: transparent !important;
200
205
  border: none !important;
201
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
+ }
202
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;