jamdesk 1.1.118 → 1.1.120

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jamdesk",
3
- "version": "1.1.118",
3
+ "version": "1.1.120",
4
4
  "description": "CLI for Jamdesk — build, preview, and deploy documentation sites from MDX. Dev server with hot reload, 50+ components, OpenAPI support, AI search, and Mintlify migration",
5
5
  "keywords": [
6
6
  "jamdesk",
@@ -547,10 +547,13 @@ export function Header({ config, layout = 'header-logo', tabsPosition: tabsPosit
547
547
  </>
548
548
  )}
549
549
 
550
- {/* Theme toggle - hidden on mobile since it's in the sidebar menu */}
551
- <div className="hidden lg:block">
552
- <ThemeToggle />
553
- </div>
550
+ {/* Theme toggle - hidden on mobile since it's in the sidebar menu.
551
+ Also hidden when appearance.strict pins users to the configured mode. */}
552
+ {!config.appearance?.strict && (
553
+ <div className="hidden lg:block">
554
+ <ThemeToggle />
555
+ </div>
556
+ )}
554
557
  </div>
555
558
  </div>
556
559
  </header>
@@ -738,7 +738,7 @@ export function Sidebar({ config, layout = 'header-logo', tabsPosition: tabsPosi
738
738
  <DefaultLogoCompact name={config.name} />
739
739
  )}
740
740
  </Link>
741
- <ThemeToggleCycle />
741
+ {!config.appearance?.strict && <ThemeToggleCycle />}
742
742
  </div>
743
743
  <button
744
744
  onClick={onClose}
@@ -770,7 +770,7 @@ export function Sidebar({ config, layout = 'header-logo', tabsPosition: tabsPosi
770
770
  <DefaultLogo name={config.name} showBadge={false} />
771
771
  )}
772
772
  </Link>
773
- <ThemeToggle />
773
+ {!config.appearance?.strict && <ThemeToggle />}
774
774
  </div>
775
775
  )}
776
776
 
