jamdesk 1.1.221 → 1.1.222

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 (56) hide show
  1. package/dist/__tests__/unit/custom-assets.test.js +157 -6
  2. package/dist/__tests__/unit/custom-assets.test.js.map +1 -1
  3. package/dist/commands/dev.d.ts.map +1 -1
  4. package/dist/commands/dev.js +26 -8
  5. package/dist/commands/dev.js.map +1 -1
  6. package/dist/lib/custom-assets.d.ts +17 -0
  7. package/dist/lib/custom-assets.d.ts.map +1 -1
  8. package/dist/lib/custom-assets.js +109 -1
  9. package/dist/lib/custom-assets.js.map +1 -1
  10. package/package.json +1 -1
  11. package/vendored/app/api/chat/[project]/route.ts +31 -7
  12. package/vendored/components/chat/ChatBrandIcon.tsx +17 -0
  13. package/vendored/components/chat/ChatEmptyState.tsx +5 -5
  14. package/vendored/components/chat/ChatPanel.tsx +10 -7
  15. package/vendored/components/chat/useChatLabel.ts +9 -0
  16. package/vendored/components/layout/LayoutWrapper.tsx +3 -0
  17. package/vendored/components/mdx/Mermaid.tsx +16 -3
  18. package/vendored/components/mdx/MermaidInner.tsx +272 -19
  19. package/vendored/components/mdx/MermaidZoomModal.tsx +87 -0
  20. package/vendored/components/mdx/ZoomableImage.tsx +8 -1
  21. package/vendored/components/navigation/Header.tsx +15 -10
  22. package/vendored/components/navigation/Sidebar.tsx +6 -3
  23. package/vendored/components/navigation/TabsNav.tsx +9 -2
  24. package/vendored/components/search/SearchModal.tsx +6 -3
  25. package/vendored/hooks/useChat.ts +2 -0
  26. package/vendored/hooks/useChatPanel.tsx +9 -2
  27. package/vendored/lib/anthropic-client.ts +7 -0
  28. package/vendored/lib/build/error-parser.ts +17 -0
  29. package/vendored/lib/build/extract-tarball.ts +1 -29
  30. package/vendored/lib/build/validate-no-symlink-escape.ts +100 -0
  31. package/vendored/lib/chat-branding.ts +26 -0
  32. package/vendored/lib/chat-prompt.ts +2 -2
  33. package/vendored/lib/docs-types.ts +20 -0
  34. package/vendored/lib/generate-starter-questions.ts +24 -6
  35. package/vendored/lib/github-git-auth.ts +47 -0
  36. package/vendored/lib/hedge-strip.ts +1 -1
  37. package/vendored/lib/isr-build-executor.ts +63 -12
  38. package/vendored/lib/layout-helpers.tsx +13 -6
  39. package/vendored/lib/link-rewriter.ts +1 -1
  40. package/vendored/lib/mcp-auth.ts +67 -0
  41. package/vendored/lib/openapi/cache.ts +5 -2
  42. package/vendored/lib/openapi/expand-spec-pages.ts +5 -3
  43. package/vendored/lib/openapi/safe-ref-resolvers.ts +455 -0
  44. package/vendored/lib/openapi/validator.ts +46 -9
  45. package/vendored/lib/openapi-isr.ts +24 -11
  46. package/vendored/lib/page-timestamps.ts +29 -6
  47. package/vendored/lib/query-rewriter.ts +7 -3
  48. package/vendored/lib/r2-manifest.ts +2 -0
  49. package/vendored/lib/runtime-flags.ts +54 -0
  50. package/vendored/schema/docs-schema.json +34 -0
  51. package/vendored/scripts/copy-files.cjs +90 -37
  52. package/vendored/scripts/openapi-safe-ref-resolvers.cjs +354 -0
  53. package/vendored/scripts/openapi-spec-loader.cjs +10 -5
  54. package/vendored/themes/base.css +48 -0
  55. package/vendored/workspace-package-lock.json +18 -18
  56. package/vendored/components/chat/ChatFAB.tsx +0 -36
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import { useEffect, useLayoutEffect, useRef, useState } from 'react';
3
+ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
4
4
  import mermaid from 'mermaid';
