jamdesk 1.1.212 → 1.1.214

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 (59) hide show
  1. package/dist/__tests__/unit/docs-config.test.js +26 -0
  2. package/dist/__tests__/unit/docs-config.test.js.map +1 -1
  3. package/dist/__tests__/unit/dusk-embargo.test.d.ts +2 -0
  4. package/dist/__tests__/unit/dusk-embargo.test.d.ts.map +1 -0
  5. package/dist/__tests__/unit/dusk-embargo.test.js +25 -0
  6. package/dist/__tests__/unit/dusk-embargo.test.js.map +1 -0
  7. package/dist/__tests__/unit/migrate-resolve-theme.test.js +11 -0
  8. package/dist/__tests__/unit/migrate-resolve-theme.test.js.map +1 -1
  9. package/dist/commands/migrate/index.d.ts.map +1 -1
  10. package/dist/commands/migrate/index.js +7 -3
  11. package/dist/commands/migrate/index.js.map +1 -1
  12. package/dist/commands/migrate/types.d.ts +4 -1
  13. package/dist/commands/migrate/types.d.ts.map +1 -1
  14. package/dist/commands/migrate/types.js +13 -1
  15. package/dist/commands/migrate/types.js.map +1 -1
  16. package/dist/index.js +8 -2
  17. package/dist/index.js.map +1 -1
  18. package/dist/lib/deps.js +1 -1
  19. package/dist/lib/docs-config.d.ts +1 -1
  20. package/dist/lib/docs-config.d.ts.map +1 -1
  21. package/dist/lib/docs-config.js +1 -1
  22. package/dist/lib/docs-config.js.map +1 -1
  23. package/dist/lib/dusk-embargo.d.ts +15 -0
  24. package/dist/lib/dusk-embargo.d.ts.map +1 -0
  25. package/dist/lib/dusk-embargo.js +15 -0
  26. package/dist/lib/dusk-embargo.js.map +1 -0
  27. package/dist/lib/normalize-config.js +1 -1
  28. package/dist/lib/normalize-config.js.map +1 -1
  29. package/package.json +3 -3
  30. package/vendored/app/(unlock)/jd/unlock/page.tsx +2 -2
  31. package/vendored/app/api/markdown-export/[project]/[...slug]/route.ts +31 -1
  32. package/vendored/components/mdx/Tooltip.tsx +27 -1
  33. package/vendored/components/navigation/DefaultLogo.tsx +25 -18
  34. package/vendored/components/navigation/Header.tsx +78 -29
  35. package/vendored/components/navigation/Sidebar.tsx +5 -3
  36. package/vendored/components/navigation/SocialFooter.tsx +5 -6
  37. package/vendored/components/navigation/ThemePreviewChips.tsx +3 -2
  38. package/vendored/lib/branding-url.ts +9 -0
  39. package/vendored/lib/build/error-parser.ts +1 -1
  40. package/vendored/lib/docs-types.ts +5 -1
  41. package/vendored/lib/dusk-embargo.ts +16 -0
  42. package/vendored/lib/email-notifier.ts +6 -8
  43. package/vendored/lib/extract-highlights.ts +1 -1
  44. package/vendored/lib/layout-helpers.tsx +19 -2
  45. package/vendored/lib/mailfully.ts +86 -0
  46. package/vendored/lib/markdown-absolute-links.ts +197 -0
  47. package/vendored/lib/middleware-helpers.ts +24 -0
  48. package/vendored/lib/normalize-config.ts +1 -1
  49. package/vendored/lib/r2-cleanup.ts +14 -10
  50. package/vendored/lib/screenshot-capture.ts +88 -16
  51. package/vendored/lib/static-artifacts.ts +92 -31
  52. package/vendored/lib/static-file-route.ts +25 -8
  53. package/vendored/lib/theme-preview.ts +16 -8
  54. package/vendored/lib/validate-config.ts +4 -4
  55. package/vendored/schema/docs-schema.json +124 -0
  56. package/vendored/themes/dusk/variables.css +476 -0
  57. package/vendored/themes/index.ts +28 -0
  58. package/vendored/themes/types.ts +8 -0
  59. package/vendored/workspace-package-lock.json +187 -164
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Dusk launch embargo — flip to `true` on October 1, 2026.
3
+ *
4
+ * One of four twins (build-service, CLI, dashboard, marketing); separate apps
5
+ * with separate deploys, so they cannot share a module. Flip all four in one
6
+ * commit. See docs/plans/2026-09-23-dusk-october-1-release-plan.md.
7
+ *
8
+ * Here it gates the `jamdesk migrate` theme prompt and the `--theme` help.
9
+ * Validation is NOT gated.
10
+ *
11
+ * The `: boolean` annotation is LOAD-BEARING: without it TS narrows to the
12
+ * literal `false` and stops type-checking the launch branch.
13
+ */
14
+ export const DUSK_PUBLIC = false;
15
+ //# sourceMappingURL=dusk-embargo.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"dusk-embargo.js","sourceRoot":"","sources":["../../src/lib/dusk-embargo.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,MAAM,CAAC,MAAM,WAAW,GAAY,KAAK,CAAC"}
@@ -62,7 +62,7 @@ export function normalizeConfig(config) {
62
62
  }
63
63
  // 3. Warn about layout
64
64
  if (layout) {
65
- warnings.push('layout field is ignored. Jamdesk determines layout from theme (pulsar=sidebar, jam/nebula/halo=header).');
65
+ warnings.push('layout field is ignored. Jamdesk determines layout from theme (pulsar=sidebar, jam/nebula/halo/dusk=header).');
66
66
  }
67
67
  // 4. Warn about navbar.style
