@nu-appdev/northwestern-starlight-theme 1.0.1 → 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 +39 -2
- package/README.md +1 -1
- package/index.ts +71 -2
- package/package.json +1 -1
- package/src/components/Hero.astro +7 -5
- package/src/favicon.png +0 -0
- package/src/scripts/mermaid-toolbar.ts +109 -23
- package/src/styles/mermaid-toolbar.css +58 -19
- package/src/styles/navigation.css +9 -1
- package/src/styles/theme.css +4 -1
- package/src/virtual.d.ts +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,7 +7,42 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
-
## [1.
|
|
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
|
+
|
|
28
|
+
## [1.1.0] - 2026-03-25
|
|
29
|
+
|
|
30
|
+
### Added
|
|
31
|
+
|
|
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.
|
|
34
|
+
|
|
35
|
+
### Fixed
|
|
36
|
+
|
|
37
|
+
- `showTitle: false` now works in the centered hero layout (previously only worked in split layout).
|
|
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.
|
|
39
|
+
- Search input focus ring uses Northwestern purple instead of the browser default.
|
|
40
|
+
|
|
41
|
+
### Changed
|
|
42
|
+
|
|
43
|
+
- Release workflow uses npm trusted publishing (OIDC) instead of long-lived tokens.
|
|
44
|
+
|
|
45
|
+
## [1.0.0] - 2026-03-25
|
|
11
46
|
|
|
12
47
|
- Initial public release
|
|
13
48
|
- Northwestern Purple color palette mapped to Starlight's accent system
|
|
@@ -17,5 +52,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
17
52
|
- OpenAPI plugin compatibility with method badge preservation
|
|
18
53
|
- Reduced motion support for transitions
|
|
19
54
|
|
|
20
|
-
[Unreleased]: https://github.com/NIT-Administrative-Systems/northwestern-starlight-theme/compare/v1.
|
|
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
|
|
57
|
+
[1.1.0]: https://github.com/NIT-Administrative-Systems/northwestern-starlight-theme/compare/v1.0.0...v1.1.0
|
|
21
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
|
|
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/index.ts
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import { copyFileSync, mkdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
1
5
|
import type { StarlightPlugin } from "@astrojs/starlight/types";
|
|
2
6
|
|
|
3
7
|
export interface NorthwesternHomepageConfig {
|
|
@@ -17,6 +21,15 @@ export interface NorthwesternHomepageConfig {
|
|
|
17
21
|
* @default true
|
|
18
22
|
*/
|
|
19
23
|
showTitle?: boolean;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Maximum width of the hero image in the centered layout, in pixels.
|
|
27
|
+
*
|
|
28
|
+
* Use a larger value for wide lockup images (e.g., `"750px"`, `"1000px"`).
|
|
29
|
+
*
|
|
30
|
+
* @default "500px"
|
|
31
|
+
*/
|
|
32
|
+
imageWidth?: string;
|
|
20
33
|
}
|
|
21
34
|
|
|
22
35
|
export interface NorthwesternThemeConfig {
|
|
@@ -53,12 +66,27 @@ const RESOLVED_VIRTUAL_MODULE_ID = `\0${VIRTUAL_MODULE_ID}`;
|
|
|
53
66
|
|
|
54
67
|
export default function northwesternTheme(config: NorthwesternThemeConfig = {}): StarlightPlugin {
|
|
55
68
|
const { homepage = {}, mermaid = true } = config;
|
|
56
|
-
const themeConfig = {
|
|
69
|
+
const themeConfig = {
|
|
70
|
+
homepage: {
|
|
71
|
+
layout: homepage.layout ?? "centered",
|
|
72
|
+
showTitle: homepage.showTitle ?? true,
|
|
73
|
+
imageWidth: homepage.imageWidth ?? "500px",
|
|
74
|
+
},
|
|
75
|
+
};
|
|
57
76
|
|
|
58
77
|
return {
|
|
59
78
|
name: "northwestern-starlight-theme",
|
|
60
79
|
hooks: {
|
|
61
80
|
async "config:setup"({ config: starlightConfig, updateConfig, addIntegration, logger }) {
|
|
81
|
+
// Apply default favicon if consumer hasn't set one
|
|
82
|
+
const consumerSetFavicon =
|
|
83
|
+
starlightConfig.favicon &&
|
|
84
|
+
(starlightConfig.favicon as unknown as { href: string }).href !== "/favicon.svg";
|
|
85
|
+
|
|
86
|
+
// Read favicon bytes once for the Vite plugin to serve
|
|
87
|
+
const faviconPath = join(dirname(fileURLToPath(import.meta.url)), "src", "favicon.png");
|
|
88
|
+
const faviconBytes = consumerSetFavicon ? null : readFileSync(faviconPath);
|
|
89
|
+
|
|
62
90
|
addIntegration({
|
|
63
91
|
name: "northwestern-theme-config",
|
|
64
92
|
hooks: {
|
|
@@ -77,14 +105,54 @@ export default function northwesternTheme(config: NorthwesternThemeConfig = {}):
|
|
|
77
105
|
}
|
|
78
106
|
},
|
|
79
107
|
},
|
|
108
|
+
// Serve the bundled favicon during dev — no file touches public/
|
|
109
|
+
...(faviconBytes
|
|
110
|
+
? [
|
|
111
|
+
{
|
|
112
|
+
name: "northwestern-theme-favicon",
|
|
113
|
+
configureServer(server: {
|
|
114
|
+
middlewares: {
|
|
115
|
+
use(
|
|
116
|
+
fn: (
|
|
117
|
+
req: IncomingMessage,
|
|
118
|
+
res: ServerResponse,
|
|
119
|
+
next: () => void,
|
|
120
|
+
) => void,
|
|
121
|
+
): void;
|
|
122
|
+
};
|
|
123
|
+
}) {
|
|
124
|
+
server.middlewares.use((req, res, next) => {
|
|
125
|
+
if (req.url === "/favicon.png") {
|
|
126
|
+
res.setHeader("Content-Type", "image/png");
|
|
127
|
+
res.setHeader(
|
|
128
|
+
"Cache-Control",
|
|
129
|
+
"public, max-age=31536000, immutable",
|
|
130
|
+
);
|
|
131
|
+
res.end(faviconBytes);
|
|
132
|
+
} else {
|
|
133
|
+
next();
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
]
|
|
139
|
+
: []),
|
|
80
140
|
],
|
|
81
141
|
},
|
|
82
142
|
});
|
|
83
143
|
},
|
|
144
|
+
// Copy favicon into build output so nothing touches public/
|
|
145
|
+
"astro:build:done": ({ dir }) => {
|
|
146
|
+
if (faviconBytes) {
|
|
147
|
+
const dest = join(fileURLToPath(dir), "favicon.png");
|
|
148
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
149
|
+
copyFileSync(faviconPath, dest);
|
|
150
|
+
}
|
|
151
|
+
},
|
|
84
152
|
},
|
|
85
153
|
});
|
|
86
154
|
// Detect mermaid availability
|
|
87
|
-
let mermaidEnabled = mermaid
|
|
155
|
+
let mermaidEnabled = mermaid;
|
|
88
156
|
|
|
89
157
|
if (mermaidEnabled) {
|
|
90
158
|
const hasMermaid = await canResolve("astro-mermaid");
|
|
@@ -102,6 +170,7 @@ export default function northwesternTheme(config: NorthwesternThemeConfig = {}):
|
|
|
102
170
|
}
|
|
103
171
|
|
|
104
172
|
updateConfig({
|
|
173
|
+
...(consumerSetFavicon ? {} : { favicon: "/favicon.png" }),
|
|
105
174
|
components: {
|
|
106
175
|
...(starlightConfig.components ?? {}),
|
|
107
176
|
Hero:
|
package/package.json
CHANGED
|
@@ -7,11 +7,12 @@ const PAGE_TITLE_ID = "_top";
|
|
|
7
7
|
const { data } = Astro.locals.starlightRoute.entry;
|
|
8
8
|
const { title = data.title, tagline, image, actions = [] } = data.hero || {};
|
|
9
9
|
|
|
10
|
+
const imageWidthPx = parseInt(themeConfig.homepage.imageWidth, 10) || 500;
|
|
11
|
+
|
|
10
12
|
const imageAttrs = {
|
|
11
13
|
loading: "eager" as const,
|
|
12
14
|
decoding: "async" as const,
|
|
13
|
-
width:
|
|
14
|
-
height: 400,
|
|
15
|
+
width: imageWidthPx,
|
|
15
16
|
alt: image?.alt || "",
|
|
16
17
|
};
|
|
17
18
|
|
|
@@ -32,9 +33,10 @@ if (image) {
|
|
|
32
33
|
const hasImage = Boolean(darkImage || lightImage || rawHtml);
|
|
33
34
|
const isSplit = hasImage && themeConfig.homepage.layout === "split";
|
|
34
35
|
const showTitle = themeConfig.homepage.showTitle;
|
|
36
|
+
const imageWidth = themeConfig.homepage.imageWidth;
|
|
35
37
|
---
|
|
36
38
|
|
|
37
|
-
<div class:list={["hero", { "hero--split": isSplit }]}>
|
|
39
|
+
<div class:list={["hero", { "hero--split": isSplit }]} style={`--hero-image-width: ${imageWidth};`}>
|
|
38
40
|
{
|
|
39
41
|
isSplit ? (
|
|
40
42
|
<>
|
|
@@ -81,7 +83,7 @@ const showTitle = themeConfig.homepage.showTitle;
|
|
|
81
83
|
</div>
|
|
82
84
|
)}
|
|
83
85
|
<div class="sl-flex copy">
|
|
84
|
-
<h1 id={PAGE_TITLE_ID} data-page-title set:html={title} />
|
|
86
|
+
{showTitle && <h1 id={PAGE_TITLE_ID} data-page-title set:html={title} />}
|
|
85
87
|
{tagline && <div class="tagline" set:html={tagline} />}
|
|
86
88
|
</div>
|
|
87
89
|
{actions.length > 0 && (
|
|
@@ -163,7 +165,7 @@ const showTitle = themeConfig.homepage.showTitle;
|
|
|
163
165
|
.hero-image-centered .hero-image,
|
|
164
166
|
.hero-image-centered .hero-html {
|
|
165
167
|
object-fit: contain;
|
|
166
|
-
width: min(90%, 500px);
|
|
168
|
+
width: min(90%, var(--hero-image-width, 500px));
|
|
167
169
|
height: auto;
|
|
168
170
|
}
|
|
169
171
|
|
package/src/favicon.png
ADDED
|
Binary file
|
|
@@ -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"],
|
|
@@ -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
|
|
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
|
-
|
|
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
|
-
},
|
|
503
|
+
}, 200);
|
|
454
504
|
}
|
|
455
505
|
}
|
|
456
506
|
|
|
457
|
-
|
|
458
|
-
|
|
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 =
|
|
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:
|
|
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: 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:
|
|
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,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:
|
|
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;
|
|
107
145
|
}
|
|
108
146
|
|
|
109
147
|
.nu-mermaid-overlay-controls .nu-mermaid-btn:hover {
|
|
110
|
-
color:
|
|
111
|
-
background:
|
|
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:
|
|
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:
|
|
132
|
-
border-color:
|
|
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:
|
|
137
|
-
border-color:
|
|
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,7 +7,8 @@
|
|
|
7
7
|
|
|
8
8
|
@layer northwestern {
|
|
9
9
|
.site-title,
|
|
10
|
-
.header a,
|
|
10
|
+
.header > a,
|
|
11
|
+
.header > nav a,
|
|
11
12
|
.social-icons a,
|
|
12
13
|
starlight-theme-select,
|
|
13
14
|
starlight-theme-select label,
|
|
@@ -58,6 +59,13 @@
|
|
|
58
59
|
line-height: 1 !important;
|
|
59
60
|
}
|
|
60
61
|
|
|
62
|
+
/* stylelint-disable-next-line selector-id-pattern -- Starlight's own ID */
|
|
63
|
+
#starlight__search input:focus {
|
|
64
|
+
--pagefind-ui-border: var(--nu-focus-border) !important;
|
|
65
|
+
outline: none !important;
|
|
66
|
+
box-shadow: 0 0 0 0.25rem var(--nu-focus-shadow-color) !important;
|
|
67
|
+
}
|
|
68
|
+
|
|
61
69
|
site-search button[data-open-modal] > svg {
|
|
62
70
|
flex-shrink: 0;
|
|
63
71
|
align-self: center;
|
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
|
}
|