5
5
  import {
6
6
  CACHE_KEY_PREFIX,
@@ -10,6 +10,7 @@ import {
10
10
  readSvgHeight,
11
11
  type CachedDiagram,
12
12
  } from './mermaidCache';
13
+ import { MermaidZoomModal, isZoomDisabled, withZoomIds } from './MermaidZoomModal';
13
14
 
14
15
  mermaid.initialize({
15
16
  startOnLoad: false,
@@ -35,6 +36,48 @@ interface MermaidInnerProps {
35
36
  className?: string;
36
37
  /** Minimum width for the SVG diagram (e.g., "700px", "100%") */
37
38
  minWidth?: string;
39
+ /** Turns off click to expand. All three spellings: see isZoomDisabled. */
40
+ noZoom?: boolean | string;
41
+ nozoom?: boolean | string;
42
+ 'data-no-zoom'?: boolean | string;
43
+ }
44
+
45
+ // Same breakpoint as ZoomableImage (Tailwind `md`): below it the modal would
46
+ // be no larger than the inline diagram.
47
+ const ZOOM_MEDIA_QUERY = '(min-width: 768px)';
48
+ const ZOOM_MIN_VIEWPORT_PX = 768;
49
+
50
+ // The panel is 90vw, so without a cap a small pie or a narrow sequence diagram
51
+ // is scaled several times over and has to be scrolled. 1.5x keeps a default
52
+ // pie (about 570x450) fully visible in a 90vh panel on a 900px-tall screen,
53
+ // where 2x did not. Wide diagrams still fit the panel width; tall ones scroll.
54
+ const ZOOM_MAX_SCALE = 1.5;
55
+
56
+ function isZoomViewport(): boolean {
57
+ return typeof window.matchMedia === 'function'
58
+ ? window.matchMedia(ZOOM_MEDIA_QUERY).matches
59
+ : window.innerWidth >= ZOOM_MIN_VIEWPORT_PX;
60
+ }
61
+
62
+ // Mermaid writes its render width as an inline px max-width (`useMaxWidth`);
63
+ // the viewBox width is the same number when that is off.
64
+ function naturalSvgWidth(svgEl: SVGSVGElement): number | null {
65
+ const maxWidth = svgEl.style.maxWidth;
66
+ if (maxWidth.endsWith('px') && parseFloat(maxWidth) > 0) return parseFloat(maxWidth);
67
+ const viewBoxWidth = Number(svgEl.getAttribute('viewBox')?.trim().split(/[\s,]+/)[2]);
68
+ return viewBoxWidth > 0 ? viewBoxWidth : null;
69
+ }
70
+
71
+ function setButtonSemantics(el: HTMLElement, on: boolean): void {
72
+ if (on) {
73
+ el.setAttribute('role', 'button');
74
+ el.setAttribute('tabindex', '0');
75
+ el.setAttribute('aria-label', 'Expand diagram');
76
+ } else {
77
+ el.removeAttribute('role');
78
+ el.removeAttribute('tabindex');
79
+ el.removeAttribute('aria-label');
80
+ }
38
81
  }
39
82
 
40
83
  interface ColorPalette {
@@ -96,10 +139,31 @@ const lightPalette: ColorPalette = {
96
139
  pieSliceStroke: '#ffffff', // white — crisp separation between slices on a light bg
97
140
  };
98
141
 
142
+ // Mermaid marks author styles (`style`, `classDef`, subgraph `style`) as inline
143
+ // `!important` and never marks its own defaults, so this is the stable signal for
144
+ // "the author chose this". We never write `!important` ourselves, so the signal
145
+ // survives re-theming on a dark-mode toggle.
146
+ function isAuthored(el: Element, prop: string): boolean {
147
+ return (el as HTMLElement).style.getPropertyPriority(prop) === 'important';
148
+ }
149
+
150
+ function kebab(prop: string): string {
151
+ return prop.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
152
+ }
153
+
154
+ // Writes a property unless the author set it; setProperty (not `style.x =`)
155
+ // is used because plain assignment would also drop an authored `!important`.
156
+ function setThemed(el: Element, prop: string, value: string): void {
157
+ if (isAuthored(el, prop)) return;
158
+ (el as HTMLElement).style.setProperty(prop, value);
159
+ }
160
+
99
161
  // Styling helper utilities
100
162
  function styleElements(svgEl: SVGElement, selector: string, styles: Partial<CSSStyleDeclaration>): void {
101
163
  svgEl.querySelectorAll(selector).forEach((el) => {
102
- Object.assign((el as SVGElement).style, styles);
164
+ for (const [prop, value] of Object.entries(styles)) {
165
+ setThemed(el, kebab(prop), String(value));
166
+ }
103
167
  });
104
168
  }
105
169
 
@@ -243,7 +307,8 @@ function applyCommonStyles(svgEl: SVGElement, palette: ColorPalette): void {
243
307
  // Cylinder/database shapes (paths with light fills)
244
308
  svgEl.querySelectorAll('.node path').forEach((path) => {
245
309
  if (isLightColor(window.getComputedStyle(path).fill)) {
246
- Object.assign((path as SVGElement).style, { fill: palette.nodeFill, stroke: palette.nodeStroke });
310
+ setThemed(path, 'fill', palette.nodeFill);
311
+ setThemed(path, 'stroke', palette.nodeStroke);
247
312
  }
248
313
  });
249
314
 
@@ -271,7 +336,12 @@ function applyDarkModeInversions(svgEl: SVGElement, palette: ColorPalette): void
271
336
 
272
337
  // Invert text colors. text.slice (pie percentage labels) is excluded so the
273
338
  // white set in applyPieChartStyles stays legible on the colored slices.
274
- styleElements(svgEl, 'text:not(.slice), .nodeLabel, .edgeLabel, .label, tspan', { fill: palette.text });
339
+ svgEl.querySelectorAll('text:not(.slice), .nodeLabel, .edgeLabel, .label, tspan').forEach((el) => {
340
+ const label = el.closest('.label');
341
+ if (isAuthored(el, 'fill') || isAuthored(el, 'color')) return;
342
+ if (label && (isAuthored(label, 'fill') || isAuthored(label, 'color'))) return;
343
+ setThemed(el, 'fill', palette.text);
344
+ });
275
345
  styleElements(svgEl, 'text.actor, .messageText, .labelText, .loopText, .noteText', { fill: palette.text });
276
346
 
277
347
  // Invert foreignObject text and clear light backgrounds
@@ -280,7 +350,7 @@ function applyDarkModeInversions(svgEl: SVGElement, palette: ColorPalette): void
280
350
  const computed = window.getComputedStyle(htmlEl);
281
351
 
282
352
  if (isDarkColor(computed.color)) {
283
- htmlEl.style.color = palette.text;
353
+ setThemed(htmlEl, 'color', palette.text);
284
354
  }
285
355
  if (isLightColor(computed.backgroundColor)) {
286
356
  htmlEl.style.backgroundColor = 'transparent';
@@ -293,10 +363,10 @@ function applyDarkModeInversions(svgEl: SVGElement, palette: ColorPalette): void
293
363
  if ((el as Element).classList.contains('pieCircle')) return;
294
364
  const computed = window.getComputedStyle(el);
295
365
  if (computed.stroke && computed.stroke !== 'none') {
296
- (el as SVGElement).style.stroke = palette.line;
366
+ setThemed(el, 'stroke', palette.line);
297
367
  }
298
368
  if (isLightColor(computed.fill)) {
299
- (el as SVGElement).style.fill = 'transparent';
369
+ setThemed(el, 'fill', 'transparent');
300
370
  }
301
371
  });
302
372
 
@@ -306,9 +376,83 @@ function applyDarkModeInversions(svgEl: SVGElement, palette: ColorPalette): void
306
376
  // Circles (state diagram start/end states)
307
377
  svgEl.querySelectorAll('circle').forEach((circle) => {
308
378
  if (isDarkColor(window.getComputedStyle(circle).fill)) {
309
- (circle as SVGElement).style.fill = palette.line;
379
+ setThemed(circle, 'fill', palette.line);
310
380
  }
311
- (circle as SVGElement).style.stroke = palette.line;
381
+ setThemed(circle, 'stroke', palette.line);
382
+ });
383
+ }
384
+
385
+ // Common CSS names seen in mermaid docs; only a fallback for engines whose
386
+ // computed style doesn't resolve a named color to rgb() (browsers do).
387
+ const NAMED_COLORS: Record<string, string> = {
388
+ white: '255,255,255', black: '0,0,0', red: '255,0,0', green: '0,128,0', blue: '0,0,255',
389
+ yellow: '255,255,0', orange: '255,165,0', purple: '128,0,128', pink: '255,192,203',
390
+ gray: '128,128,128', grey: '128,128,128', lightblue: '173,216,230', lightgreen: '144,238,144',
391
+ lightgray: '211,211,211', lightgrey: '211,211,211', lightyellow: '255,255,224',
392
+ navy: '0,0,128', teal: '0,128,128', cyan: '0,255,255', magenta: '255,0,255',
393
+ brown: '165,42,42', gold: '255,215,0', silver: '192,192,192', darkblue: '0,0,139',
394
+ darkgreen: '0,100,0', darkred: '139,0,0',
395
+ };
396
+
397
+ function hslToRgb(value: string): string | null {
398
+ const m = value.match(/hsla?\(\s*([\d.]+)(?:deg)?\s*[, ]\s*([\d.]+)%\s*[, ]\s*([\d.]+)%/i);
399
+ if (!m) return null;
400
+ const h = (+m[1] % 360) / 360;
401
+ const sat = +m[2] / 100;
402
+ const l = +m[3] / 100;
403
+ const q = l < 0.5 ? l * (1 + sat) : l + sat - l * sat;
404
+ const p = 2 * l - q;
405
+ const channel = (t: number) => {
406
+ const x = (t + 1) % 1;
407
+ if (x < 1 / 6) return p + (q - p) * 6 * x;
408
+ if (x < 1 / 2) return q;
409
+ if (x < 2 / 3) return p + (q - p) * (2 / 3 - x) * 6;
410
+ return p;
411
+ };
412
+ return [channel(h + 1 / 3), channel(h), channel(h - 1 / 3)].map((c) => Math.round(c * 255)).join(',');
413
+ }
414
+
415
+ // Computed style first (browsers resolve names and hsl() to rgb()), then the
416
+ // raw inline value parsed by hand for engines that don't.
417
+ function resolveRgb(shape: Element): [number, number, number] | null {
418
+ const raw = (shape as HTMLElement).style.getPropertyValue('fill').trim().toLowerCase();
419
+ const parsed =
420
+ normalizeColor(window.getComputedStyle(shape).fill) ??
421
+ normalizeColor(raw) ??
422
+ hslToRgb(raw) ??
423
+ NAMED_COLORS[raw] ??
424
+ null;
425
+ return parsed ? (parsed.split(',').map(Number) as [number, number, number]) : null;
426
+ }
427
+
428
+ function relativeLuminance(rgb: [number, number, number]): number {
429
+ const [r, g, b] = rgb.map((v) => {
430
+ const c = v / 255;
431
+ return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
432
+ });
433
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
434
+ }
435
+
436
+ // A node with an authored fill but no authored label color would get the
437
+ // theme's text color, which can vanish against the author's fill (pale text on
438
+ // a light classDef fill in dark mode). Pick black or white from the fill. Runs
439
+ // last in both modes so the dark-mode inversions can't overwrite it.
440
+ function applyAuthoredContrast(svgEl: SVGElement): void {
441
+ svgEl.querySelectorAll('.node').forEach((node) => {
442
+ const shape = Array.from(node.querySelectorAll('rect, polygon, path, circle, ellipse')).find((s) =>
443
+ isAuthored(s, 'fill'),
444
+ );
445
+ if (!shape) return;
446
+ const rgb = resolveRgb(shape);
447
+ if (!rgb) return;
448
+ const textColor = relativeLuminance(rgb) > 0.5 ? '#111827' : '#ffffff';
449
+
450
+ const labels = Array.from(node.querySelectorAll('.label, .nodeLabel, foreignObject div, foreignObject span, tspan'));
451
+ if (labels.some((l) => isAuthored(l, 'color') || isAuthored(l, 'fill'))) return;
452
+ labels.forEach((l) => {
453
+ (l as HTMLElement).style.setProperty('color', textColor);
454
+ if (l.tagName.toLowerCase() === 'tspan') (l as HTMLElement).style.setProperty('fill', textColor);
455
+ });
312
456
  });
313
457
  }
314
458
 
@@ -347,8 +491,17 @@ function applyLightModeCleanup(svgEl: SVGElement): void {
347
491
  * A --> B --> C
348
492
  * </Mermaid>
349
493
  */
350
- export function MermaidInner({ children, className, minWidth }: MermaidInnerProps) {
494
+ export function MermaidInner({
495
+ children,
496
+ className,
497
+ minWidth,
498
+ noZoom,
499
+ nozoom,
500
+ 'data-no-zoom': dataNoZoom,
501
+ }: MermaidInnerProps) {
351
502
  const containerRef = useRef<HTMLDivElement>(null);
503
+ const zoomEnabled = !isZoomDisabled(noZoom, nozoom, dataNoZoom);
504
+ const [zoom, setZoom] = useState<{ svgHtml: string; maxWidthPx?: number } | null>(null);
352
505
  const [svg, setSvg] = useState<string>(() => {
353
506
  if (typeof window === 'undefined') return '';
354
507
  return readCache(children)?.svg ?? '';
@@ -427,11 +580,13 @@ export function MermaidInner({ children, className, minWidth }: MermaidInnerProp
427
580
  const applyDarkModeStyles = () => {
428
581
  applyCommonStyles(svgEl, darkPalette);
429
582
  applyDarkModeInversions(svgEl, darkPalette);
583
+ applyAuthoredContrast(svgEl);
430
584
  };
431
585
 
432
586
  const applyLightModeStyles = () => {
433
587
  applyCommonStyles(svgEl, lightPalette);
434
588
  applyLightModeCleanup(svgEl);
589
+ applyAuthoredContrast(svgEl);
435
590
  };
436
591
 
437
592
  if (isDarkMode) {
@@ -461,6 +616,100 @@ export function MermaidInner({ children, className, minWidth }: MermaidInnerProp
461
616
  return () => observer.disconnect();
462
617
  }, [svg, minWidth]);
463
618
 
619
+ // Button semantics only where activation works: once the diagram exists and
620
+ // at >= 768px. Elsewhere (mobile, loading) screen readers get the SVG's own
621
+ // accessible text. Set imperatively: putting them in React state would
622
+ // re-render the container on every breakpoint crossing (see the memo note
623
+ // below), and React leaves attributes it doesn't own alone.
624
+ useEffect(() => {
625
+ const el = containerRef.current;
626
+ if (!el || !zoomEnabled || !svg) return;
627
+ const mq = typeof window.matchMedia === 'function' ? window.matchMedia(ZOOM_MEDIA_QUERY) : null;
628
+ const sync = () => setButtonSemantics(el, mq ? mq.matches : window.innerWidth >= ZOOM_MIN_VIEWPORT_PX);
629
+ sync();
630
+ mq?.addEventListener('change', sync);
631
+ return () => {
632
+ mq?.removeEventListener('change', sync);
633
+ setButtonSemantics(el, false);
634
+ };
635
+ }, [svg, zoomEnabled]);
636
+
637
+ // Width is checked again at open time, like ZoomableImage.
638
+ /** Returns whether the modal opened, so a click only claims itself then. */
639
+ const openZoom = useCallback((): boolean => {
640
+ const el = containerRef.current;
641
+ const svgEl = el?.querySelector('svg');
642
+ // No <svg> yet: the diagram is still rendering, and a snapshot would open
643
+ // an empty modal.
644
+ if (!el || !svgEl || !isZoomViewport()) return false;
645
+ const natural = naturalSvgWidth(svgEl);
646
+ // The container's DOM is already themed, so the copy keeps the active
647
+ // palette and author colors without re-running the style pass.
648
+ setZoom({
649
+ svgHtml: withZoomIds(el.innerHTML),
650
+ maxWidthPx: natural ? natural * ZOOM_MAX_SCALE : undefined,
651
+ });
652
+ return true;
653
+ }, []);
654
+
655
+ const handleClick = useCallback(
656
+ (e: React.MouseEvent<HTMLDivElement>) => {
657
+ // A customer's delegated zoom script may run before or after this
658
+ // handler. Whoever goes second sees defaultPrevented and backs off, so
659
+ // the diagram never opens two modals.
660
+ if (e.defaultPrevented) return;
661
+ // A drag that selects label text also fires click; so does a link the
662
+ // author put in the diagram. Neither should open the modal.
663
+ if (window.getSelection()?.toString()) return;
664
+ if ((e.target as Element).closest('a')) return;
665
+ // Only claim the click when the modal really opens. Below 768px it does
666
+ // not, and a customer zoom script that honors defaultPrevented must
667
+ // still get its turn.
668
+ if (openZoom()) e.preventDefault();
669
+ },
670
+ [openZoom],
671
+ );
672
+
673
+ const handleKeyDown = useCallback(
674
+ (e: React.KeyboardEvent<HTMLDivElement>) => {
675
+ // Keys from a focused link inside the diagram bubble here; leave them
676
+ // alone so Enter still follows the link.
677
+ if (e.target !== e.currentTarget) return;
678
+ if (e.key !== 'Enter' && e.key !== ' ') return;
679
+ e.preventDefault(); // Space would otherwise scroll the page
680
+ openZoom();
681
+ },
682
+ [openZoom],
683
+ );
684
+
685
+ const closeZoom = useCallback(() => {
686
+ setZoom(null);
687
+ containerRef.current?.focus();
688
+ }, []);
689
+
690
+ // Security: Mermaid's securityLevel: 'strict' sanitizes the SVG output,
691
+ // similar to how KaTeX sanitizes LaTeX in the Latex component.
692
+ // The dangerouslySetInnerHTML is safe here because mermaid.render()
693
+ // outputs sanitized SVG when securityLevel is 'strict'.
694
+ //
695
+ // Memoized so opening or closing the zoom modal (a state change here) does
696
+ // not re-render this element. A re-write of its innerHTML would throw away
697
+ // the imperative theming above, which only re-runs on [svg, minWidth]. React
698
+ // 19.3 skips the write while __html is unchanged; the memo keeps that from
699
+ // being the only guard. Same trap as the memoized element in Mermaid.tsx.
700
+ const zoomReady = zoomEnabled && svg !== '';
701
+ const diagram = useMemo(
702
+ () => (
703
+ <div
704
+ ref={containerRef}
705
+ className={`mermaid-container my-6 flex justify-center items-start overflow-x-auto ${zoomReady ? 'md:cursor-zoom-in' : ''} ${className || ''}`}
706
+ dangerouslySetInnerHTML={{ __html: svg }}
707
+ {...(zoomEnabled && { onClick: handleClick, onKeyDown: handleKeyDown })}
708
+ />
709
+ ),
710
+ [svg, className, zoomEnabled, zoomReady, handleClick, handleKeyDown],
711
+ );
712
+
464
713
  if (error) {
465
714
  return (
466
715
  <div className="my-4 p-4 rounded-lg bg-red-50 dark:bg-red-950 border border-red-200 dark:border-red-800">
@@ -470,15 +719,19 @@ export function MermaidInner({ children, className, minWidth }: MermaidInnerProp
470
719
  );
471
720
  }
472
721
 
473
- // Security: Mermaid's securityLevel: 'strict' sanitizes the SVG output,
474
- // similar to how KaTeX sanitizes LaTeX in the Latex component.
475
- // The dangerouslySetInnerHTML is safe here because mermaid.render()
476
- // outputs sanitized SVG when securityLevel is 'strict'.
722
+ // The modal is a portal into document.body, so it adds no DOM child here:
723
+ // Mermaid.tsx's firstElementChild height probe still sees the container.
477
724
  return (
478
- <div
479
- ref={containerRef}
480
- className={`mermaid-container my-6 flex justify-center items-start overflow-x-auto ${className || ''}`}
481
- dangerouslySetInnerHTML={{ __html: svg }}
482
- />
725
+ <>
726
+ {diagram}
727
+ {zoom !== null && (
728
+ <MermaidZoomModal
729
+ svgHtml={zoom.svgHtml}
730
+ maxWidthPx={zoom.maxWidthPx}
731
+ label="Expanded diagram"
732
+ onClose={closeZoom}
733
+ />
734
+ )}
735
+ </>
483
736
  );
484
737
  }
@@ -0,0 +1,87 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useRef } from 'react';
4
+ import { createPortal } from 'react-dom';
5
+ import { useBodyScrollLock } from '../../hooks/useBodyScrollLock';
6
+
7
+ type ZoomFlag = boolean | string | undefined;
8
+
9
+ /**
10
+ * Mirrors the image opt-out in MDXComponents.tsx: preprocess-mdx turns a bare
11
+ * `noZoom` attribute into `data-no-zoom="true"`, and HTML-style parsing can
12
+ * lowercase it to `nozoom`.
13
+ */
14
+ export function isZoomDisabled(...flags: ZoomFlag[]): boolean {
15
+ return flags.some((f) => f === true || f === 'true');
16
+ }
17
+
18
+ /**
19
+ * Mermaid scopes its `<style>` rules and marker references to the root svg id
20
+ * (`#X .node`, `url(#X_arrow)`), so a second copy with the same id would clash
21
+ * with the inline diagram. Rewrites every occurrence of the id. The ids are
22
+ * random (`mermaid-<9 chars>`), so a plain substring replace can't hit
23
+ * unrelated text.
24
+ */
25
+ export function withZoomIds(svgHtml: string): string {
26
+ const id = svgHtml.match(/^\s*<svg\b[^>]*?\sid="([^"]+)"/)?.[1];
27
+ if (!id) return svgHtml;
28
+ return svgHtml.split(id).join(`${id}-zoom`);
29
+ }
30
+
31
+ interface MermaidZoomModalProps {
32
+ svgHtml: string;
33
+ label: string;
34
+ onClose: () => void;
35
+ /** Upper bound on the diagram's width; the panel width still applies. */
36
+ maxWidthPx?: number;
37
+ }
38
+
39
+ export function MermaidZoomModal({ svgHtml, label, onClose, maxWidthPx }: MermaidZoomModalProps) {
40
+ const closeRef = useRef<HTMLButtonElement>(null);
41
+
42
+ useBodyScrollLock(true);
43
+
44
+ useEffect(() => {
45
+ closeRef.current?.focus();
46
+ }, []);
47
+
48
+ useEffect(() => {
49
+ const handleKeyDown = (e: KeyboardEvent) => {
50
+ if (e.key === 'Escape') onClose();
51
+ };
52
+ document.addEventListener('keydown', handleKeyDown);
53
+ return () => document.removeEventListener('keydown', handleKeyDown);
54
+ }, [onClose]);
55
+
56
+ // Only a click on the backdrop itself closes: clicks inside the panel must
57
+ // not, so the reader can scroll and select within a large diagram.
58
+ const handleBackdropClick = (e: React.MouseEvent<HTMLDivElement>) => {
59
+ if (e.target === e.currentTarget) onClose();
60
+ };
61
+
62
+ // Security: the markup is a copy of the inline diagram's DOM, which mermaid
63
+ // produced with securityLevel 'strict' (see MermaidInner).
64
+ return createPortal(
65
+ <div className="image-zoom-modal" role="dialog" aria-modal="true" aria-label={label}>
66
+ <div className="image-zoom-backdrop" onClick={handleBackdropClick}>
67
+ <div className="mermaid-zoom-panel">
68
+ <button
69
+ ref={closeRef}
70
+ type="button"
71
+ className="mermaid-zoom-close"
72
+ aria-label="Close diagram"
73
+ onClick={onClose}
74
+ >
75
+ <i className="fa-solid fa-xmark" aria-hidden="true" />
76
+ </button>
77
+ <div
78
+ className="mermaid-zoom-content"
79
+ style={maxWidthPx ? { maxWidth: `${maxWidthPx}px` } : undefined}
80
+ dangerouslySetInnerHTML={{ __html: svgHtml }}
81
+ />
82
+ </div>
83
+ </div>
84
+ </div>,
85
+ document.body,
86
+ );
87
+ }
@@ -32,9 +32,16 @@ export function ZoomableImage({
32
32
 
33
33
  useBodyScrollLock(isZoomed);
34
34
 
35
- const handleClick = useCallback(() => {
35
+ const handleClick = useCallback((e: React.MouseEvent<HTMLImageElement>) => {
36
+ // A customer's delegated zoom script may run first; back off if it did,
37
+ // and mark the click handled so it backs off if we ran first.
38
+ if (e.defaultPrevented) return;
39
+ // An image inside a link must leave the link's navigation alone; before
40
+ // the defaultPrevented guard, the click bubbled to the anchor and followed it.
41
+ if ((e.target as Element).closest('a')) return;
36
42
  // Check mobile at click time — avoids per-image resize listeners
37
43
  if (!noZoom && window.innerWidth >= 768) {
44
+ e.preventDefault();
38
45
  setIsZoomed(true);
39
46
  }
40
47
  }, [noZoom]);
@@ -18,6 +18,8 @@ import { resolveNavigation } from '@/lib/navigation-resolver';
18
18
  import { getIconClass } from '@/lib/icon-utils';
19
19
  import { useLinkPrefix } from '@/lib/link-prefix-context';
20
20
  import { useChatOpenHandler } from '@/hooks/useChatPanel';
21
+ import { ChatBrandIcon } from '@/components/chat/ChatBrandIcon';
22
+ import { useChatLabel } from '@/components/chat/useChatLabel';
21
23
  import { useSearchOpenHandler } from '@/hooks/useSearchOpenHandler';
22
24
  import { extractLanguageFromPath } from '@/lib/language-utils';
23
25
  import { getUiStrings } from '@/lib/ui-strings';
@@ -70,6 +72,7 @@ export function Header({ config, layout = 'header-logo', tabsPosition: tabsPosit
70
72
  const linkPrefix = useLinkPrefix();
71
73
  const currentLang = extractLanguageFromPath(pathname || '/');
72
74
  const ui = getUiStrings(currentLang);
75
+ const chatLabel = useChatLabel(ui.askAi);
73
76
 
74
77
  // In sidebar-logo layout, logo is in sidebar, not header
75
78
  const showLogoInHeader = layout === 'header-logo';
@@ -358,9 +361,11 @@ export function Header({ config, layout = 'header-logo', tabsPosition: tabsPosit
358
361
  // that the logo gives way.
359
362
  <div className="@container flex-1 flex justify-center lg:px-4 lg:min-w-[8rem]">
360
363
  <div className="hidden lg:flex items-center gap-2">
364
+ {/* shrink-0: a long custom chat label (up to 40 chars) must
365
+ truncate itself, not squeeze "Search" down to "Se…". */}
361
366
  <button
362
367
  onClick={() => onSearchOpen()}
363
- className={`${BAR_BUTTON} text-[var(--color-text-muted)] text-sm hover:border-[var(--color-border-hover)] transition-colors cursor-pointer`}
368
+ className={`${BAR_BUTTON} shrink-0 text-[var(--color-text-muted)] text-sm hover:border-[var(--color-border-hover)] transition-colors cursor-pointer`}
364
369
  aria-label={ui.search}
365
370
  title={`${ui.search} (⌘K)`}
366
371
  >
@@ -375,11 +380,11 @@ export function Header({ config, layout = 'header-logo', tabsPosition: tabsPosit
375
380
  <button
376
381
  onClick={onChatOpen}
377
382
  className={`${BAR_BUTTON} hover:border-[var(--color-border-hover)] cursor-pointer transition-colors text-sm text-[var(--color-text-muted)]`}
378
- aria-label={ui.askAi}
379
- title={`${ui.askAi} (⌘I)`}
383
+ aria-label={chatLabel}
384
+ title={`${chatLabel} (⌘I)`}
380
385
  >
381
- <i className="fa-solid fa-sparkles text-[14px] text-[var(--color-accent)]" aria-hidden="true" />
382
- <span className={BAR_LABEL}>{ui.askAi}</span>
386
+ <ChatBrandIcon className="text-[14px] text-[var(--color-accent)]" sizePx={14} />
387
+ <span className={BAR_LABEL}>{chatLabel}</span>
383
388
  <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`}>
384
389
  <span className="text-sm leading-none">⌘</span>
385
390
  <span>I</span>
@@ -510,9 +515,9 @@ export function Header({ config, layout = 'header-logo', tabsPosition: tabsPosit
510
515
  <button
511
516
  onClick={onChatOpen}
512
517
  className={MOBILE_ICON_BUTTON}
513
- aria-label={ui.askAi}
518
+ aria-label={chatLabel}
514
519
  >
515
- <i className="fa-solid fa-sparkles text-[16px] text-[var(--color-accent)]" aria-hidden="true" />
520
+ <ChatBrandIcon className="text-[16px] text-[var(--color-accent)]" sizePx={16} />
516
521
  </button>
517
522
  )}
518
523
 
@@ -570,10 +575,10 @@ export function Header({ config, layout = 'header-logo', tabsPosition: tabsPosit
570
575
  <button
571
576
  onClick={onChatOpen}
572
577
  className={`${COMPACT_ICON_BUTTON} text-[var(--color-accent)]`}
573
- aria-label={ui.askAi}
574
- title={`${ui.askAi} (⌘I)`}
578
+ aria-label={chatLabel}
579
+ title={`${chatLabel} (⌘I)`}
575
580
  >
576
- <i className="fa-solid fa-sparkles text-[16px]" aria-hidden="true" />
581
+ <ChatBrandIcon className="text-[16px]" sizePx={16} />
577
582
  </button>
578
583
  )}
579
584
  </>
@@ -20,6 +20,8 @@ import { getIconClass } from '@/lib/icon-utils';
20
20
  import { getTabsFromConfig } from '@/lib/navigation-utils';
21
21
  import { useLinkPrefix } from '@/lib/link-prefix-context';
22
22
  import { useChatOpenHandler } from '@/hooks/useChatPanel';
23
+ import { ChatBrandIcon } from '@/components/chat/ChatBrandIcon';
24
+ import { useChatLabel } from '@/components/chat/useChatLabel';
23
25
  import { useSearchOpenHandler } from '@/hooks/useSearchOpenHandler';
24
26
  import { useDelayedNavigationSpinner } from '@/hooks/useDelayedNavigationSpinner';
25
27
 
@@ -213,6 +215,7 @@ export function Sidebar({ config, layout = 'header-logo', tabsPosition: tabsPosi
213
215
  const pathname = usePathname() ?? '';
214
216
  const router = useRouter();
215
217
  const linkPrefix = useLinkPrefix();
218
+ const chatLabel = useChatLabel('Ask AI');
216
219
  // Seed both `expanded: true` groups and the current page's ancestor groups
217
220
  // synchronously so SSR HTML matches first client paint. A useEffect-only seed
218
221
  // (the previous approach) renders the sidebar collapsed on the server, then
@@ -938,10 +941,10 @@ export function Sidebar({ config, layout = 'header-logo', tabsPosition: tabsPosi
938
941
  <button
939
942
  onClick={onChatOpen}
940
943
  className="flex-shrink-0 flex items-center justify-center w-9 h-9 bg-[var(--color-bg-secondary)] border border-[var(--color-border)] rounded-lg text-[var(--color-text-muted)] hover:border-[var(--color-border-hover)] hover:bg-[var(--color-bg-tertiary)] transition-colors cursor-pointer"
941
- aria-label="Ask AI"
942
- title="Ask AI (⌘I)"
944
+ aria-label={chatLabel}
945
+ title={`${chatLabel} (⌘I)`}
943
946
  >
944
- <i className="fa-solid fa-sparkles text-[14px] text-[var(--color-accent)]" aria-hidden="true" />
947
+ <ChatBrandIcon className="text-[14px] text-[var(--color-accent)]" sizePx={14} />
945
948
  </button>
946
949
  )}
947
950
  </div>
@@ -4,13 +4,20 @@ import { useMemo } from 'react';
4
4
  import Link from 'next/link';
5
5
  import { usePathname } from 'next/navigation';
6
6
  // Icons use Font Awesome CSS classes for lightweight rendering
7
- import type { DocsConfig, TabsPosition } from '@/lib/docs-types';
7
+ import type { DocsConfig, TabsAlignment, TabsPosition } from '@/lib/docs-types';
8
8
  import { resolveNavigation } from '@/lib/navigation-resolver';
9
9
  import { getIconClass } from '@/lib/icon-utils';
10
10
  import { getTheme } from '@/themes';
11
11
  import { getTabsFromConfig } from '@/lib/navigation-utils';
12
12
  import { useLinkPrefix } from '@/lib/link-prefix-context';
13
13
 
14
+ // Logical flex justification: under dir="rtl" `start` stays at the reading start.
15
+ export function tabsJustifyClass(alignment: TabsAlignment | undefined): 'justify-start' | 'justify-center' | 'justify-end' {
16
+ if (alignment === 'center') return 'justify-center';
17
+ if (alignment === 'end') return 'justify-end';
18
+ return 'justify-start';
19
+ }
20
+
14
21
  interface TabsNavProps {
15
22
  config: DocsConfig;
16
23
  className?: string;
@@ -114,7 +121,7 @@ export function TabsNav({ config, className = '' }: TabsNavProps) {
114
121
  would not flip under `dir="rtl"` and would invert the misalignment
115
122
  for Arabic/Hebrew docs sites with `tabsPosition: 'top'`. */}
116
123
  <div
117
- className="flex flex-wrap items-center gap-x-2 gap-y-1 py-3"
124
+ className={`flex flex-wrap items-center gap-x-2 gap-y-1 py-3 ${tabsJustifyClass(config.tabsAlignment)}`}
118
125
  style={{ marginBlock: 0, marginInline: '12px 24px', borderBottom: '0.5px solid var(--color-border)' }}
119
126
  >
120
127
  {tabsWithPaths.map((tab) => {
@@ -11,6 +11,8 @@ import { useLinkPrefix } from '@/lib/link-prefix-context';
11
11
  import { useProjectSlug } from '@/lib/project-slug-context';
12
12
  import type { SearchResult, SearchGroup } from '@/lib/search-client';
13
13
  import { useChatPanelOptional } from '@/hooks/useChatPanel';
14
+ import { ChatBrandIcon } from '@/components/chat/ChatBrandIcon';
15
+ import { useChatLabel } from '@/components/chat/useChatLabel';
14
16
  import { modifierKeyLabel } from '@/lib/platform-keys';
15
17
  import { getIconString, type IconConfig } from '@/lib/docs-types';
16
18
  import { getIconClass } from '@/lib/icon-utils';
@@ -145,6 +147,7 @@ export function SearchModal({ isOpen, onClose, popularPages, onNavigate, placeho
145
147
  const linkPrefix = useLinkPrefix();
146
148
  const projectSlug = useProjectSlug();
147
149
  const chatPanel = useChatPanelOptional();
150
+ const chatLabel = useChatLabel('Ask AI');
148
151
  // Ask AI is offered only when the docs chrome (ChatPanelProvider) is present
149
152
  // AND chat is enabled. The 404 page renders SearchModal without the provider,
150
153
  // so chatPanel is null there and the affordance is hidden.
@@ -747,10 +750,10 @@ export function SearchModal({ isOpen, onClose, popularPages, onNavigate, placeho
747
750
  type="button"
748
751
  onClick={handleAskAi}
749
752
  className="inline-flex items-center gap-1.5 px-2 py-1 rounded-md text-[11px] font-medium text-[var(--color-accent)] hover:bg-[var(--color-bg-tertiary)] transition-colors cursor-pointer focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-accent)]"
750
- aria-label={`Ask AI about ${query.trim()}`}
753
+ aria-label={`${chatLabel} about ${query.trim()}`}
751
754
  >
752
- <i className="fa-solid fa-sparkles text-[10px]" aria-hidden="true" />
753
- <span>Ask AI</span>
755
+ <ChatBrandIcon className="text-[10px]" sizePx={10} />
756
+ <span>{chatLabel}</span>
754
757
  {/* One keycap for the whole chord (⌘/Ctrl + ↵). Mouse-only:
755
758
  hidden on touch (coarse pointer), where there's no shortcut
756
759
  to press and the label tap suffices. */}