@nu-appdev/northwestern-starlight-theme 1.2.0 → 1.3.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.
Files changed (45) hide show
  1. package/CHANGELOG.md +63 -2
  2. package/index.ts +84 -5
  3. package/mermaid.ts +196 -80
  4. package/package.json +19 -4
  5. package/src/components/Expandable.astro +19 -0
  6. package/src/components/Glossary.astro +11 -0
  7. package/src/components/Hero.astro +0 -2
  8. package/src/components/Kbd.astro +147 -0
  9. package/src/components/Property.astro +64 -0
  10. package/src/components/PropertyGroup.astro +13 -0
  11. package/src/components/PropertyTable.astro +54 -0
  12. package/src/components/Term.astro +29 -0
  13. package/src/components/Tooltip.astro +183 -0
  14. package/src/components/glossary-store.ts +12 -0
  15. package/src/components/index.ts +8 -0
  16. package/src/rehype-table-scroll.ts +35 -0
  17. package/src/scripts/mermaid/focus.ts +100 -0
  18. package/src/scripts/mermaid/fullscreen.ts +120 -0
  19. package/src/scripts/mermaid/index.ts +2 -0
  20. package/src/scripts/mermaid/overlay.ts +132 -0
  21. package/src/scripts/mermaid/pan-zoom.ts +285 -0
  22. package/src/scripts/mermaid/render.ts +143 -0
  23. package/src/scripts/mermaid/toolbar.ts +137 -0
  24. package/src/scripts/mermaid/ui.ts +265 -0
  25. package/src/styles/a11y.css +69 -0
  26. package/src/styles/components/blockquotes.css +37 -0
  27. package/src/styles/components/code.css +8 -5
  28. package/src/styles/components/footnotes.css +76 -0
  29. package/src/styles/components/kbd.css +69 -0
  30. package/src/styles/components/mermaid.css +4 -4
  31. package/src/styles/components/property-table.css +569 -0
  32. package/src/styles/components/steps.css +0 -3
  33. package/src/styles/components/tables.css +60 -0
  34. package/src/styles/components/tabs.css +0 -3
  35. package/src/styles/components/tooltip.css +145 -0
  36. package/src/styles/content.css +4 -160
  37. package/src/styles/layers.css +0 -7
  38. package/src/styles/mermaid-toolbar.css +62 -13
  39. package/src/styles/navigation.css +66 -17
  40. package/src/styles/openapi.css +0 -3
  41. package/src/styles/theme.css +0 -4
  42. package/src/styles/typography.css +4 -0
  43. package/src/styles/variables.css +0 -3
  44. package/src/virtual.d.ts +11 -0
  45. package/src/scripts/mermaid-toolbar.ts +0 -659
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Mermaid toolbar entry point.
3
+ *
4
+ * Orchestrates diagram rendering, toolbar injection, and lifecycle management
5
+ * across page loads, theme changes, and Astro view transitions.
6
+ */
7
+
8
+ declare global {
9
+ interface Window {
10
+ /** Whether the hover toolbar is enabled (injected by the Northwestern Mermaid integration). */
11
+ __NU_MERMAID_TOOLBAR__?: boolean;
12
+ /** Northwestern Mermaid theme configs for light and dark modes. */
13
+ __NU_MERMAID_CONFIGS__?: {
14
+ light?: Record<string, unknown>;
15
+ dark?: Record<string, unknown>;
16
+ };
17
+ }
18
+ }
19
+
20
+ import { openFullscreen } from "./fullscreen";
21
+ import { diagramSources, renderAllDiagrams } from "./render";
22
+ import { createActionButton, downloadDiagramSvg, flashSuccess, ICON_PATHS, writeToClipboard } from "./ui";
23
+
24
+ /** Maximum time (ms) to watch for lazy-rendered diagrams before disconnecting the observer. */
25
+ const TOOLBAR_OBSERVER_TIMEOUT_MS = 30_000;
26
+
27
+ /**
28
+ * Inject hover toolbars into rendered mermaid diagrams.
29
+ *
30
+ * Safe to call repeatedly across page loads, lazy render callbacks, theme changes,
31
+ * and Astro view transitions.
32
+ *
33
+ * **Idempotency guards:**
34
+ * - Skips fullscreen clones (`.nu-mermaid-fullscreen`)
35
+ * - Skips diagrams without a rendered `<svg>` child
36
+ * - Skips diagrams that already have a `.nu-mermaid-toolbar`
37
+ */
38
+ function injectToolbars(): number {
39
+ const diagrams = document.querySelectorAll<HTMLElement>(".mermaid");
40
+ if (!diagrams.length) return 0;
41
+
42
+ let injectedCount = 0;
43
+
44
+ diagrams.forEach((container) => {
45
+ if (container.classList.contains("nu-mermaid-fullscreen")) return;
46
+ if (!container.querySelector("svg")) return;
47
+ if (container.querySelector(".nu-mermaid-toolbar")) return;
48
+
49
+ const toolbar = document.createElement("div");
50
+ toolbar.className = "nu-mermaid-toolbar";
51
+
52
+ toolbar.appendChild(createActionButton("Fullscreen", "fullscreen", ICON_PATHS.fullscreen));
53
+ toolbar.appendChild(createActionButton("Download SVG", "download-svg", ICON_PATHS.download));
54
+
55
+ const source = diagramSources.get(container);
56
+ if (source) {
57
+ toolbar.appendChild(createActionButton("Copy Mermaid", "copy-source", ICON_PATHS.code));
58
+ }
59
+
60
+ container.style.position = "relative";
61
+ container.appendChild(toolbar);
62
+ injectedCount++;
63
+
64
+ toolbar.addEventListener("click", (event) => {
65
+ const button = (event.target as HTMLElement).closest<HTMLButtonElement>(".nu-mermaid-btn");
66
+ if (!button) return;
67
+ event.stopPropagation();
68
+
69
+ const svg = container.querySelector("svg");
70
+ if (!svg) return;
71
+
72
+ const isKeyboardActivated = event.detail === 0;
73
+
74
+ switch (button.dataset.action) {
75
+ case "fullscreen":
76
+ openFullscreen(svg, container, button, isKeyboardActivated);
77
+ break;
78
+ case "download-svg":
79
+ downloadDiagramSvg(svg, container);
80
+ flashSuccess(button, "Downloaded!");
81
+ break;
82
+ case "copy-source":
83
+ if (source) writeToClipboard(source, button);
84
+ break;
85
+ }
86
+ });
87
+ });
88
+
89
+ return injectedCount;
90
+ }
91
+
92
+ function isToolbarEnabled(): boolean {
93
+ return window.__NU_MERMAID_TOOLBAR__ !== false;
94
+ }
95
+
96
+ /**
97
+ * Render diagrams and inject toolbars. Called on initial page load
98
+ * and after Astro view transitions.
99
+ */
100
+ let domObserver: MutationObserver | undefined;
101
+
102
+ function onPageReady(): void {
103
+ domObserver?.disconnect();
104
+
105
+ void renderAllDiagrams(false).then(() => {
106
+ if (isToolbarEnabled()) injectToolbars();
107
+
108
+ // Watch for lazy-rendered diagrams that enter the viewport after initial load
109
+ domObserver = new MutationObserver(() => {
110
+ if (isToolbarEnabled()) injectToolbars();
111
+ });
112
+ domObserver.observe(document.body, { childList: true, subtree: true });
113
+ setTimeout(() => domObserver?.disconnect(), TOOLBAR_OBSERVER_TIMEOUT_MS);
114
+ });
115
+ }
116
+
117
+ // Re-render all diagrams and re-inject toolbars when the theme changes
118
+ const themeObserver = new MutationObserver(() => {
119
+ void renderAllDiagrams(true).then(() => {
120
+ if (isToolbarEnabled()) injectToolbars();
121
+ });
122
+ });
123
+
124
+ themeObserver.observe(document.documentElement, {
125
+ attributes: true,
126
+ attributeFilter: ["data-theme"],
127
+ });
128
+
129
+ // Initial page load
130
+ if (document.readyState === "loading") {
131
+ document.addEventListener("DOMContentLoaded", onPageReady);
132
+ } else {
133
+ onPageReady();
134
+ }
135
+
136
+ // Astro view transitions
137
+ document.addEventListener("astro:page-load", onPageReady);
@@ -0,0 +1,265 @@
1
+ /**
2
+ * Shared UI primitives for the Mermaid toolbar and fullscreen viewer.
3
+ *
4
+ * Pure factories and helpers with no DOM querying or lifecycle logic.
5
+ * Everything here is stateless except the success timer and toast tracking,
6
+ * which are scoped to individual button/container instances.
7
+ */
8
+
9
+ import { diagramSources } from "./render";
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // SVG icon paths (Lucide-compatible, 24x24 viewBox)
13
+ // ---------------------------------------------------------------------------
14
+
15
+ /** SVG path data for toolbar and overlay icons. */
16
+ export const ICON_PATHS = {
17
+ 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"],
18
+ download: ["M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4", "M7 10l5 5 5-5", "M12 15V3"],
19
+ close: ["M18 6L6 18M6 6l12 12"],
20
+ zoomIn: ["M12 5v14", "M5 12h14"],
21
+ zoomOut: ["M5 12h14"],
22
+ reset: ["M3 12a9 9 0 1018 0 9 9 0 00-18 0z", "M12 8v4l2 2"],
23
+ copy: [
24
+ "M8 4H6a2 2 0 00-2 2v12a2 2 0 002 2h8a2 2 0 002-2v-2",
25
+ "M16 4h2a2 2 0 012 2v6a2 2 0 01-2 2h-8a2 2 0 01-2-2V6a2 2 0 012-2",
26
+ ],
27
+ code: ["M16 18l6-6-6-6", "M8 6l-6 6 6 6"],
28
+ check: ["M20 6L9 17l-5-5"],
29
+ } as const;
30
+
31
+ const SVG_NS = "http://www.w3.org/2000/svg";
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Icon and button factories
35
+ // ---------------------------------------------------------------------------
36
+
37
+ /** Create a stroked SVG icon from an array of path `d` attributes. */
38
+ export function createIcon(paths: readonly string[], size = 14): SVGElement {
39
+ const svg = document.createElementNS(SVG_NS, "svg");
40
+ const attrs: Record<string, string | number> = {
41
+ width: size,
42
+ height: size,
43
+ viewBox: "0 0 24 24",
44
+ fill: "none",
45
+ stroke: "currentColor",
46
+ "stroke-width": "2",
47
+ "stroke-linecap": "round",
48
+ "stroke-linejoin": "round",
49
+ };
50
+ for (const [attr, value] of Object.entries(attrs)) {
51
+ svg.setAttribute(attr, String(value));
52
+ }
53
+ for (const d of paths) {
54
+ const path = document.createElementNS(SVG_NS, "path");
55
+ path.setAttribute("d", d);
56
+ svg.appendChild(path);
57
+ }
58
+ return svg;
59
+ }
60
+
61
+ /** Create a zoom-specific icon with a magnifying glass circle + handle. */
62
+ function createZoomIcon(innerPaths: readonly string[]): SVGElement {
63
+ const svg = createIcon(innerPaths, 14);
64
+ const circle = document.createElementNS(SVG_NS, "circle");
65
+ circle.setAttribute("cx", "11");
66
+ circle.setAttribute("cy", "11");
67
+ circle.setAttribute("r", "8");
68
+ svg.insertBefore(circle, svg.firstChild);
69
+ const handle = document.createElementNS(SVG_NS, "path");
70
+ handle.setAttribute("d", "M21 21l-4.35-4.35");
71
+ svg.insertBefore(handle, svg.children[1]);
72
+ return svg;
73
+ }
74
+
75
+ /** Create an accessible toolbar button with an icon and a `data-action` attribute. */
76
+ export function createActionButton(
77
+ label: string,
78
+ action: string,
79
+ iconPaths: readonly string[],
80
+ className = "nu-mermaid-btn",
81
+ ): HTMLButtonElement {
82
+ const button = document.createElement("button");
83
+ button.className = className;
84
+ button.dataset.action = action;
85
+ button.title = label;
86
+ button.setAttribute("aria-label", label);
87
+ button.appendChild(createIcon(iconPaths));
88
+ return button;
89
+ }
90
+
91
+ /** Create a zoom-specific button with a magnifying glass icon. */
92
+ export function createZoomButton(
93
+ label: string,
94
+ action: string,
95
+ innerPaths: readonly string[],
96
+ className = "nu-mermaid-btn",
97
+ ): HTMLButtonElement {
98
+ const button = document.createElement("button");
99
+ button.className = className;
100
+ button.dataset.action = action;
101
+ button.title = label;
102
+ button.setAttribute("aria-label", label);
103
+ button.appendChild(createZoomIcon(innerPaths));
104
+ return button;
105
+ }
106
+
107
+ // ---------------------------------------------------------------------------
108
+ // Button success feedback
109
+ // ---------------------------------------------------------------------------
110
+
111
+ const activeSuccessTimers = new WeakMap<HTMLButtonElement, number>();
112
+
113
+ /**
114
+ * Temporarily replace a button's icon with a checkmark and apply the success style.
115
+ *
116
+ * Debounced per button — rapid clicks clear the previous timer and restart.
117
+ * The original icon and title restore after 1500ms.
118
+ */
119
+ export function flashSuccess(button: HTMLButtonElement, label = "Copied!"): void {
120
+ const previousTimer = activeSuccessTimers.get(button);
121
+ if (previousTimer) {
122
+ clearTimeout(previousTimer);
123
+ button.querySelector(".nu-mermaid-check")?.remove();
124
+ }
125
+
126
+ const originalTitle = button.title;
127
+ const originalIcon = button.querySelector("svg:not(.nu-mermaid-check)");
128
+ const checkIcon = createIcon(ICON_PATHS.check, 14);
129
+ checkIcon.classList.add("nu-mermaid-check");
130
+
131
+ button.title = label;
132
+ button.classList.add("nu-mermaid-success");
133
+ if (originalIcon) {
134
+ (originalIcon as HTMLElement).style.display = "none";
135
+ button.insertBefore(checkIcon, button.firstChild);
136
+ }
137
+
138
+ const timer = window.setTimeout(() => {
139
+ activeSuccessTimers.delete(button);
140
+ button.title = originalTitle;
141
+ button.classList.remove("nu-mermaid-success");
142
+ checkIcon.remove();
143
+ if (originalIcon) (originalIcon as HTMLElement).style.display = "";
144
+ }, 1500);
145
+ activeSuccessTimers.set(button, timer);
146
+ }
147
+
148
+ // ---------------------------------------------------------------------------
149
+ // Toast notifications
150
+ // ---------------------------------------------------------------------------
151
+
152
+ let currentToast: HTMLElement | null = null;
153
+ let currentToastTimer = 0;
154
+
155
+ /** Display a toast message in the bottom-right of a container. Replaces any active toast. */
156
+ export function showToast(container: HTMLElement, message: string): void {
157
+ if (currentToast) {
158
+ clearTimeout(currentToastTimer);
159
+ currentToast.remove();
160
+ }
161
+
162
+ const toast = document.createElement("div");
163
+ toast.className = "nu-mermaid-toast";
164
+ toast.textContent = message;
165
+ container.appendChild(toast);
166
+ currentToast = toast;
167
+
168
+ requestAnimationFrame(() => toast.classList.add("nu-mermaid-toast-visible"));
169
+
170
+ currentToastTimer = window.setTimeout(() => {
171
+ toast.classList.remove("nu-mermaid-toast-visible");
172
+ setTimeout(() => {
173
+ toast.remove();
174
+ if (currentToast === toast) currentToast = null;
175
+ }, 200);
176
+ }, 2000);
177
+ }
178
+
179
+ // ---------------------------------------------------------------------------
180
+ // Clipboard
181
+ // ---------------------------------------------------------------------------
182
+
183
+ /**
184
+ * Copy text to the clipboard with a visual success flash on the button.
185
+ *
186
+ * Uses the Clipboard API when available (HTTPS / localhost). Falls back to
187
+ * `document.execCommand("copy")` via a hidden textarea for insecure contexts
188
+ * (e.g., HTTP over LAN). The textarea is appended inside the closest overlay
189
+ * to prevent iOS Safari's URL bar from toggling.
190
+ */
191
+ export async function writeToClipboard(text: string, triggerButton: HTMLButtonElement): Promise<void> {
192
+ try {
193
+ await navigator.clipboard.writeText(text);
194
+ flashSuccess(triggerButton, "Copied!");
195
+ } catch {
196
+ const host = triggerButton.closest(".nu-mermaid-overlay") ?? document.body;
197
+ const textarea = document.createElement("textarea");
198
+ textarea.value = text;
199
+ textarea.setAttribute("readonly", "");
200
+ textarea.style.cssText = "position:absolute;left:-9999px;opacity:0;height:0";
201
+ host.appendChild(textarea);
202
+ textarea.select();
203
+ const success = document.execCommand("copy");
204
+ textarea.remove();
205
+ if (success) flashSuccess(triggerButton, "Copied!");
206
+ }
207
+ }
208
+
209
+ // ---------------------------------------------------------------------------
210
+ // SVG download
211
+ // ---------------------------------------------------------------------------
212
+
213
+ /**
214
+ * Download the diagram SVG with a descriptive filename derived from
215
+ * the site title, page slug, and diagram type.
216
+ */
217
+ export function downloadDiagramSvg(svg: SVGElement, container: Element): void {
218
+ const clone = svg.cloneNode(true) as SVGElement;
219
+ clone.setAttribute("xmlns", SVG_NS);
220
+ const blob = new Blob([clone.outerHTML], { type: "image/svg+xml" });
221
+ const url = URL.createObjectURL(blob);
222
+ const anchor = document.createElement("a");
223
+ anchor.href = url;
224
+ anchor.download = buildDownloadFilename(container);
225
+ anchor.click();
226
+ URL.revokeObjectURL(url);
227
+ }
228
+
229
+ // ---------------------------------------------------------------------------
230
+ // Filename generation (private)
231
+ // ---------------------------------------------------------------------------
232
+
233
+ function slugify(text: string): string {
234
+ return text
235
+ .toLowerCase()
236
+ .replace(/[^a-z0-9]+/g, "-")
237
+ .replace(/^-|-$/g, "");
238
+ }
239
+
240
+ function extractSiteSlug(): string {
241
+ const segments = document.title.split("|");
242
+ return slugify((segments.length > 1 ? segments.at(-1)! : segments[0]).trim());
243
+ }
244
+
245
+ function extractPageSlug(): string {
246
+ return slugify(window.location.pathname.replace(/^\/|\/$/g, "")) || "index";
247
+ }
248
+
249
+ function detectDiagramType(container: Element): string {
250
+ const source = diagramSources.get(container) ?? "";
251
+ const keyword = source.trim().split(/[\s\n]/)[0];
252
+ return slugify(keyword.replace(/[-_](v\d+|beta)$/i, "").replace(/(diagram|chart)$/i, "")) || "diagram";
253
+ }
254
+
255
+ function buildDownloadFilename(container: Element): string {
256
+ const site = extractSiteSlug();
257
+ const page = extractPageSlug();
258
+ const type = detectDiagramType(container);
259
+
260
+ const allDiagrams = [...document.querySelectorAll<HTMLElement>(".mermaid:not(.nu-mermaid-fullscreen)")];
261
+ const sameType = allDiagrams.filter((d) => detectDiagramType(d) === type);
262
+ const disambiguator = sameType.length > 1 ? `-${sameType.indexOf(container as HTMLElement) + 1}` : "";
263
+
264
+ return `${site}_${page}-${type}${disambiguator}.svg`;
265
+ }
@@ -0,0 +1,69 @@
1
+ /* ==========================================================================
2
+ Accessibility — High contrast and reduced transparency overrides
3
+ ========================================================================== */
4
+
5
+ @layer northwestern {
6
+ @media (prefers-contrast: more) {
7
+ :root,
8
+ ::backdrop {
9
+ --nu-border-color: CanvasText;
10
+ --nu-color-text-muted: CanvasText;
11
+ --nu-focus-ring: Highlight;
12
+ --nu-focus-border: Highlight;
13
+ --nu-focus-shadow-color: transparent;
14
+ --nu-hover-bg: Mark;
15
+ }
16
+
17
+ :root[data-theme="light"],
18
+ [data-theme="light"] ::backdrop {
19
+ --nu-border-color: CanvasText;
20
+ --nu-color-text-muted: CanvasText;
21
+ --nu-focus-ring: Highlight;
22
+ }
23
+
24
+ .sl-markdown-content table:not(:where(.not-content *)) > thead > tr > th,
25
+ .sl-markdown-content table:not(:where(.not-content *)) > tbody > tr > td {
26
+ border-width: 2px;
27
+ }
28
+
29
+ .starlight-aside {
30
+ border-width: 2px !important;
31
+ }
32
+
33
+ .sl-markdown-content blockquote:not(:where(.not-content *)) {
34
+ border-inline-start-width: 6px;
35
+ }
36
+
37
+ .sl-badge {
38
+ border-width: 2px;
39
+ }
40
+ }
41
+
42
+ @media (prefers-reduced-transparency: reduce) {
43
+ :root,
44
+ ::backdrop {
45
+ --nu-purple-tint: var(--nu-purple-10);
46
+ --nu-purple-tint-strong: var(--nu-purple-20);
47
+ --nu-purple-surface: #f3f0f7;
48
+ --nu-purple-surface-subtle: #f7f5f9;
49
+ --nu-surface-muted: #f5f5f5;
50
+ --nu-hover-bg: #ebebeb;
51
+ }
52
+
53
+ :root:not([data-theme="light"]),
54
+ :root:not([data-theme="light"]) ::backdrop {
55
+ --nu-purple-surface: #2a2235;
56
+ --nu-purple-surface-subtle: #231d2e;
57
+ --nu-surface-muted: #262626;
58
+ --nu-hover-bg: #333;
59
+ }
60
+
61
+ .sl-markdown-content blockquote:not(:where(.not-content *)) {
62
+ background: var(--nu-purple-surface);
63
+ }
64
+
65
+ :root:not([data-theme="light"]) .sl-markdown-content blockquote:not(:where(.not-content *)) {
66
+ background: var(--nu-purple-surface);
67
+ }
68
+ }
69
+ }
@@ -0,0 +1,37 @@
1
+ /* ==========================================================================
2
+ Blockquotes — Styled quotations with cite/footer support
3
+ ========================================================================== */
4
+
5
+ @layer northwestern {
6
+ .sl-markdown-content blockquote:not(:where(.not-content *)) {
7
+ padding: 1rem 1.5rem;
8
+ margin: 2rem 0;
9
+ color: var(--nu-color-text);
10
+ background: rgb(228 224 238 / 42%);
11
+ border-inline-start: 10px solid var(--nu-purple-100);
12
+ }
13
+
14
+ .sl-markdown-content blockquote:not(:where(.not-content *)) p:last-child {
15
+ margin-bottom: 0;
16
+ }
17
+
18
+ .sl-markdown-content blockquote:not(:where(.not-content *)) cite,
19
+ .sl-markdown-content blockquote:not(:where(.not-content *)) footer {
20
+ display: block;
21
+ margin-top: 0.75rem;
22
+ font-size: var(--sl-text-sm);
23
+ font-style: normal;
24
+ font-weight: 700;
25
+ color: var(--nu-color-link);
26
+ }
27
+
28
+ :root:not([data-theme="light"]) .sl-markdown-content blockquote:not(:where(.not-content *)) {
29
+ background: rgb(48 16 78 / 35%);
30
+ border-inline-start-color: var(--nu-purple-40);
31
+ }
32
+
33
+ :root:not([data-theme="light"]) .sl-markdown-content blockquote:not(:where(.not-content *)) cite,
34
+ :root:not([data-theme="light"]) .sl-markdown-content blockquote:not(:where(.not-content *)) footer {
35
+ color: var(--nu-purple-30);
36
+ }
37
+ }
@@ -35,10 +35,6 @@
35
35
  font-weight: 600;