68
68
  if (rest.navbar?.style) {
@@ -1 +1 @@
1
- {"version":3,"file":"normalize-config.js","sourceRoot":"","sources":["../../src/lib/normalize-config.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAmCH;;;;;;;GAOG;AACH,MAAM,UAAU,eAAe,CAAC,MAAmB;IACjD,MAAM,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,EAAE,GAAG,IAAI,EAAE,GAAG,MAAM,CAAC;IACzD,MAAM,QAAQ,GAAa,EAAE,CAAC;IAE9B,MAAM,UAAU,GAAgB,EAAE,GAAG,IAAI,EAAE,CAAC;IAE5C,wCAAwC;IACxC,IAAI,UAAU,EAAE,CAAC;QACf,QAAQ,CAAC,IAAI,CACX,sFAAsF,CACvF,CAAC;QACF,IAAI,CAAC,MAAM,CAAC,UAAU,EAAE,CAAC;YACvB,UAAU,CAAC,UAAU,GAAG;gBACtB,OAAO,EAAE,UAAU,CAAC,OAAO,IAAI,QAAQ;gBACvC,MAAM,EAAE,UAAU,CAAC,QAAQ,KAAK,IAAI;aACrC,CAAC;QACJ,CAAC;IACH,CAAC;IAED,yBAAyB;IACzB,sFAAsF;IACtF,iFAAiF;IACjF,IAAI,QAAQ,EAAE,CAAC;QACb,MAAM,EAAE,SAAS,EAAE,GAAG,cAAc,EAAE,GAAG,QAAQ,CAAC;QAElD,IAAI,SAAS,KAAK,SAAS,EAAE,CAAC;YAC5B,UAAU,CAAC,QAAQ,GAAG,EAAE,SAAS,EAAE,SAAS,KAAK,IAAI,EAAE,CAAC;QAC1D,CAAC;QAED,MAAM,UAAU,GAAG,MAAM,CAAC,IAAI,CAAC,cAAc,CAAC,CAAC;QAC/C,IAAI,UAAU,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;YAC1B,QAAQ,CAAC,IAAI,CACX,iEAAiE,CAClE,CAAC;YACF,MAAM,gBAAgB,GAAI,UAAU,CAAC,GAAW,EAAE,QAAQ,IAAI,EAAE,CAAC;YACjE,UAAU,CAAC,GAAG,GAAG;gBACf,GAAG,UAAU,CAAC,GAAG;gBACjB,QAAQ,EAAE;oBACR,GAAI,cAAyC;oBAC7C,GAAG,gBAAgB;iBACpB;aACF,CAAC;QACJ,CAAC;IACH,CAAC;IAED,uBAAuB;IACvB,IAAI,MAAM,EAAE,CAAC;QACX,QAAQ,CAAC,IAAI,CACX,yGAAyG,CAC1G,CAAC;IACJ,CAAC;IAED,6BAA6B;IAC7B,IAAI,IAAI,CAAC,MAAM,EAAE,KAAK,EAAE,CAAC;QACvB,QAAQ,CAAC,IAAI,CACX,6DAA6D,CAC9D,CAAC;IACJ,CAAC;IAED,OAAO,EAAE,MAAM,EAAE,UAAU,EAAE,QAAQ,EAAE,CAAC;AAC1C,CAAC"}
1
+ {"version":3,"file":"normalize-config.js","sourceRoot":"","sources":["../../src/lib/normalize-config.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAmCH;;;;;;;GAOG;AACH,MAAM,UAAU,eAAe,CAAC,MAAmB;IACjD,MAAM,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,EAAE,GAAG,IAAI,EAAE,GAAG,MAAM,CAAC;IACzD,MAAM,QAAQ,GAAa,EAAE,CAAC;IAE9B,MAAM,UAAU,GAAgB,EAAE,GAAG,IAAI,EAAE,CAAC;IAE5C,wCAAwC;IACxC,IAAI,UAAU,EAAE,CAAC;QACf,QAAQ,CAAC,IAAI,CACX,sFAAsF,CACvF,CAAC;QACF,IAAI,CAAC,MAAM,CAAC,UAAU,EAAE,CAAC;YACvB,UAAU,CAAC,UAAU,GAAG;gBACtB,OAAO,EAAE,UAAU,CAAC,OAAO,IAAI,QAAQ;gBACvC,MAAM,EAAE,UAAU,CAAC,QAAQ,KAAK,IAAI;aACrC,CAAC;QACJ,CAAC;IACH,CAAC;IAED,yBAAyB;IACzB,sFAAsF;IACtF,iFAAiF;IACjF,IAAI,QAAQ,EAAE,CAAC;QACb,MAAM,EAAE,SAAS,EAAE,GAAG,cAAc,EAAE,GAAG,QAAQ,CAAC;QAElD,IAAI,SAAS,KAAK,SAAS,EAAE,CAAC;YAC5B,UAAU,CAAC,QAAQ,GAAG,EAAE,SAAS,EAAE,SAAS,KAAK,IAAI,EAAE,CAAC;QAC1D,CAAC;QAED,MAAM,UAAU,GAAG,MAAM,CAAC,IAAI,CAAC,cAAc,CAAC,CAAC;QAC/C,IAAI,UAAU,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;YAC1B,QAAQ,CAAC,IAAI,CACX,iEAAiE,CAClE,CAAC;YACF,MAAM,gBAAgB,GAAI,UAAU,CAAC,GAAW,EAAE,QAAQ,IAAI,EAAE,CAAC;YACjE,UAAU,CAAC,GAAG,GAAG;gBACf,GAAG,UAAU,CAAC,GAAG;gBACjB,QAAQ,EAAE;oBACR,GAAI,cAAyC;oBAC7C,GAAG,gBAAgB;iBACpB;aACF,CAAC;QACJ,CAAC;IACH,CAAC;IAED,uBAAuB;IACvB,IAAI,MAAM,EAAE,CAAC;QACX,QAAQ,CAAC,IAAI,CACX,8GAA8G,CAC/G,CAAC;IACJ,CAAC;IAED,6BAA6B;IAC7B,IAAI,IAAI,CAAC,MAAM,EAAE,KAAK,EAAE,CAAC;QACvB,QAAQ,CAAC,IAAI,CACX,6DAA6D,CAC9D,CAAC;IACJ,CAAC;IAED,OAAO,EAAE,MAAM,EAAE,UAAU,EAAE,QAAQ,EAAE,CAAC;AAC1C,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jamdesk",
3
- "version": "1.1.212",
3
+ "version": "1.1.214",
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",
@@ -106,7 +106,7 @@
106
106
  "@inquirer/prompts": "^8.7.2",
107
107
  "ajv": "^8.17.1",
108
108
  "ajv-formats": "^3.0.1",
109
- "chalk": "^6.0.0",
109
+ "chalk": "^6.0.1",
110
110
  "commander": "^15.0.0",
111
111
  "dictionary-en": "^4.0.0",
112
112
  "estree-util-visit": "^2.0.0",
@@ -129,7 +129,7 @@
129
129
  "devDependencies": {
130
130
  "@mdx-js/mdx": "^3.1.1",
131
131
  "@types/fs-extra": "^11.0.0",
132
- "@types/node": "^26.4.1",
132
+ "@types/node": "^26.6.3",
133
133
  "@typescript/native": "npm:typescript@^7.0.2",
134
134
  "typescript": "npm:@typescript/typescript6@^6.0.2",
135
135
  "vitest": "^4.1.11"
@@ -2,7 +2,7 @@ import { headers } from 'next/headers';
2
2
  import { getDocsConfig } from '@/lib/docs-isr';
3
3
  import { normalizeLogo } from '@/lib/docs-types';
4
4
  import { sanitizeFrom } from '@/lib/sanitize-from';
5
- import { getBrandingUrl } from '@/lib/branding-url';
5
+ import { getBrandingUrl, isBrandingVisible } from '@/lib/branding-url';
6
6
  import UnlockForm from './UnlockForm';
7
7
 
8
8
  interface UnlockPageProps {
@@ -34,7 +34,7 @@ export default async function UnlockPage({ searchParams }: UnlockPageProps) {
34
34
  const from = sanitizeFrom(rawFrom ?? null);
35
35
  const showError = params.error === '1';
36
36
 
37
- const showBranding = process.env.NEXT_PUBLIC_SHOW_BRANDING !== 'false';
37
+ const showBranding = isBrandingVisible(config);
38
38
  const brandingUrl = getBrandingUrl(slug);
39
39
 
40
40
  return (
@@ -20,6 +20,7 @@ import { apiSpecsMarkdownFooter } from '@/lib/api-specs-markdown-hint';
20
20
  import { collectLocalSpecPaths } from '@/lib/openapi/operation-description';
21
21
  import { operationMarkdownForRef } from '@/lib/openapi/enrich-page-infos';
22
22
  import { injectAgentDirective } from '@/lib/agent-directive';
23
+ import { absolutizeMarkdownLinks } from '@/lib/markdown-absolute-links';
23
24
  import { resolveMarkdownFallbackPath } from '@/lib/root-page-slug';
24
25
  import { logger } from '@/shared/logger';
25
26
  import type { DocsConfig } from '@/lib/docs-types';
@@ -153,7 +154,9 @@ export async function GET(
153
154
  // path returns whatever the source MDX happened to end with. 107 of snag's
154
155
  // 115 exports shipped without it, so anything appending to or concatenating
155
156
  // these files ran the last line of one into the next.
156
- const body = `${hinted.replace(/\s+$/, '')}\n`;
157
+ // Last, so links in the appended OpenAPI operation content are covered too.
158
+ const linked = absolutizeLinks(hinted, request.headers, project, hostAtDocs, linkPrefix);
159
+ const body = `${linked.replace(/\s+$/, '')}\n`;
157
160
 
158
161
  // Two paths reach this handler via proxy rewrite:
159
162
  // (a) `.md` URL: unique CDN cache key, safe to share.
@@ -177,6 +180,33 @@ export async function GET(
177
180
  });
178
181
  }
179
182
 
183
+ /**
184
+ * Root-relative links in the export become absolute URLs on the host the
185
+ * reader asked for (see absolutizeMarkdownLinks for why). ISR only: `jamdesk
186
+ * dev` serves from localhost over http, and getBaseUrl always builds https.
187
+ * The base comes from getBaseUrl, the same trusted-header resolution the
188
+ * api-specs footer and canonical tags use, so a custom domain's own prefix
189
+ * wins over the subdomain's. Never throws.
190
+ */
191
+ function absolutizeLinks(
192
+ markdown: string,
193
+ headers: Headers,
194
+ project: string,
195
+ hostAtDocs: boolean,
196
+ linkPrefix: string,
197
+ ): string {
198
+ if (!isIsrMode()) return markdown;
199
+ try {
200
+ return absolutizeMarkdownLinks(markdown, getBaseUrl(headers, project, hostAtDocs), linkPrefix);
201
+ } catch (err) {
202
+ logger.warn('[markdown-export] link absolutize skipped', {
203
+ project,
204
+ error: err instanceof Error ? err.message : String(err),
205
+ });
206
+ return markdown;
207
+ }
208
+ }
209
+
180
210
  /**
181
211
  * The two shapes fetchMdxContent throws for a missing page — the
182
212
  * `Page not found: …` message the S3 path builds and the error names the
@@ -26,6 +26,7 @@ export function Tooltip({
26
26
  const linkPrefix = useLinkPrefix();
27
27
  const [isVisible, setIsVisible] = useState(false);
28
28
  const [position, setPosition] = useState<'top' | 'bottom'>('top');
29
+ const [shiftX, setShiftX] = useState(0);
29
30
  const triggerRef = useRef<HTMLSpanElement>(null);
30
31
  const tooltipRef = useRef<HTMLSpanElement>(null);
31
32
  const tooltipId = useId();
@@ -39,6 +40,19 @@ export function Tooltip({
39
40
 
40
41
  // Show below if not enough space above
41
42
  setPosition(spaceAbove < tooltipHeight + 8 ? 'bottom' : 'top');
43
+
44
+ // The popover is centered on the trigger, so a trigger near the edge of
45
+ // the content column pushes half of it past the column's overflow edge,
46
+ // where it is clipped. Shift it back inside the nearest clipping
47
+ // ancestor (the viewport if there is none); the arrow counter-shifts so
48
+ // it still points at the trigger.
49
+ const bounds = clippingBounds(triggerRef.current);
50
+ const center = triggerRect.left + triggerRect.width / 2;
51
+ const halfWidth = tooltipRef.current.offsetWidth / 2;
52
+ const margin = 8;
53
+ const overflowLeft = bounds.left + margin - (center - halfWidth);
54
+ const overflowRight = center + halfWidth - (bounds.right - margin);
55
+ setShiftX(overflowLeft > 0 ? overflowLeft : overflowRight > 0 ? -overflowRight : 0);
42
56
  }
43
57
  }, [isVisible]);
44
58
 
@@ -64,7 +78,7 @@ export function Tooltip({
64
78
  ref={tooltipRef}
65
79
  id={tooltipId}
66
80
  role="tooltip"
67
- style={{ boxShadow: 'var(--shadow-lg)', display: 'block' }}
81
+ style={{ boxShadow: 'var(--shadow-lg)', display: 'block', marginLeft: `${shiftX}px` }}
68
82
  className={`absolute z-50 w-64 p-3 rounded-lg border border-[var(--color-border)] bg-[var(--color-bg-primary)] transition-all duration-150 ${
69
83
  position === 'top'
70
84
  ? 'bottom-full left-1/2 -translate-x-1/2 mb-2'
@@ -99,6 +113,7 @@ export function Tooltip({
99
113
 
100
114
  {/* Arrow */}
101
115
  <span
116
+ style={{ marginLeft: `${-shiftX}px` }}
102
117
  className={`absolute left-1/2 -translate-x-1/2 w-2 h-2 rotate-45 border-[var(--color-border)] bg-[var(--color-bg-primary)] ${
103
118
  position === 'top'
104
119
  ? 'bottom-[-5px] border-r border-b'
@@ -109,3 +124,14 @@ export function Tooltip({
109
124
  </span>
110
125
  );
111
126
  }
127
+
128
+ /** Left/right edges of the nearest ancestor that clips horizontally. */
129
+ function clippingBounds(el: HTMLElement): { left: number; right: number } {
130
+ for (let node = el.parentElement; node; node = node.parentElement) {
131
+ if (getComputedStyle(node).overflowX !== 'visible') {
132
+ const rect = node.getBoundingClientRect();
133
+ return { left: rect.left, right: rect.right };
134
+ }
135
+ }
136
+ return { left: 0, right: document.documentElement.clientWidth };
137
+ }
@@ -12,27 +12,34 @@ interface DefaultLogoProps {
12
12
 
13
13
  export function DefaultLogo({ name, showBadge = true }: DefaultLogoProps) {
14
14
  return (
15
- <div className="flex items-center gap-2.5 group">
16
- {/* Jamdesk icon: Three overlapping rounded rectangles */}
17
- <div className="relative w-8 h-8 transition-transform duration-200 group-hover:scale-105">
18
- <svg viewBox="0 0 32 32" fill="none" className="w-8 h-8">
19
- {/* Back rectangle (left) - lightest purple */}
20
- <rect x="3" y="7" width="14" height="19" rx="2.5" fill="#C4B5FD" fillOpacity="0.9"/>
21
- {/* Middle rectangle - medium purple */}
22
- <rect x="8" y="4" width="14" height="19" rx="2.5" fill="#A78BFA" fillOpacity="0.85"/>
23
- {/* Front rectangle (right) - darker purple */}
24
- <rect x="13" y="9" width="14" height="19" rx="2.5" fill="#8B5CF6" fillOpacity="0.9"/>
25
- </svg>
15
+ // In a crowded header the badge goes first, then the name cuts off with
16
+ // "…", rather than either pushing the controls beside it off-screen. The
17
+ // badge goes by wrapping: this row is one line tall, clipped and allowed to
18
+ // wrap, so the badge drops to the hidden second line as soon as icon +
19
+ // name + badge stop fitting.
20
+ <div className="flex flex-wrap content-start items-center gap-x-2.5 h-8 overflow-hidden min-w-0 group">
21
+ <div className="flex items-center gap-2.5 min-w-0">
22
+ {/* Jamdesk icon: Three overlapping rounded rectangles */}
23
+ <div className="relative w-8 h-8 flex-shrink-0 transition-transform duration-200 group-hover:scale-105">
24
+ <svg viewBox="0 0 32 32" fill="none" className="w-8 h-8">
25
+ {/* Back rectangle (left) - lightest purple */}
26
+ <rect x="3" y="7" width="14" height="19" rx="2.5" fill="#C4B5FD" fillOpacity="0.9"/>
27
+ {/* Middle rectangle - medium purple */}
28
+ <rect x="8" y="4" width="14" height="19" rx="2.5" fill="#A78BFA" fillOpacity="0.85"/>
29
+ {/* Front rectangle (right) - darker purple */}
30
+ <rect x="13" y="9" width="14" height="19" rx="2.5" fill="#8B5CF6" fillOpacity="0.9"/>
31
+ </svg>
32
+ </div>
33
+
34
+ {/* Site name */}
35
+ <span className="text-lg font-semibold text-[var(--color-text-primary)] tracking-tight truncate">
36
+ {name}
37
+ </span>
26
38
  </div>
27
-
28
- {/* Site name */}
29
- <span className="text-lg font-semibold text-[var(--color-text-primary)] tracking-tight">
30
- {name}
31
- </span>
32
-
39
+
33
40
  {/* Docs badge */}
34
41
  {showBadge && (
35
- <span className="px-2 py-0.5 text-[10px] font-bold bg-[var(--color-accent)]/10 text-[var(--color-accent)] rounded uppercase tracking-wide border border-[var(--color-accent)]/20">
42
+ <span className="flex-shrink-0 px-2 py-0.5 text-[10px] font-bold bg-[var(--color-accent)]/10 text-[var(--color-accent)] rounded uppercase tracking-wide border border-[var(--color-accent)]/20">
36
43
  docs
37
44
  </span>
38
45
  )}
@@ -9,7 +9,7 @@ import type { DocsConfig, TabsPosition } from '@/lib/docs-types';
9
9
  import { normalizeLogo, getIconString } from '@/lib/docs-types';
10
10
  import type { LayoutVariant } from '@/themes';
11
11
  import { getTheme } from '@/themes';
12
- import { ThemeToggle } from '@/components/theme/ThemeToggle';
12
+ import { ThemeToggle, ThemeToggleCycle } from '@/components/theme/ThemeToggle';
13
13
  import { LazySearchModal as SearchModal } from '@/components/search/LazySearchModal';
14
14
  import { DefaultLogo } from './DefaultLogo';
15
15
  import { LanguageSelector } from './LanguageSelector';
@@ -33,11 +33,29 @@ interface HeaderProps {
33
33
  // Max tabs to show before overflow dropdown
34
34
  const MAX_VISIBLE_TABS = 4;
35
35
 
36
- // Shared button classes for mobile search/AI icons
36
+ // Shared button classes for search/AI icons below lg. Every search/AI control
37
+ // shows at exactly one set of widths: these below lg, the full bar or the
38
+ // compact icons from lg (header-responsive.test.tsx).
39
+ //
40
+ // Viewport breakpoints decide what lives in the header versus the drawer,
41
+ // because the drawer only exists below lg. How much room a control takes keys
42
+ // off container widths instead: from xl the inline chat panel takes 350-500px
43
+ // of the viewport, and with viewport breakpoints alone Jam's row ran 120px
44
+ // past its edge at 1280 with chat open.
37
45
  const MOBILE_ICON_BUTTON = 'lg:hidden p-2 text-[var(--color-text-muted)] hover:text-[var(--color-text-primary)] bg-[var(--color-bg-secondary)]/50 border border-[var(--color-border)]/50 hover:bg-[var(--color-bg-secondary)] rounded-md transition-colors cursor-pointer';
38
46
 
47
+ // Full search bar buttons (Jam, Halo): icon | label | ⌘ hint. Grid rather
48
+ // than flex so the label column's minimum is 0: as a flex item a nowrap label
49
+ // holds its full width as the bar's minimum and overflows instead of cutting
50
+ // off. Margins rather than gap, so a hidden label or hint leaves no stray gap.
51
+ // The fixed height keeps an icon-only button as tall as a labelled one (the
52
+ // label's text-sm line sets it; Font Awesome's own line-height beats leading-*).
53
+ const BAR_BUTTON = 'grid grid-cols-[auto_minmax(0,max-content)_auto] items-center h-9.5 px-3 py-2 bg-[var(--color-bg-secondary)] border border-[var(--color-border)] rounded-lg';
54
+ const BAR_LABEL = 'col-start-2 ml-3 hidden @min-[16rem]:block truncate';
55
+ const BAR_HINT = 'col-start-3 ml-3 hidden @min-[24rem]:inline-flex';
56
+
39
57
  // Shared button classes for compact (Nebula) header icon buttons
40
- const COMPACT_ICON_BUTTON = 'hidden md:flex p-2 hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-secondary)] rounded-lg transition-colors cursor-pointer';
58
+ const COMPACT_ICON_BUTTON = 'hidden lg:flex p-2 hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-secondary)] rounded-lg transition-colors cursor-pointer';
41
59
 
42
60
  export function Header({ config, layout = 'header-logo', tabsPosition: tabsPositionProp, isSidebarOpen, onToggleSidebar }: HeaderProps) {
43
61
  const { onChatOpen, devNotice } = useChatOpenHandler();
@@ -257,12 +275,14 @@ export function Header({ config, layout = 'header-logo', tabsPosition: tabsPosit
257
275
  const renderLogo = () => {
258
276
  if (showLogoImage) {
259
277
  return (
260
- <div className="flex items-center gap-2">
278
+ <div className="flex items-center gap-2 min-w-0">
261
279
  {/* eslint-disable-next-line @next/next/no-img-element */}
262
280
  <img
263
281
  src={logoSrc}
264
282
  alt={`${config.name} logo`}
265
- style={{ height: '28px', width: 'auto', maxWidth: '170px' }}
283
+ // minWidth 0 lets the logo give way when the row runs out of room;
284
+ // contain keeps it from squashing while it does.
285
+ style={{ height: '28px', width: 'auto', maxWidth: '170px', minWidth: 0, objectFit: 'contain', objectPosition: 'left center' }}
266
286
  onError={() => setLogoError(true)}
267
287
  />
268
288
  </div>
@@ -291,9 +311,12 @@ export function Header({ config, layout = 'header-logo', tabsPosition: tabsPosit
291
311
  CSS that wants to detect the canonical tab strip's presence should target
292
312
  `.sidebar-scroll[data-has-tabs="true"]` instead (set by Sidebar.tsx). */}
293
313
  <header className={headerClasses} data-has-tabs={showTabsInHeader && hasTabs ? 'true' : 'false'}>
294
- <div className="max-w-[1440px] 2xl:mx-auto flex items-center py-3" style={{ margin: '0 24px' }}>
295
- {/* Left: Hamburger + Logo */}
296
- <div className="flex items-center gap-3 flex-shrink-0">
314
+ <div className="@container max-w-[1440px] 2xl:mx-auto flex items-center py-3" style={{ margin: '0 24px' }}>
315
+ {/* Left: Hamburger + Logo. The one block that can shrink: when the
316
+ row is still too narrow, the logo gives way (a text logo cuts off
317
+ with "…") instead of pushing Ask AI or the theme toggle off-screen.
318
+ A logo-less site's name + badge overran 375px by 13-20px. */}
319
+ <div className="flex items-center gap-3 min-w-0">
297
320
  {/* Hamburger menu - mobile only */}
298
321
  <button
299
322
  onClick={onToggleSidebar}
@@ -309,11 +332,11 @@ export function Header({ config, layout = 'header-logo', tabsPosition: tabsPosit
309
332
 
310
333
  {/* Logo - always shown in header-logo layout, shown on mobile only for sidebar-logo layout */}
311
334
  {showLogoInHeader ? (
312
- <Link href={logoHref} prefetch={false} className="flex items-center">
335
+ <Link href={logoHref} prefetch={false} className="flex items-center min-w-0">
313
336
  {renderLogo()}
314
337
  </Link>
315
338
  ) : (
316
- <Link href={logoHref} prefetch={false} className="lg:hidden flex items-center">
339
+ <Link href={logoHref} prefetch={false} className="lg:hidden flex items-center min-w-0">
317
340
  {renderLogo()}
318
341
  </Link>
319
342
  )}
@@ -321,16 +344,30 @@ export function Header({ config, layout = 'header-logo', tabsPosition: tabsPosit
321
344
 
322
345
  {/* Center: Search Bar + Ask AI Button - only in header-logo layout, not for compact search themes */}
323
346
  {showLogoInHeader && !useCompactSearch && (
324
- <div className="flex-1 flex justify-center px-4">
325
- <div className="hidden md:flex items-center gap-2">
347
+ // From lg only: below it the icon buttons cover search/AI, and the
348
+ // column is just a spacer, so it carries no padding either.
349
+ //
350
+ // The column is the container the labels and ⌘ hints query: its
351
+ // width is exactly the room left between the logo and the right
352
+ // side, whatever took the rest (the chat panel, a long navbar, a
353
+ // wide logo). Labels need 16rem, which covers English with room to
354
+ // spare and French or Portuguese with a little cut off ("…", never
355
+ // wrapped: "Demander à l'IA" wrapped to a 57px-tall button at
356
+ // 1024). The hints need 24rem. Size containment means the column
357
+ // no longer takes a minimum from its content, so lg:min-w-[8rem]
358
+ // holds the two icon-only buttons (2 x 43px + gap + padding); past
359
+ // that the logo gives way.
360
+ <div className="@container flex-1 flex justify-center lg:px-4 lg:min-w-[8rem]">
361
+ <div className="hidden lg:flex items-center gap-2">
326
362
  <button
327
363
  onClick={() => onSearchOpen()}
328
- className="flex items-center gap-3 px-3 py-2 bg-[var(--color-bg-secondary)] border border-[var(--color-border)] rounded-lg text-[var(--color-text-muted)] text-sm hover:border-[var(--color-border-hover)] transition-colors cursor-pointer"
364
+ className={`${BAR_BUTTON} text-[var(--color-text-muted)] text-sm hover:border-[var(--color-border-hover)] transition-colors cursor-pointer`}
329
365
  aria-label={ui.search}
366
+ title={`${ui.search} (⌘K)`}
330
367
  >
331
- <i className="fa-solid fa-magnifying-glass text-[14px] flex-shrink-0" aria-hidden="true" />
332
- <span>{ui.search}</span>
333
- <kbd className="hidden sm:inline-flex items-center gap-[2px] px-2 py-0.5 text-xs text-[var(--color-text-muted)] bg-[var(--color-bg-primary)] border border-[var(--color-border)] rounded">
368
+ <i className="fa-solid fa-magnifying-glass text-[14px]" aria-hidden="true" />
369
+ <span className={BAR_LABEL}>{ui.search}</span>
370
+ <kbd className={`${BAR_HINT} items-center gap-[2px] px-2 py-0.5 text-xs text-[var(--color-text-muted)] bg-[var(--color-bg-primary)] border border-[var(--color-border)] rounded`}>
334
371
  <span className="text-sm leading-none">⌘</span>
335
372
  <span>K</span>
336
373
  </kbd>
@@ -338,13 +375,13 @@ export function Header({ config, layout = 'header-logo', tabsPosition: tabsPosit
338
375
  {onChatOpen && (
339
376
  <button
340
377
  onClick={onChatOpen}
341
- className="flex items-center gap-3 px-3 py-2 bg-[var(--color-bg-secondary)] border border-[var(--color-border)] rounded-lg hover:border-[var(--color-border-hover)] cursor-pointer transition-colors text-sm text-[var(--color-text-muted)]"
378
+ className={`${BAR_BUTTON} hover:border-[var(--color-border-hover)] cursor-pointer transition-colors text-sm text-[var(--color-text-muted)]`}
342
379
  aria-label={ui.askAi}
343
380
  title={`${ui.askAi} (⌘I)`}
344
381
  >
345
382
  <i className="fa-solid fa-sparkles text-[14px] text-[var(--color-accent)]" aria-hidden="true" />
346
- <span>{ui.askAi}</span>
347
- <kbd className="hidden sm:inline-flex items-center gap-[2px] px-2 py-0.5 text-xs text-[var(--color-text-muted)] bg-[var(--color-bg-primary)] border border-[var(--color-border)] rounded">
383
+ <span className={BAR_LABEL}>{ui.askAi}</span>
384
+ <kbd className={`${BAR_HINT} items-center gap-[2px] px-2 py-0.5 text-xs text-[var(--color-text-muted)] bg-[var(--color-bg-primary)] border border-[var(--color-border)] rounded`}>
348
385
  <span className="text-sm leading-none">⌘</span>
349
386
  <span>I</span>
350
387
  </kbd>
@@ -517,14 +554,18 @@ export function Header({ config, layout = 'header-logo', tabsPosition: tabsPosit
517
554
  </a>
518
555
  )}
519
556
 
520
- {/* Language Selector - for header-logo layout */}
557
+ {/* Language Selector - header-logo layout, desktop only. Below lg it
558
+ lives in the sidebar drawer, like the theme toggle: at 375px the
559
+ row can't fit it beside the logo. */}
521
560
  {showLogoInHeader && resolved?.languages && resolved.languages.length > 1 && (
522
- <LanguageSelector
523
- languages={resolved.languages}
524
- compact={true}
525
- position="down"
526
- defaultLanguage={resolved.languages.find((l) => l.isDefault)?.code}
527
- />
561
+ <div className="hidden lg:block">
562
+ <LanguageSelector
563
+ languages={resolved.languages}
564
+ compact={true}
565
+ position="down"
566
+ defaultLanguage={resolved.languages.find((l) => l.isDefault)?.code}
567
+ />
568
+ </div>
528
569
  )}
529
570
 
530
571
  {/* Compact search + AI icons - for themes like Nebula that use icon-only buttons */}
@@ -556,10 +597,18 @@ export function Header({ config, layout = 'header-logo', tabsPosition: tabsPosit
556
597
  {config.auth?.jwt?.enabled === true && <LogoutButton />}
557
598
 
558
599
  {/* Theme toggle - hidden on mobile since it's in the sidebar menu.
559
- Also hidden when appearance.strict pins users to the configured mode. */}
560
- {!config.appearance?.strict && (
600
+ Also hidden when appearance.strict pins users to the configured mode.
601
+ Header-logo only: sidebar-logo (Pulsar) keeps it in the sidebar.
602
+ In a row under 48rem (the chat panel dragged to 500px at 1280)
603
+ the three-button toggle gives way to the one-button cycle. */}
604
+ {showLogoInHeader && !config.appearance?.strict && (
561
605
  <div className="hidden lg:block">
562
- <ThemeToggle />
606
+ <div className="hidden @min-[48rem]:block">
607
+ <ThemeToggle />
608
+ </div>
609
+ <div className="flex @min-[48rem]:hidden">
610
+ <ThemeToggleCycle />
611
+ </div>
563
612
  </div>
564
613
  )}
565
614
  </div>
@@ -964,9 +964,11 @@ export function Sidebar({ config, layout = 'header-logo', tabsPosition: tabsPosi
964
964
  </div>
965
965
  </nav>
966
966
 
967
- {/* Language Selector - for sidebar-logo layout */}
968
- {layout === 'sidebar-logo' && resolvedNav.languages.length > 1 && (
969
- <div className="px-4 py-3 border-t border-[var(--color-border)]">
967
+ {/* Language Selector. Sidebar-logo (Pulsar) keeps it here at every
968
+ width. Header-logo layouts show it in the header from lg and here,
969
+ in the drawer, below it (header-responsive.test.tsx). */}
970
+ {resolvedNav.languages.length > 1 && (
971
+ <div className={`px-4 py-3 border-t border-[var(--color-border)]${layout === 'sidebar-logo' ? '' : ' lg:hidden'}`}>
970
972
  <LanguageSelector
971
973
  languages={resolvedNav.languages}
972
974
  position="up"
@@ -3,9 +3,7 @@
3
3
  import type { CSSProperties } from 'react';
4
4
  import type { DocsConfig, SocialPlatform, FooterLinkColumn } from '@/lib/docs-types';
5
5
  import { getIconClass } from '@/lib/icon-utils';
6
- import { getBrandingUrl } from '@/lib/branding-url';
7
-
8
- const showBranding = process.env.NEXT_PUBLIC_SHOW_BRANDING !== 'false';
6
+ import { getBrandingUrl, isBrandingVisible } from '@/lib/branding-url';
9
7
 
10
8
  // Wordmark renders as a CSS mask so its fill inherits `currentColor`, letting
11
9
  // the link's text color (and hover state) drive the SVG color in one place.
@@ -149,9 +147,8 @@ function SocialIcons({ socials }: { socials: Partial<Record<SocialPlatform, stri
149
147
  }
150
148
 
151
149
  /** "Powered by Jamdesk" attribution link, shared by the full footer and the
152
- * embed footer. Renders nothing when branding is disabled at build time. */
150
+ * embed footer. Callers decide whether to render it (isBrandingVisible). */
153
151
  function BrandingLink({ projectSlug }: { projectSlug?: string }) {
154
- if (!showBranding) return null;
155
152
  return (
156
153
  <a
157
154
  href={getBrandingUrl(projectSlug)}
@@ -171,6 +168,8 @@ function BrandingLink({ projectSlug }: { projectSlug?: string }) {
171
168
  }
172
169
 
173
170
  export function SocialFooter({ config, hidden, projectSlug, embed }: SocialFooterProps) {
171
+ const showBranding = isBrandingVisible(config);
172
+
174
173
  // Embed render (widget modal): keep ONLY the "Powered by Jamdesk" attribution.
175
174
  // The link columns + social icons read as out of place inside an embedded
176
175
  // changelog; the attribution stays even when the normal footer is hidden,
@@ -202,7 +201,7 @@ export function SocialFooter({ config, hidden, projectSlug, embed }: SocialFoote
202
201
  {hasLinks && <LinkColumns columns={links} />}
203
202
  <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
204
203
  {hasSocials && <SocialIcons socials={socials} />}
205
- <BrandingLink projectSlug={projectSlug} />
204
+ {showBranding && <BrandingLink projectSlug={projectSlug} />}
206
205
  </div>
207
206
  </footer>
208
207
  );
@@ -4,7 +4,8 @@ import { useId, useState } from 'react';
4
4
  import { useLinkPrefix } from '@/lib/link-prefix-context';
5
5
  import { useThemePreview } from '@/lib/theme-preview-context';
6
6
  import { applyThemePreview, type ThemePreviewSurface } from '@/lib/theme-preview-client';
7
- import { getAllThemes, type ThemeName } from '@/themes';
7
+ import { getPreviewThemes } from '@/lib/theme-preview';
8
+ import type { ThemeName } from '@/themes';
8
9
 
9
10
  /**
10
11
  * Jamdesk-only affordance: re-skins jamdesk.com/docs with any built-in theme
@@ -52,7 +53,7 @@ export function ThemePreviewChips() {
52
53
  applyThemePreview(name, { linkPrefix, surface });
53
54
  }
54
55
 
55
- const themes = getAllThemes();
56
+ const themes = getPreviewThemes();
56
57
  const previewing = activeTheme !== defaultTheme;
57
58
  const activeName = themes.find((t) => t.name === activeTheme)?.displayName ?? activeTheme;
58
59
 
@@ -7,3 +7,12 @@ export function getBrandingUrl(projectSlug?: string | null): string {
7
7
  );
8
8
  return `https://www.jamdesk.com?utm_campaign=poweredBy&utm_medium=referral&utm_source=${slug}`;
9
9
  }
10
+
11
+ /** Whether to show "Powered by Jamdesk". The build writes `_showBranding: false`
12
+ * into the site config only when the owner hid it, so a missing setting (an
13
+ * older build, local preview) or a missing config keeps the badge. */
14
+ export function isBrandingVisible(
15
+ config: { _showBranding?: boolean } | null | undefined,
16
+ ): boolean {
17
+ return config?._showBranding !== false;
18
+ }
@@ -262,7 +262,7 @@ export function parseErrorDetails(
262
262
  const requiredFields =
263
263
  'Required fields:\n' +
264
264
  '• "name": Your site name\n' +
265
- '• "theme": One of "jam", "nebula", "pulsar", or "halo"\n' +
265
+ '• "theme": One of "jam", "nebula", "pulsar", "halo", or "dusk"\n' +
266
266
  '• "colors": { "primary": "#hexcolor" }\n' +
267
267
  '• "navigation": one of "tabs", "groups", "pages", "dropdowns", ' +
268
268
  '"versions", "languages", or "products" ' +
@@ -23,7 +23,7 @@ export const ASSET_PREFIX = '/_jd';
23
23
  /**
24
24
  * Available themes
25
25
  */
26
- export type ThemeName = 'jam' | 'nebula' | 'pulsar' | 'halo';
26
+ export type ThemeName = 'jam' | 'nebula' | 'pulsar' | 'halo' | 'dusk';
27
27
 
28
28
  // =============================================================================
29
29
  // ICON TYPES
@@ -973,6 +973,10 @@ export interface DocsConfig {
973
973
  _hasCustomCss?: boolean;
974
974
  _hasCustomJs?: boolean;
975
975
 
976
+ // Set by the build to `false` only when the owner hid "Powered by Jamdesk"
977
+ // (paid plans). Absent means shown. Read by isBrandingVisible.
978
+ _showBranding?: boolean;
979
+
976
980
  // Runtime: Set by ISR middleware based on project config
977
981
  // When true, all navigation links should be prefixed with /docs
978
982
  hostAtDocs?: boolean;
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Dusk launch embargo — flip to `true` on October 1, 2026.
3
+ *
4
+ * One of four twins (build-service, CLI, dashboard, marketing); separate apps
5
+ * with separate deploys, so they cannot share a module. Flip all four in one
6
+ * commit. See docs/plans/2026-09-23-dusk-october-1-release-plan.md.
7
+ *
8
+ * Here it gates the jamdesk-docs theme picker, which lists every registry
9
+ * theme and is live on jamdesk.com/docs — without it the next ISR deploy
10
+ * would announce Dusk. The registry, schema and validation are NOT gated: a
11
+ * docs.json that names "dusk" must build (the demo site soaks on it).
12
+ *
13
+ * The `: boolean` annotation is LOAD-BEARING: without it TS narrows to the
14
+ * literal `false` and stops type-checking the launch branch.
15
+ */
16
+ export const DUSK_PUBLIC: boolean = false;
@@ -1,11 +1,11 @@
1
1
  /**
2
- * Email Notifier — sends internal build failure notifications via Resend.
2
+ * Email Notifier — sends internal build failure notifications via Mailfully.
3
3
  *
4
- * Env: BUILDER_RESEND_API_KEY, JAMDESK_REPORT_EMAIL
4
+ * Env: MAILFULLY_API_KEY, JAMDESK_REPORT_EMAIL
5
5
  */
6
6
 
7
7
  import { render } from '@react-email/render';
8
- import { Resend } from 'resend';
8
+ import { sendMailfullyEmail } from './mailfully.js';
9
9
  import { BuildFailureEmail, type BuildFailureEmailProps } from './email-templates/index.js';
10
10
 
11
11
  export type BuildFailureEmailInfo = BuildFailureEmailProps;
@@ -15,13 +15,13 @@ export type BuildFailureEmailInfo = BuildFailureEmailProps;
15
15
  * Gracefully skips if environment variables are not configured.
16
16
  */
17
17
  export async function sendInternalBuildFailureEmail(info: BuildFailureEmailInfo): Promise<void> {
18
- const apiKey = process.env.BUILDER_RESEND_API_KEY;
18
+ const apiKey = process.env.MAILFULLY_API_KEY;
19
19
  const reportEmail = process.env.JAMDESK_REPORT_EMAIL;
20
20
 
21
21
  if (!apiKey || !reportEmail) {
22
22
  console.log(JSON.stringify({
23
23
  severity: 'WARNING',
24
- message: `Email notification skipped - ${!apiKey ? 'BUILDER_RESEND_API_KEY' : 'JAMDESK_REPORT_EMAIL'} not configured`,
24
+ message: `Email notification skipped - ${!apiKey ? 'MAILFULLY_API_KEY' : 'JAMDESK_REPORT_EMAIL'} not configured`,
25
25
  projectId: info.projectId,
26
26
  buildId: info.buildId,
27
27
  }));
@@ -29,13 +29,11 @@ export async function sendInternalBuildFailureEmail(info: BuildFailureEmailInfo)
29
29
  }
30
30
 
31
31
  try {
32
- const resend = new Resend(apiKey);
33
-
34
32
  const template = BuildFailureEmail(info);
35
33
  const html = await render(template);
36
34
  const text = await render(template, {plainText: true});
37
35
 
38
- const result = await resend.emails.send({
36
+ const result = await sendMailfullyEmail(apiKey, {
39
37
  from: 'Jamdesk <support@jamdesk.com>',
40
38
  to: reportEmail,
41
39
  subject: `Jamdesk build failed: ${info.projectName || info.projectId}`,