@@ -0,0 +1,104 @@
1
+ import type { CloneError } from '../../github.js';
2
+ import type { ErrorDetails } from '../../shared/types.js';
3
+
4
+ /**
5
+ * Format a CloneError into user-facing ErrorDetails.
6
+ *
7
+ * Pure function — no side effects, no regex. The `kind` is set at the source
8
+ * (cloneRepo's catch block in github.ts); this function only renders.
9
+ *
10
+ * Headlines (`message`) are kept under 140 characters because they're
11
+ * forwarded to GitHub commit-status descriptions, which GitHub truncates.
12
+ */
13
+ export function formatCloneError(err: CloneError): ErrorDetails {
14
+ const HEADLINE_BUDGET = 140;
15
+ const fitInHeadline = (s: string): string =>
16
+ s.length <= HEADLINE_BUDGET ? s : s.slice(0, HEADLINE_BUDGET - 1) + '…';
17
+
18
+ const repoFull = err.repoFullName;
19
+ const repoQuoted = repoFull ? `'${repoFull}'` : "the repository";
20
+ const repoText = repoFull ?? 'the repository';
21
+
22
+ switch (err.kind) {
23
+ case 'branch_not_found': {
24
+ const branch = err.branch ?? 'unknown';
25
+ const branchQuoted = `'${branch}'`;
26
+ return {
27
+ type: 'clone_failed',
28
+ message: fitInHeadline(`Branch ${branchQuoted} not found in ${repoText}`),
29
+ details:
30
+ `We tried to clone the ${branchQuoted} branch from ${repoQuoted}, ` +
31
+ `but it does not exist on GitHub.`,
32
+ suggestion:
33
+ `Either:\n` +
34
+ `• Push a ${branchQuoted} branch to ${repoQuoted}, or\n` +
35
+ `• Update the configured branch in your project's GitHub connection ` +
36
+ `settings to one that exists (e.g. main).`,
37
+ };
38
+ }
39
+
40
+ case 'repo_not_found': {
41
+ return {
42
+ type: 'clone_failed',
43
+ message: fitInHeadline(
44
+ repoFull
45
+ ? `Repository ${repoFull} not found or not accessible`
46
+ : 'Repository not found or not accessible',
47
+ ),
48
+ details:
49
+ `GitHub returned "Repository not found" when we tried to clone ${repoQuoted}. ` +
50
+ `The Jamdesk GitHub App cannot see this repository — either it was not ` +
51
+ `selected during installation, the installation was uninstalled, or the ` +
52
+ `repository was renamed or deleted.`,
53
+ suggestion:
54
+ 'Open your project settings and click "Reconnect GitHub". ' +
55
+ `Make sure ${repoQuoted} is selected in the Jamdesk GitHub App installation.`,
56
+ };
57
+ }
58
+
59
+ case 'auth_failed': {
60
+ return {
61
+ type: 'clone_failed',
62
+ message: fitInHeadline('GitHub authentication failed'),
63
+ details:
64
+ `The GitHub App token was rejected when cloning ${repoQuoted}. ` +
65
+ `The installation may have been suspended, revoked, or had its ` +
66
+ `permissions changed.`,
67
+ suggestion:
68
+ 'Open your project settings and click "Reconnect GitHub" to reinstall ' +
69
+ 'the Jamdesk GitHub App, then re-trigger the build.',
70
+ };
71
+ }
72
+
73
+ case 'network_error': {
74
+ return {
75
+ type: 'clone_failed',
76
+ message: fitInHeadline(`Network error cloning ${repoText}`),
77
+ details:
78
+ `git could not reach GitHub when cloning ${repoQuoted}. This is ` +
79
+ `usually transient — DNS, TLS, or GitHub's git endpoint having a ` +
80
+ `bad moment — but a persistent firewall or proxy change can also ` +
81
+ `cause it.`,
82
+ suggestion:
83
+ 'Re-trigger the build. If it keeps failing:\n' +
84
+ '• Check https://www.githubstatus.com for ongoing incidents\n' +
85
+ '• Confirm any custom egress proxy/firewall still allows ' +
86
+ 'github.com:443',
87
+ };
88
+ }
89
+
90
+ case 'unknown':
91
+ default: {
92
+ return {
93
+ type: 'clone_failed',
94
+ message: fitInHeadline('Failed to clone repository'),
95
+ details: `Could not clone ${repoText}.`,
96
+ suggestion:
97
+ 'Check that:\n' +
98
+ `• ${repoText} exists and is accessible\n` +
99
+ '• The Jamdesk GitHub App has access to the repository\n' +
100
+ '• The branch name is correct',
101
+ };
102
+ }
103
+ }
104
+ }
@@ -103,9 +103,13 @@ export function parseErrorDetails(
103
103
  output: string,
104
104
  message: string,
105
105
  phase: string,
106
- opts: { pageToFileMap?: Record<string, string>; discoveryHint?: string } = {},
106
+ opts: {
107
+ pageToFileMap?: Record<string, string>;
108
+ discoveryHint?: string;
109
+ repoFullName?: string;
110
+ } = {},
107
111
  ): ErrorDetails {
108
- const { pageToFileMap, discoveryHint } = opts;
112
+ const { pageToFileMap, discoveryHint, repoFullName } = opts;
109
113
  const lowerOutput = output.toLowerCase();
110
114
 
111
115
  // Extract error source information upfront - used by multiple error types
@@ -703,15 +707,20 @@ export function parseErrorDetails(
703
707
  };
704
708
  }
705
709
 