36
36
  }
37
37
 
38
- .expressive-code .copy button {
39
- border-radius: 0;
40
- }
41
-
42
38
  .expressive-code .frame.has-title:not(.is-terminal) .header {
43
39
  display: flex;
44
40
  align-items: end;
@@ -77,7 +73,6 @@
77
73
  height: 6px;
78
74
  content: "";
79
75
  background-color: #bbb4c7;
80
- border-radius: 999px;
81
76
  box-shadow:
82
77
  10px 0 0 #bbb4c7,
83
78
  20px 0 0 #bbb4c7;
@@ -134,6 +129,10 @@
134
129
  color: #6e6e6e;
135
130
  }
136
131
 
132
+ :root:not([data-theme="light"]) .expressive-code .gutter .ln {
133
+ color: #999;
134
+ }
135
+
137
136
  :root:not([data-theme="light"]) .expressive-code .frame {
138
137
  border-top-color: var(--nu-purple-40);
139
138
  box-shadow: none;
@@ -165,4 +164,8 @@
165
164
  background: rgb(41 41 41 / 95%);
166
165
  border-color: var(--nu-purple-40);
167
166
  }
167
+
168
+ :root:not([data-theme="light"]) .expressive-code .copy button:hover::after {
169
+ background-color: var(--nu-purple-40);
170
+ }
168
171
  }