706
- // Clone failure
707
- if (phase === 'clone' || lowerOutput.includes('clone')) {
710
+ // Clone failure — defense-in-depth fallback. The primary path is
711
+ // cloneRepo throwing CloneError, formatted by formatCloneError. This
712
+ // branch only fires if a plain Error somehow reaches the parser during
713
+ // the clone phase. Gate strictly on phase to avoid misclassifying any
714
+ // unrelated build error whose stderr mentions the word "clone".
715
+ if (phase === 'clone') {
716
+ const repoText = repoFullName ?? 'the repository';
708
717
  return {
709
718
  type: 'clone_failed',
710
719
  message: 'Failed to clone repository',
711
- details: 'Could not clone your GitHub repository.',
720
+ details: `Could not clone ${repoText}.`,
712
721
  suggestion:
713
722
  'Check that:\n' +
714
- '• The repository exists and is accessible\n' +
723
+ `• ${repoText} exists and is accessible\n` +
715
724
  '• The Jamdesk GitHub App has access to the repository\n' +
716
725
  '• The branch name is correct',
717
726
  };
@@ -0,0 +1,41 @@
1
+ import { CloneError } from '../../github.js';
2
+ import type { ErrorDetails } from '../../shared/types.js';
3
+ import { formatCloneError } from './clone-error-format.js';
4
+ import { parseErrorDetails } from './error-parser.js';
5
+
6
+ export interface ErrorRoutingOpts {
7
+ combinedOutput: string;
8
+ phase: string;
9
+ repoFullName?: string;
10
+ pageToFileMap?: Record<string, string>;
11
+ }
12
+
13
+ /**
14
+ * Route a caught build error to the appropriate ErrorDetails formatter.
15
+ *
16
+ * - `CloneError` → `formatCloneError` (source-classified, type-safe)
17
+ * - anything else → `parseErrorDetails` (regex-based, legacy)
18
+ *
19
+ * Centralized here so the routing rule is testable in isolation and
20
+ * build.ts's catch block stays short.
21
+ */
22
+ export function errorDetailsFromCaught(
23
+ error: Error,
24
+ errorRef: string,
25
+ opts: ErrorRoutingOpts,
26
+ ): ErrorDetails {
27
+ if (error instanceof CloneError) {
28
+ return { ...formatCloneError(error), errorRef };
29
+ }
30
+ const parsed = parseErrorDetails(
31
+ opts.combinedOutput,
32
+ error.message,
33
+ opts.phase,
34
+ {
35
+ pageToFileMap: opts.pageToFileMap,
36
+ discoveryHint: (error as Error & { discoveryHint?: string }).discoveryHint,
37
+ repoFullName: opts.repoFullName,
38
+ },
39
+ );
40
+ return { ...parsed, errorRef };
41
+ }
@@ -548,11 +548,20 @@ export type MintlifyLayout = 'sidenav' | 'topnav';
548
548
  /**
549
549
  * Background configuration
550
550
  *
551
- * `decoration: "none"` disables the theme's default decoration — on Jam this
552
- * removes the light-mode radial gradient. `color` overrides the theme's
553
- * `--color-bg-primary` in light and dark mode. `gradient` tunes the Jam
554
- * gradient (color, size, position, opacity); ignored when `decoration` is
555
- * anything other than `gradient`, or on themes without a default gradient.
551
+ * `decoration` chooses one of four background treatments:
552
+ * - `"gradient"` (default for Jam) — radial gradient, tunable via `gradient`.
553
+ * - `"grid"` subtle dot grid pattern.
554
+ * - `"windows"` Windows 11-style frosted radial spots.
555
+ * - `"none"` flat `--color-bg-primary` fill, no decoration.
556
+ *
557
+ * When `decoration` is unset, no `data-decoration` attribute is rendered — the
558
+ * gate selector `:not([data-decoration="none"])` treats absence as the default
559
+ * (gradient) case.
560
+ *
561
+ * `color` overrides the theme's `--color-bg-primary` in light and dark mode.
562
+ * `gradient` tunes the Jam gradient (color, size, position, opacity); it is
563
+ * ignored when `decoration` is anything other than `"gradient"` (or unset), or
564
+ * on themes without a default gradient.
556
565
  */
557
566
  export interface BackgroundConfig {
558
567
  image?: string | { light: string; dark: string };
@@ -59,9 +59,46 @@ const styles = {
59
59
  paddingTop: '12px',
60
60
  borderTop: '1px solid #FECACA',
61
61
  },
62
+ suggestionContinuation: {
63
+ color: colors.textSecondary,
64
+ fontSize: '13px',
65
+ fontStyle: 'italic' as const,
66
+ margin: '4px 0 0 0',
67
+ },
68
+ // Used when the previous source line was blank — gives a visible paragraph
69
+ // break (e.g. between bullets and the "If you need help…" reference line).
70
+ suggestionParagraphBreak: {
71
+ color: colors.textSecondary,
72
+ fontSize: '13px',
73
+ fontStyle: 'italic' as const,
74
+ margin: '14px 0 0 0',
75
+ },
62
76
  };
63
77
 
64
78
  export function ErrorBox({ error, errorRef, errorType, errorSuggestion }: ErrorBoxProps) {
79
+ // Split on `\n` so multi-line suggestions render as a real list. A single
80
+ // <Text> (which becomes <p>) collapses whitespace, so "Either:\n• one\n• two"
81
+ // would otherwise reach the reader as a wall of prose. Blank source lines
82
+ // (from "...\n\nIf you need help...") translate to a visible paragraph
83
+ // break — the next non-empty line gets a larger top margin.
84
+ const suggestionLines: Array<{ text: string; afterBlank: boolean }> = [];
85
+ if (errorSuggestion) {
86
+ let pendingBlank = false;
87
+ for (const raw of errorSuggestion.split('\n')) {
88
+ if (raw.trim().length === 0) {
89
+ pendingBlank = true;
90
+ continue;
91
+ }
92
+ suggestionLines.push({ text: raw, afterBlank: pendingBlank });
93
+ pendingBlank = false;
94
+ }
95
+ }
96
+
97
+ const styleFor = (i: number, afterBlank: boolean) => {
98
+ if (i === 0) return styles.suggestion;
99
+ return afterBlank ? styles.suggestionParagraphBreak : styles.suggestionContinuation;
100
+ };
101
+
65
102
  return (
66
103
  <Section style={styles.container} className="email-error-box">
67
104
  <Text style={styles.errorLabel} className="email-error-title">Error</Text>
@@ -75,11 +112,15 @@ export function ErrorBox({ error, errorRef, errorType, errorSuggestion }: ErrorB
75
112
  <Text style={styles.errorType} className="email-paragraph">Type: {errorType}</Text>
76
113
  )}
77
114
 
78
- {errorSuggestion && (
79
- <Text style={styles.suggestion} className="email-paragraph">
80
- 💡 {errorSuggestion}
115
+ {suggestionLines.map(({ text, afterBlank }, i) => (
116
+ <Text
117
+ key={i}
118
+ style={styleFor(i, afterBlank)}
119
+ className="email-paragraph"
120
+ >
121
+ {i === 0 ? `💡 ${text}` : text}
81
122
  </Text>
82
- )}
123
+ ))}
83
124
  </Section>
84
125
  );
85
126
  }
@@ -177,20 +177,26 @@ const BG_POSITION_ALLOWED = new Set([
177
177
  * to the Jam theme CSS. Returns `null` when nothing is configured or only
178
178
  * invalid values were provided.
179
179
  *
180
- * `decoration: "none"` is NOT rendered here — it's wired through the body's
181
- * `data-decoration` attribute so the gradient rules can opt out via :not().
180
+ * `decoration` is NOT emitted as CSS here — it's wired through the body's
181
+ * `data-decoration` attribute (see DocsChrome below) so the theme CSS can
182
+ * select on it via `body[data-decoration="..."]` rules.
183
+ *
184
+ * Gradient vars are only emitted when `decoration` is unset or `"gradient"`.
185
+ * For `"grid"`, `"windows"`, and `"none"` the gradient is dead-code in CSS,
186
+ * so the vars are suppressed (and a dev-only warning fires if the caller
187
+ * passed a gradient block alongside one of those decorations).
182
188
  */
183
189
  export function generateBackgroundVariables(
184
190
  background: BackgroundConfig | undefined,
185
191
  ): string | null {
186
192
  if (!background) return null;
187
193
 
188
- // When the gradient is opted out via decoration: "none", suppress all
189
- // gradient-related CSS vars so they don't leak into other rules. The
190
- // body[data-decoration="none"] selector already gates the Jam gradient
191
- // off, but emitting orphan `--jd-gradient-*` vars on :root would let any
192
- // future descendant rule pick them up. Keep :root clean.
193
- const gradientDisabled = background.decoration === 'none';
194
+ // Suppress gradient vars unless decoration is unset or explicitly "gradient".
195
+ // The Jam CSS only consumes --jd-gradient-* under the default-gradient gate
196
+ // (variables.css:190/214). Emitting them under grid/windows/none would leak
197
+ // orphan vars onto :root that any future descendant rule could pick up.
198
+ const gradientDisabled =
199
+ background.decoration !== undefined && background.decoration !== 'gradient';
194
200
 
195
201
  // typeof guards defend the .trim() calls against callers that bypass Ajv
196
202
  // (dashboard live-preview, hand-written configs). Contract is silent-drop.
@@ -238,8 +244,9 @@ export function generateBackgroundVariables(
238
244
  const darkLines: string[] = [];
239
245
  if (validDark) darkLines.push(` --color-bg-primary: ${validDark};`);
240
246
 
241
- // Dev-only warning when both decoration:"none" and gradient:{...} are set —
242
- // the gradient is silently ignored, which would confuse the customer.
247
+ // Dev-only warning when a non-gradient decoration is combined with a
248
+ // gradient:{...} block — the gradient is silently ignored, which would
249
+ // confuse the customer (their tuning has no visible effect).
243
250
  if (
244
251
  gradientDisabled &&
245
252
  background.gradient &&
@@ -247,7 +254,7 @@ export function generateBackgroundVariables(
247
254
  process.env.NODE_ENV === 'development'
248
255
  ) {
249
256
  console.warn(
250
- '[bg-config] background.gradient is ignored because background.decoration is "none". Set decoration to "gradient" (or omit) to apply gradient tuning.',
257
+ `[bg-config] background.gradient is ignored because background.decoration is "${background.decoration}". Set decoration to "gradient" (or omit) to apply gradient tuning.`,
251
258
  );
252
259
  }
253
260
 
@@ -393,10 +400,11 @@ export async function DocsChrome({
393
400
  config.colors?.dark,
394
401
  );
395
402
  const backgroundVariables = generateBackgroundVariables(config.background);
396
- // 'gradient' | 'grid' | 'windows' | 'none' | undefined. Only "none" currently
397
- // alters rendering (gates off Jam's light-mode gradient + chrome transparency).
398
- // The other values are stored on the body as data-decoration="<value>" so future
399
- // CSS rules can target them, but no rule consumes them yet.
403
+ // 'gradient' | 'grid' | 'windows' | 'none' | undefined. Wired through to the
404
+ // body's `data-decoration` attribute; the Jam theme CSS selects on each value
405
+ // (see variables.css for the rule blocks). Unset / "gradient" both render the
406
+ // default radial gradient; "none" is a solid fill; "grid" and "windows" paint
407
+ // their respective patterns.
400
408
  const decoration = config.background?.decoration;
401
409
 
402
410
  const appearanceDefault = config.appearance?.default || 'system';
@@ -274,7 +274,7 @@
274
274
  "windows",
275
275
  "none"
276
276
  ],
277
- "description": "Background decoration style. Currently implemented: `gradient` (the default for Jam) and `none` (disables Jam's light-mode radial gradient). `grid` and `windows` are reserved for future Mintlify-compatible decorations and currently render as the theme default (no warning is emitted) do not rely on them yet."
277
+ "description": "Background decoration style. `gradient` (default for Jam) renders a radial gradient. `grid` renders a subtle dot grid. `windows` renders Windows 11-style frosted spots. `none` disables the theme's decoration, falling back to a flat --color-bg-primary background."
278
278
  },
279
279
  "color": {
280
280
  "$ref": "#/definitions/colorPairSchema",
@@ -311,7 +311,7 @@
311
311
  }
312
312
  },
313
313
  "additionalProperties": false,
314
- "description": "Customize the theme's background gradient. Currently applies only to the Jam theme (light mode). Ignored when `decoration` is `none`"
314
+ "description": "Customize the theme's background gradient. Currently applies only to the Jam theme (light mode). Ignored unless `decoration` is `gradient` (or unset)"
315
315
  }
316
316
  },
317
317
  "additionalProperties": false,
@@ -187,7 +187,7 @@
187
187
  * Firefox 113+, Safari 16.2+). Older browsers fall back to the static rgba
188
188
  * gradient block below — they see the original look but cannot customize it.
189
189
  */
190
- html:not(.dark) body[data-theme="jam"]:not([data-decoration="none"]) {
190
+ html:not(.dark) body[data-theme="jam"]:not([data-decoration="none"]):not([data-decoration="grid"]):not([data-decoration="windows"]) {
191
191
  /* Override solid background-color from base.css */
192
192
  background-color: transparent;
193
193
  /* Fallback gradient (static, for browsers without color-mix). Older browsers
@@ -211,7 +211,7 @@ html:not(.dark) body[data-theme="jam"]:not([data-decoration="none"]) {
211
211
  Pre-computing in TS avoids fractional-percentage edge cases in older Blink
212
212
  (e.g., `calc(0.12 * 66.67%)` → 8.0004% can be clamped). */
213
213
  @supports (background: color-mix(in srgb, red, transparent)) {
214
- html:not(.dark) body[data-theme="jam"]:not([data-decoration="none"]) {
214
+ html:not(.dark) body[data-theme="jam"]:not([data-decoration="none"]):not([data-decoration="grid"]):not([data-decoration="windows"]) {
215
215
  background:
216
216
  radial-gradient(
217
217
  circle var(--jd-gradient-size, 500px) at var(--jd-gradient-position, top center),
@@ -226,31 +226,34 @@ html:not(.dark) body[data-theme="jam"]:not([data-decoration="none"]) {
226
226
  }
227
227
  }
228
228
 
229
- /* Make main content / wrappers / chrome transparent in light mode SO the gradient shows through.
230
- Only applies when the gradient is active (gated on :not([data-decoration="none"])). */
231
- html:not(.dark) body[data-theme="jam"]:not([data-decoration="none"]) main {
229
+ /* Make main content / wrappers / chrome transparent in light mode SO the body
230
+ decoration shows through. Applies to the default gradient AND the windows
231
+ frosted-spots decoration (whose corner spots are part of the chrome look),
232
+ but NOT to grid (the dot pattern behind sidebar/header hurts readability)
233
+ and NOT to "none" (no decoration to expose). */
234
+ html:not(.dark) body[data-theme="jam"]:not([data-decoration="none"]):not([data-decoration="grid"]) main {
232
235
  background-color: transparent !important;
233
236
  }
234
237
 
235
- html:not(.dark) body[data-theme="jam"]:not([data-decoration="none"]) > div > div.flex:not([role="dialog"]):not(:has([data-chat-panel])) {
238
+ html:not(.dark) body[data-theme="jam"]:not([data-decoration="none"]):not([data-decoration="grid"]) > div > div.flex:not([role="dialog"]):not(:has([data-chat-panel])) {
236
239
  background-color: transparent !important;
237
240
  }
238
241
 
239
- html:not(.dark) body[data-theme="jam"]:not([data-decoration="none"]) > div > div.flex > main {
242
+ html:not(.dark) body[data-theme="jam"]:not([data-decoration="none"]):not([data-decoration="grid"]) > div > div.flex > main {
240
243
  background-color: transparent !important;
241
244
  }
242
245
 
243
- html:not(.dark) body[data-theme="jam"]:not([data-decoration="none"]) div.flex-1.lg\:ml-\[300px\] {
246
+ html:not(.dark) body[data-theme="jam"]:not([data-decoration="none"]):not([data-decoration="grid"]) div.flex-1.lg\:ml-\[300px\] {
244
247
  background-color: transparent !important;
245
248
  }
246
249
 
247
- html:not(.dark) body[data-theme="jam"]:not([data-decoration="none"]) header {
250
+ html:not(.dark) body[data-theme="jam"]:not([data-decoration="none"]):not([data-decoration="grid"]) header {
248
251
  background-color: transparent !important;
249
252
  background: transparent !important;
250
253
  }
251
254
 
252
255
  @media (min-width: 1024px) {
253
- html:not(.dark) body[data-theme="jam"]:not([data-decoration="none"]) aside {
256
+ html:not(.dark) body[data-theme="jam"]:not([data-decoration="none"]):not([data-decoration="grid"]) aside {
254
257
  background-color: transparent !important;
255
258
  background: transparent !important;
256
259
  }
@@ -264,22 +267,96 @@ html:not(.dark) body[data-theme="jam"][data-decoration="none"] {
264
267
  background: var(--color-bg-primary, #ffffff);
265
268
  }
266
269
 
270
+ /* ===== GRID DECORATION (decoration: "grid") =====
271
+ Subtle dot grid layered over --color-bg-primary. 1px dots at 24px spacing,
272
+ theme primary color at 8% opacity. Dark-mode keeps solid bg (handled by
273
+ base.css; no rule needed here). */
274
+ html:not(.dark) body[data-theme="jam"][data-decoration="grid"] {
275
+ background-color: var(--color-bg-primary, #ffffff);
276
+ /* 1.5px radius (= 3 device-pixels on 2x Retina) prevents the dot from
277
+ resolving to fuzzy sub-pixel anti-aliasing. The hard transparent stop at
278
+ 1.5px keeps the edge crisp on 1x and 2x. */
279
+ /* Static rgba fallback for browsers without color-mix() */
280
+ background-image: radial-gradient(
281
+ circle 1.5px at center,
282
+ rgba(34, 102, 194, 0.08) 0 1.5px,
283
+ transparent 1.5px
284
+ );
285
+ background-size: 24px 24px;
286
+ background-repeat: repeat;
287
+ }
288
+
289
+ @supports (background: color-mix(in srgb, red, transparent)) {
290
+ html:not(.dark) body[data-theme="jam"][data-decoration="grid"] {
291
+ background-image: radial-gradient(
292
+ circle 1.5px at center,
293
+ color-mix(in srgb, var(--color-primary, #2266C2) 8%, transparent) 0 1.5px,
294
+ transparent 1.5px
295
+ );
296
+ }
297
+ }
298
+
299
+ /* ===== WINDOWS DECORATION (decoration: "windows") =====
300
+ Windows 11-style frosted look: two soft radial spots in the upper-left and
301
+ upper-right, fading over --color-bg-primary. Light-mode only; dark inherits
302
+ solid bg from base.css. */
303
+ html:not(.dark) body[data-theme="jam"][data-decoration="windows"] {
304
+ /* Static rgba fallback. `background:` shorthand resets all background-*
305
+ sub-properties before applying the layered gradients; the trailing
306
+ var(--color-bg-primary) layer re-sets background-color, so no separate
307
+ declaration is needed. */
308
+ background:
309
+ radial-gradient(circle 600px at 15% 0%, rgba(34, 102, 194, 0.14) 0%, transparent 60%),
310
+ radial-gradient(circle 600px at 85% 0%, rgba(34, 102, 194, 0.10) 0%, transparent 60%),
311
+ var(--color-bg-primary, #ffffff);
312
+ background-repeat: no-repeat;
313
+ /* `fixed` keeps the spots viewport-anchored on scroll (matches the
314
+ default-gradient behavior). Without it the spots scroll out of view
315
+ after the first viewport, leaving the rest of a long page flat. */
316
+ background-attachment: fixed;
317
+ }
318
+
319
+ @supports (background: color-mix(in srgb, red, transparent)) {
320
+ html:not(.dark) body[data-theme="jam"][data-decoration="windows"] {
321
+ background:
322
+ radial-gradient(
323
+ circle 600px at 15% 0%,
324
+ color-mix(in srgb, var(--color-primary, #2266C2) 14%, transparent) 0%,
325
+ transparent 60%
326
+ ),
327
+ radial-gradient(
328
+ circle 600px at 85% 0%,
329
+ color-mix(in srgb, var(--color-primary, #2266C2) 10%, transparent) 0%,
330
+ transparent 60%
331
+ ),
332
+ var(--color-bg-primary, #ffffff);
333
+ /* The `background:` shorthand above resets repeat + attachment. Re-set
334
+ them so the modern path matches the fallback's behavior. */
335
+ background-repeat: no-repeat;
336
+ background-attachment: fixed;
337
+ }
338
+ }
339
+
267
340
  /* ===== SCROLL-BASED GRADIENT AND HEADER CHANGES ===== */
268
- html:not(.dark) body[data-theme="jam"]:not([data-decoration="none"]) {
341
+ html:not(.dark) body[data-theme="jam"]:not([data-decoration="none"]):not([data-decoration="grid"]):not([data-decoration="windows"]) {
269
342
  transition: background 0.3s ease-out;
270
343
  }
271
344
 
272
- html:not(.dark) body[data-theme="jam"]:not([data-decoration="none"]).scrolled {
345
+ html:not(.dark) body[data-theme="jam"]:not([data-decoration="none"]):not([data-decoration="grid"]):not([data-decoration="windows"]).scrolled {
273
346
  background: var(--color-bg-primary, #ffffff) !important;
274
347
  }
275
348
 
276
- html:not(.dark) body[data-theme="jam"]:not([data-decoration="none"]).scrolled header {
349
+ /* Header scroll-fade rules — symmetric with the body rules above. Skip the
350
+ pattern decorations (grid, windows) so their chrome behavior (opaque for
351
+ grid, transparent-over-fixed-spots for windows) doesn't get clobbered on
352
+ scroll. */
353
+ html:not(.dark) body[data-theme="jam"]:not([data-decoration="none"]):not([data-decoration="grid"]):not([data-decoration="windows"]).scrolled header {
277
354
  background-color: var(--color-bg-primary) !important;
278
355
  background: var(--color-bg-primary) !important;
279
356
  transition: background 0.3s ease-out;
280
357
  }
281
358
 
282
- html:not(.dark) body[data-theme="jam"]:not([data-decoration="none"]) header {
359
+ html:not(.dark) body[data-theme="jam"]:not([data-decoration="none"]):not([data-decoration="grid"]):not([data-decoration="windows"]) header {
283
360
  transition: background 0.3s ease-out;
284
361
  }
285
362
 
@@ -287,7 +364,7 @@ html:not(.dark) body[data-theme="jam"]:not([data-decoration="none"]) header {
287
364
 
288
365
  /* Prose body */
289
366
  body[data-theme="jam"] .prose {
290
- font-family: 'Inter', sans-serif;
367
+ font-family: var(--font-sans, 'Inter'), sans-serif;
291
368
  font-size: var(--prose-body-size);
292
369
  line-height: var(--prose-body-line-height);
293
370
  font-weight: var(--prose-body-weight);
@@ -297,6 +374,7 @@ body[data-theme="jam"] .prose {
297
374
  /* Prose headings */
298
375
  body[data-theme="jam"] .prose h1,
299
376
  body[data-theme="jam"] h1 {
377
+ font-family: var(--font-heading, var(--font-sans, 'Inter')), sans-serif;
300
378
  font-size: var(--prose-h1-size);
301
379
  line-height: var(--prose-h1-line-height);
302
380
  font-weight: var(--prose-h1-weight);
@@ -305,6 +383,7 @@ body[data-theme="jam"] h1 {
305
383
  }
306
384
 
307
385
  body[data-theme="jam"] .prose h2 {
386
+ font-family: var(--font-heading, var(--font-sans, 'Inter')), sans-serif;
308
387
  font-size: var(--prose-h2-size);
309
388
  line-height: var(--prose-h2-line-height);
310
389
  font-weight: var(--prose-h2-weight);
@@ -313,6 +392,7 @@ body[data-theme="jam"] .prose h2 {
313
392
  }
314
393
 
315
394
  body[data-theme="jam"] .prose h3 {
395
+ font-family: var(--font-heading, var(--font-sans, 'Inter')), sans-serif;
316
396
  font-size: var(--prose-h3-size);
317
397
  line-height: var(--prose-h3-line-height);
318
398
  font-weight: var(--prose-h3-weight);
@@ -321,6 +401,7 @@ body[data-theme="jam"] .prose h3 {
321
401
  }
322
402
 
323
403
  body[data-theme="jam"] .prose h4 {
404
+ font-family: var(--font-heading, var(--font-sans, 'Inter')), sans-serif;
324
405
  font-size: var(--prose-h4-size);
325
406
  line-height: var(--prose-h4-line-height);
326
407
  font-weight: var(--prose-h4-weight);
@@ -66,13 +66,20 @@
66
66
  /* ===== NEBULA THEME TYPOGRAPHY ===== */
67
67
 
68
68
  .prose {
69
- font-family: 'IBM Plex Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
69
+ font-family: var(--font-sans, 'IBM Plex Mono'), ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
70
70
  }
71
71
 
72
72
  .prose code:not([class*="language-"]) {
73
73
  font-family: 'IBM Plex Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
74
74
  }
75
75
 
76
+ .prose h1,
77
+ .prose h2,
78
+ .prose h3,
79
+ .prose h4 {
80
+ font-family: var(--font-heading, var(--font-sans, 'IBM Plex Mono')), ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
81
+ }
82
+
76
83
  .dark {
77
84
  /* Dark theme colors - warm dark background */
78
85
  --color-bg-primary: #151413;
@@ -175,7 +175,7 @@
175
175
 
176
176
  /* Prose body */
177
177
  .prose {
178
- font-family: 'Inter', sans-serif;
178
+ font-family: var(--font-sans, 'Inter'), sans-serif;
179
179
  font-size: var(--prose-body-size);
180
180
  line-height: var(--prose-body-line-height);
181
181
  font-weight: var(--prose-body-weight);
@@ -185,6 +185,7 @@
185
185
  /* Prose headings */
186
186
  .prose h1,
187
187
  h1 {
188
+ font-family: var(--font-heading, var(--font-sans, 'Inter')), sans-serif;
188
189
  font-size: var(--prose-h1-size);
189
190
  line-height: var(--prose-h1-line-height);
190
191
  font-weight: var(--prose-h1-weight);
@@ -193,6 +194,7 @@ h1 {
193
194
  }
194
195
 
195
196
  .prose h2 {
197
+ font-family: var(--font-heading, var(--font-sans, 'Inter')), sans-serif;
196
198
  font-size: var(--prose-h2-size);
197
199
  line-height: var(--prose-h2-line-height);
198
200
  font-weight: var(--prose-h2-weight);
@@ -201,6 +203,7 @@ h1 {
201
203
  }
202
204
 
203
205
  .prose h3 {
206
+ font-family: var(--font-heading, var(--font-sans, 'Inter')), sans-serif;
204
207
  font-size: var(--prose-h3-size);
205
208
  line-height: var(--prose-h3-line-height);
206
209
  font-weight: var(--prose-h3-weight);
@@ -209,6 +212,7 @@ h1 {
209
212
  }
210
213
 
211
214
  .prose h4 {
215
+ font-family: var(--font-heading, var(--font-sans, 'Inter')), sans-serif;
212
216
  font-size: var(--prose-h4-size);
213
217
  line-height: var(--prose-h4-line-height);
214
218
  font-weight: var(--prose-h4-weight);