@@ -0,0 +1,76 @@
1
+ /* ==========================================================================
2
+ Footnotes — GFM footnote references, section, and back-references
3
+ ========================================================================== */
4
+
5
+ @layer northwestern {
6
+ .sl-markdown-content a[data-footnote-ref] {
7
+ font-weight: 600;
8
+ line-height: 1;
9
+ vertical-align: super;
10
+ color: var(--sl-color-accent);
11
+ text-decoration: none;
12
+ }
13
+
14
+ .sl-markdown-content a[data-footnote-ref]::before {
15
+ content: "[";
16
+ }
17
+
18
+ .sl-markdown-content a[data-footnote-ref]::after {
19
+ content: "]";
20
+ }
21
+
22
+ .sl-markdown-content a[data-footnote-ref]:hover {
23
+ text-decoration: underline;
24
+ }
25
+
26
+ /* Footnotes section */
27
+ .sl-markdown-content section[data-footnotes] {
28
+ padding-top: 1.5rem;
29
+ margin-top: 2.5rem;
30
+ font-size: 0.875rem;
31
+ color: var(--nu-color-text-muted);
32
+ border-top: 1px solid var(--nu-border-color);
33
+ }
34
+
35
+ .sl-markdown-content section[data-footnotes] > :first-child:is(hr) {
36
+ display: none;
37
+ }
38
+
39
+ .sl-markdown-content section[data-footnotes] > .sl-heading-wrapper {
40
+ position: absolute;
41
+ width: 1px;
42
+ height: 1px;
43
+ padding: 0;
44
+ margin: -1px;
45
+ overflow: hidden;
46
+ white-space: nowrap;
47
+ border: 0;
48
+ clip-path: inset(50%);
49
+ }
50
+
51
+ .sl-markdown-content section[data-footnotes] ol {
52
+ padding-left: 1.5rem;
53
+ }
54
+
55
+ .sl-markdown-content section[data-footnotes] li {
56
+ padding-left: 0.25rem;
57
+ }
58
+
59
+ .sl-markdown-content section[data-footnotes] li + li {
60
+ margin-top: 0.5rem;
61
+ }
62
+
63
+ .sl-markdown-content section[data-footnotes] a[data-footnote-backref] {
64
+ margin-left: 0.25em;
65
+ font-family: "Segoe UI Symbol", "Noto Sans Symbols 2", sans-serif;
66
+ font-size: 1em;
67
+ font-weight: 700;
68
+ color: var(--sl-color-accent);
69
+ text-decoration: none;
70
+ font-variant-emoji: text;
71
+ }
72
+
73
+ .sl-markdown-content section[data-footnotes] a[data-footnote-backref]:hover {
74
+ border-bottom: 1px solid var(--sl-color-accent);
75
+ }
76
+ }
@@ -0,0 +1,69 @@
1
+ /* ==========================================================================
2
+ Kbd — Keyboard shortcut display
3
+ ========================================================================== */
4
+
5
+ /* stylelint-disable selector-class-pattern */
6
+
7
+ @layer northwestern {
8
+ .nu-kbd {
9
+ display: inline-flex;
10
+ gap: 0.25rem;
11
+ align-items: center;
12
+ font-family: var(--sl-font-system-mono);
13
+ font-size: var(--sl-text-xs);
14
+ line-height: 1;
15
+ white-space: nowrap;
16
+ background: none;
17
+ border: none;
18
+ }
19
+
20
+ .nu-kbd__key {
21
+ display: inline-flex;
22
+ align-items: center;
23
+ justify-content: center;
24
+ min-width: 1.375rem;
25
+ height: 1.375rem;
26
+ padding: 0 0.3125rem;
27
+ font-family: inherit;
28
+ font-size: inherit;
29
+ font-weight: 500;
30
+ line-height: 1;
31
+ color: var(--nu-color-text);
32
+ text-align: center;
33
+ background-color: var(--nu-purple-surface-subtle);
34
+ border: 1px solid rgb(78 42 132 / 18%);
35
+ border-bottom-width: 2px;
36
+ border-radius: 3px !important;
37
+ }
38
+
39
+ :root:not([data-theme="light"]) .nu-kbd__key {
40
+ color: var(--nu-color-text);
41
+ background-color: rgb(255 255 255 / 6%);
42
+ border-color: rgb(255 255 255 / 12%);
43
+ }
44
+
45
+ .nu-kbd__sep {
46
+ font-size: 0.625rem;
47
+ color: var(--nu-color-text-muted);
48
+ user-select: none;
49
+ }
50
+
51
+ .nu-kbd__mac,
52
+ .nu-kbd__other {
53
+ display: inline-flex;
54
+ gap: 0.25rem;
55
+ align-items: center;
56
+ }
57
+
58
+ .nu-kbd__other {
59
+ display: none;
60
+ }
61
+
62
+ :root[data-platform="other"] .nu-kbd__mac {
63
+ display: none;
64
+ }
65
+
66
+ :root[data-platform="other"] .nu-kbd__other {
67
+ display: inline-flex;
68
+ }
69
+ }