jamdesk 1.1.174 → 1.1.176

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.
@@ -12,6 +12,7 @@ import {
12
12
  type ReactNode,
13
13
  } from 'react';
14
14
 
15
+ import { useBodyScrollLock } from '@/hooks/useBodyScrollLock';
15
16
  import { useOnClickOutside } from '@/hooks/useOnClickOutside';
16
17
  import { renderInlineMarkdown } from '@/lib/inline-markdown';
17
18
  import { decodePromptSource } from '@/lib/prompt-source-codec';
@@ -70,6 +71,197 @@ function ActionIcon({ action }: { action: ResolvedPromptAction['action'] }): Rea
70
71
  return null;
71
72
  }
72
73
 
74
+ interface PromptCopyButtonProps {
75
+ copyState: CopyState;
76
+ className: string;
77
+ onCopy: () => void;
78
+ }
79
+
80
+ function PromptCopyButton({
81
+ copyState,
82
+ className,
83
+ onCopy,
84
+ }: PromptCopyButtonProps): ReactElement {
85
+ const label = copyState === 'copied'
86
+ ? 'Copied'
87
+ : copyState === 'error'
88
+ ? 'Copy failed'
89
+ : 'Copy prompt';
90
+ return (
91
+ <button
92
+ type="button"
93
+ aria-label="Copy prompt"
94
+ title={label}
95
+ className={className}
96
+ data-copy-state={copyState}
97
+ onClick={onCopy}
98
+ >
99
+ <i
100
+ className={copyState === 'copied'
101
+ ? 'fa-solid fa-check text-[var(--color-success-text)]'
102
+ : copyState === 'error'
103
+ ? 'fa-solid fa-triangle-exclamation text-[var(--color-error-text)]'
104
+ : 'fa-regular fa-copy'}
105
+ aria-hidden="true"
106
+ />
107
+ </button>
108
+ );
109
+ }
110
+
111
+ interface PromptDialogProps {
112
+ isOpen: boolean;
113
+ prompt: string;
114
+ title?: string;
115
+ copyState: CopyState;
116
+ onCopy: () => void;
117
+ onClose: () => void;
118
+ }
119
+
120
+ const contrastSafeFocusClassName = [
121
+ 'focus-visible:outline-none focus-visible:ring-2',
122
+ 'focus-visible:ring-[var(--color-text-primary)] focus-visible:ring-offset-2',
123
+ 'focus-visible:ring-offset-[var(--color-bg-primary)]',
124
+ ].join(' ');
125
+
126
+ const promptUtilityClassName = [
127
+ 'inline-flex size-10 shrink-0 cursor-pointer items-center justify-center rounded-[var(--radius-lg)]',
128
+ 'text-[var(--color-text-tertiary)] transition-colors',
129
+ 'hover:bg-[var(--color-bg-hover)]',
130
+ 'hover:text-[var(--color-primary)]',
131
+ contrastSafeFocusClassName,
132
+ ].join(' ');
133
+
134
+ const primaryActionClassName = [
135
+ 'inline-flex min-h-10 min-w-0 items-center gap-2 px-4 py-2 text-sm font-semibold',
136
+ 'prompt-primary-action bg-[var(--color-primary)] text-white transition-opacity cursor-pointer',
137
+ 'hover:opacity-90',
138
+ contrastSafeFocusClassName,
139
+ ].join(' ');
140
+
141
+ function PromptDialog({
142
+ isOpen,
143
+ prompt,
144
+ title,
145
+ copyState,
146
+ onCopy,
147
+ onClose,
148
+ }: PromptDialogProps): ReactElement {
149
+ const dialogRef = useRef<HTMLDialogElement>(null);
150
+ const focusFrameRef = useRef<number | null>(null);
151
+ useBodyScrollLock(isOpen);
152
+
153
+ useEffect(() => {
154
+ const dialog = dialogRef.current;
155
+ if (!dialog) return;
156
+ if (isOpen && !dialog.open) {
157
+ dialog.showModal();
158
+ focusFrameRef.current = requestAnimationFrame(() => {
159
+ dialog.querySelector<HTMLButtonElement>('[aria-label="Close prompt"]')?.focus();
160
+ });
161
+ } else if (!isOpen && dialog.open) {
162
+ dialog.close();
163
+ }
164
+ return () => {
165
+ if (focusFrameRef.current !== null) cancelAnimationFrame(focusFrameRef.current);
166
+ };
167
+ }, [isOpen]);
168
+
169
+ useEffect(() => {
170
+ if (!isOpen) return;
171
+ const handleKeyDown = (event: KeyboardEvent): void => {
172
+ if (event.key === 'Escape') {
173
+ event.preventDefault();
174
+ event.stopPropagation();
175
+ onClose();
176
+ return;
177
+ }
178
+ if (event.key !== 'Tab' || !dialogRef.current) return;
179
+ const focusable = Array.from(dialogRef.current.querySelectorAll<HTMLElement>(
180
+ 'button, [href], [tabindex]:not([tabindex="-1"])',
181
+ )).filter((element) => !element.hasAttribute('disabled'));
182
+ if (focusable.length === 0) return;
183
+ const first = focusable[0];
184
+ const last = focusable[focusable.length - 1];
185
+ if (event.shiftKey && document.activeElement === first) {
186
+ event.preventDefault();
187
+ last.focus();
188
+ } else if (!event.shiftKey && document.activeElement === last) {
189
+ event.preventDefault();
190
+ first.focus();
191
+ }
192
+ };
193
+ window.addEventListener('keydown', handleKeyDown, true);
194
+ return () => window.removeEventListener('keydown', handleKeyDown, true);
195
+ }, [isOpen, onClose]);
196
+
197
+ return (
198
+ <dialog
199
+ ref={dialogRef}
200
+ aria-modal="true"
201
+ aria-label={title ?? 'Prompt'}
202
+ className="prompt-dialog m-auto max-h-[calc(100dvh-2rem)] w-[calc(100%-2rem)] max-w-4xl overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-bg-primary)] p-0 text-[var(--color-text-primary)] shadow-[var(--shadow-lg)]"
203
+ onCancel={(event) => {
204
+ event.preventDefault();
205
+ onClose();
206
+ }}
207
+ onMouseDown={(event) => {
208
+ if (event.target === event.currentTarget) onClose();
209
+ }}
210
+ >
211
+ <div className="flex max-h-[calc(100dvh-2rem)] min-h-0 flex-col">
212
+ <div className="flex shrink-0 items-center justify-between gap-3 border-b border-[var(--color-border)] px-4 py-3">
213
+ <h2 className="prompt-dialog-title m-0 min-w-0 truncate text-sm font-semibold text-[var(--color-text-primary)]">
214
+ {title ?? 'Prompt'}
215
+ </h2>
216
+ <button
217
+ type="button"
218
+ aria-label="Close prompt"
219
+ className={promptUtilityClassName}
220
+ onClick={onClose}
221
+ >
222
+ <i className="fa-solid fa-xmark" aria-hidden="true" />
223
+ </button>
224
+ </div>
225
+ <div className="flex min-h-0 flex-1 gap-3 overflow-hidden p-4">
226
+ <pre
227
+ data-testid="prompt-dialog-source"
228
+ role="region"
229
+ aria-label="Prompt source"
230
+ tabIndex={0}
231
+ className={`m-0 min-h-0 min-w-0 flex-1 overflow-auto overscroll-contain whitespace-pre-wrap break-words font-mono text-sm leading-6 text-[var(--color-text-primary)] ${contrastSafeFocusClassName}`}
232
+ >
233
+ {prompt}
234
+ </pre>
235
+ <PromptCopyButton
236
+ copyState={copyState}
237
+ className={promptUtilityClassName}
238
+ onCopy={onCopy}
239
+ />
240
+ </div>
241
+ <span role="status" aria-live="polite" className="sr-only">
242
+ {copyState === 'copied' ? 'Copied' : copyState === 'error' ? 'Copy failed' : ''}
243
+ </span>
244
+ </div>
245
+ <style jsx>{`
246
+ .prompt-dialog::backdrop {
247
+ background: var(--color-text-primary);
248
+ opacity: 0.6;
249
+ backdrop-filter: blur(4px);
250
+ }
251
+ /* Outranks the docs themes' .prose h2 rules, which reach into the
252
+ dialog and would inflate this chrome heading. */
253
+ .prompt-dialog .prompt-dialog-title {
254
+ margin: 0;
255
+ font-size: 0.875rem;
256
+ line-height: 1.5;
257
+ font-weight: 600;
258
+ letter-spacing: normal;
259
+ }
260
+ `}</style>
261
+ </dialog>
262
+ );
263
+ }
264
+
73
265
  export function Prompt({
74
266
  children,
75
267
  title,
@@ -84,9 +276,12 @@ export function Prompt({
84
276
  }: PromptProps): ReactElement {
85
277
  const [copyState, setCopyState] = useState<CopyState>('idle');
86
278
  const [menuOpen, setMenuOpen] = useState(false);
279
+ const [dialogOpen, setDialogOpen] = useState(false);
87
280
  const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
88
281
  const copyAttemptRef = useRef(0);
89
282
  const mountedRef = useRef(false);
283
+ const expandTriggerRef = useRef<HTMLButtonElement>(null);
284
+ const returnFocusFrameRef = useRef<number | null>(null);
90
285
  const menuBoundaryRef = useRef<HTMLDivElement>(null);
91
286
  const triggerRef = useRef<HTMLButtonElement>(null);
92
287
  const itemRefs = useRef<Array<HTMLAnchorElement | null>>([]);
@@ -105,6 +300,8 @@ export function Prompt({
105
300
  const hasTitle = Boolean(title?.trim());
106
301
  const shouldHidePrompt = hasTitle && (hidePrompt === true || hidePrompt === 'true');
107
302
  const authorIcon = resolveAuthorIcon(icon, iconType);
303
+ const hasDescription = description !== undefined;
304
+ const hasHeader = hasTitle || resolvedActions.length > 0;
108
305
 
109
306
  const closeMenu = useCallback(() => {
110
307
  setMenuOpen(false);
@@ -115,6 +312,13 @@ export function Prompt({
115
312
  triggerRef.current?.focus();
116
313
  }, []);
117
314
 
315
+ const closeDialog = useCallback(() => {
316
+ setDialogOpen(false);
317
+ returnFocusFrameRef.current = requestAnimationFrame(() => {
318
+ expandTriggerRef.current?.focus();
319
+ });
320
+ }, []);
321
+
118
322
  useOnClickOutside(menuBoundaryRef, closeMenu, menuOpen);
119
323
 
120
324
  useEffect(() => {
@@ -126,6 +330,12 @@ export function Prompt({
126
330
  };
127
331
  }, []);
128
332
 
333
+ useEffect(() => () => {
334
+ if (returnFocusFrameRef.current !== null) {
335
+ cancelAnimationFrame(returnFocusFrameRef.current);
336
+ }
337
+ }, []);
338
+
129
339
  useEffect(() => {
130
340
  if (menuOpen) itemRefs.current[0]?.focus();
131
341
  }, [menuOpen]);
@@ -193,157 +403,208 @@ export function Prompt({
193
403
  }, [closeMenuAndFocusTrigger, secondary.length]);
194
404
 
195
405
  const rootClassName = [
196
- 'not-prose my-4 min-w-0 max-w-full overflow-visible',
197
- 'rounded-[var(--radius-lg)] border border-[var(--color-border)]',
198
- 'bg-[var(--color-bg-secondary)] text-[var(--color-text-primary)]',
199
- 'shadow-[var(--shadow-sm)]',
406
+ 'prompt-card-fern not-prose my-4 min-w-0 max-w-full overflow-visible',
407
+ 'rounded-[calc(var(--radius-lg)+0.25rem)] border text-[var(--color-text-primary)]',
200
408
  className,
201
409
  ].filter(Boolean).join(' ');
202
410
 
203
- const actionClassName = [
204
- 'inline-flex min-w-0 max-w-full items-center gap-2 rounded-[var(--radius-lg)]',
205
- 'border border-[var(--color-border)] px-3 py-2 text-sm font-medium',
206
- 'text-[var(--color-text-primary)] transition-colors cursor-pointer',
207
- 'hover:border-[var(--color-primary)] hover:text-[var(--color-primary)]',
208
- 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-primary)]',
209
- 'disabled:cursor-not-allowed disabled:opacity-50',
210
- ].join(' ');
211
-
212
411
  return (
213
412
  <section data-testid="prompt-card" className={rootClassName}>
214
- <div
215
- data-testid={!hasTitle ? 'prompt-body-row' : undefined}
216
- className={hasTitle
217
- ? 'contents'
218
- : 'flex min-w-0 max-w-full flex-wrap items-start gap-3 px-4 py-4'}
219
- >
413
+ {hasHeader ? (
220
414
  <div
221
- data-testid={hasTitle ? 'prompt-header' : undefined}
222
- className={hasTitle
223
- ? 'flex min-w-0 max-w-full items-center gap-3 px-4 pt-4'
224
- : 'contents'}
415
+ data-testid="prompt-header"
416
+ className="flex min-w-0 flex-col items-stretch gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:px-5"
225
417
  >
226
- <span
227
- data-testid="prompt-icon"
228
- className="shrink-0 text-[var(--color-primary)]"
229
- aria-hidden="true"
230
- >
231
- <Icon icon={authorIcon} size={18} />
232
- </span>
233
418
  {hasTitle ? (
234
- <h3 className="m-0 min-w-0 max-w-full break-words [overflow-wrap:anywhere] text-base font-semibold text-[var(--color-text-primary)]">
419
+ <h3 className="prompt-title m-0 w-full min-w-0 break-words [overflow-wrap:anywhere] text-base font-semibold text-[var(--color-text-primary)] sm:w-auto sm:flex-1">
235
420
  {title}
236
421
  </h3>
237
422
  ) : null}
238
- </div>
239
-
240
- {!shouldHidePrompt ? (
241
423
  <div
242
- data-testid="prompt-preview"
243
- className={`prompt-preview min-w-0 max-w-full text-sm leading-6 text-[var(--color-text-tertiary)]${hasTitle ? ' mx-4 mt-3' : ' flex-1'}${isSingleLine ? ' prompt-preview-single-line' : ''}`}
424
+ data-testid="prompt-actions"
425
+ className="flex min-w-0 max-w-full items-stretch justify-end self-end sm:ml-auto sm:self-auto"
244
426
  >
245
- {description !== undefined ? renderInlineMarkdown(description) : children}
427
+ {primary ? (
428
+ <a
429
+ href={primary.href}
430
+ target="_blank"
431
+ rel="noopener noreferrer"
432
+ aria-label={primary.label}
433
+ className={`${primaryActionClassName} ${secondary.length > 0 ? 'rounded-l-[var(--radius-lg)]' : 'rounded-[var(--radius-lg)]'}`}
434
+ >
435
+ <span aria-hidden="true"><ActionIcon action={primary.action} /></span>
436
+ <span className="min-w-0 truncate">{primary.label}</span>
437
+ </a>
438
+ ) : null}
439
+ {secondary.length > 0 ? (
440
+ <div ref={menuBoundaryRef} className="relative flex shrink-0">
441
+ <button
442
+ ref={triggerRef}
443
+ type="button"
444
+ aria-label="More prompt actions"
445
+ aria-haspopup="menu"
446
+ aria-expanded={menuOpen}
447
+ aria-controls={menuId}
448
+ className={`${primaryActionClassName} min-w-10 rounded-r-[var(--radius-lg)] border-l border-[var(--color-bg-primary)] px-3`}
449
+ onMouseDown={(event) => {
450
+ if (menuOpen) event.preventDefault();
451
+ }}
452
+ onClick={() => {
453
+ setMenuOpen((open) => !open);
454
+ if (menuOpen) triggerRef.current?.focus();
455
+ }}
456
+ >
457
+ <i className="fa-solid fa-chevron-down text-xs" aria-hidden="true" />
458
+ </button>
459
+ {menuOpen ? (
460
+ <div
461
+ id={menuId}
462
+ role="menu"
463
+ aria-label="More prompt actions"
464
+ className="absolute right-0 top-full z-20 mt-2 w-max max-w-[min(20rem,calc(100vw-2rem))] rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-bg-primary)] p-1 shadow-[var(--shadow-sm)]"
465
+ onKeyDown={handleMenuKeyDown}
466
+ onBlur={(event) => {
467
+ if (!event.currentTarget.contains(event.relatedTarget)) closeMenu();
468
+ }}
469
+ >
470
+ {secondary.map((action, index) => (
471
+ <a
472
+ key={`${action.label}-${action.href}-${index}`}
473
+ ref={(node) => { itemRefs.current[index] = node; }}
474
+ role="menuitem"
475
+ tabIndex={-1}
476
+ href={action.href}
477
+ target="_blank"
478
+ rel="noopener noreferrer"
479
+ aria-label={action.label}
480
+ className={`prompt-menu-item flex min-h-10 min-w-0 max-w-full cursor-pointer items-center gap-2 rounded-[var(--radius-lg)] px-3 py-2 text-sm text-[var(--color-text-primary)] hover:bg-[var(--color-border)] ${contrastSafeFocusClassName}`}
481
+ onClick={closeMenuAndFocusTrigger}
482
+ >
483
+ <span aria-hidden="true"><ActionIcon action={action.action} /></span>
484
+ <span className="min-w-0 truncate">{action.label}</span>
485
+ </a>
486
+ ))}
487
+ </div>
488
+ ) : null}
489
+ </div>
490
+ ) : null}
491
+ {shouldHidePrompt && hasPrompt ? (
492
+ <PromptCopyButton
493
+ copyState={copyState}
494
+ className={`${promptUtilityClassName} ml-2`}
495
+ onCopy={copyPrompt}
496
+ />
497
+ ) : null}
246
498
  </div>
247
- ) : null}
499
+ </div>
500
+ ) : null}
248
501
 
502
+ {!shouldHidePrompt ? (
249
503
  <div
250
- data-testid="prompt-actions"
251
- className={`flex min-w-0 max-w-full flex-wrap items-center gap-2${hasTitle ? ' px-4 pb-4 pt-3' : ' basis-full sm:ml-auto sm:basis-auto'}`}
504
+ data-testid="prompt-well"
505
+ className={`prompt-well mx-3 mb-3 flex min-w-0 items-start gap-3 rounded-[var(--radius-lg)] border bg-[var(--color-bg-primary)] px-3 py-3 sm:mx-4 sm:mb-4 sm:px-4 ${hasHeader ? '' : 'mt-3 sm:mt-4'}`}
252
506
  >
253
- <button
254
- type="button"
255
- aria-label="Copy prompt"
256
- className={actionClassName}
257
- data-copy-state={copyState}
258
- disabled={!hasPrompt}
259
- onClick={copyPrompt}
507
+ <span
508
+ data-testid="prompt-icon"
509
+ className="mt-1 shrink-0 text-[var(--color-primary)]"
510
+ aria-hidden="true"
260
511
  >
261
- <i className="fa-regular fa-copy shrink-0" aria-hidden="true" />
262
- <span>{copyState === 'copied' ? 'Copied' : copyState === 'error' ? 'Copy failed' : 'Copy'}</span>
263
- </button>
264
-
265
- <span role="status" aria-live="polite" className="sr-only">
266
- {copyState === 'copied' ? 'Copied' : copyState === 'error' ? 'Copy failed' : ''}
512
+ <Icon icon={authorIcon} size={18} />
267
513
  </span>
268
-
269
- {primary ? (
270
- <a
271
- href={primary.href}
272
- target="_blank"
273
- rel="noopener noreferrer"
274
- className={actionClassName}
275
- aria-label={primary.label}
276
- >
277
- <span aria-hidden="true"><ActionIcon action={primary.action} /></span>
278
- <span className="min-w-0 truncate">{primary.label}</span>
279
- </a>
280
- ) : null}
281
-
282
- {secondary.length > 0 ? (
283
- <div
284
- ref={menuBoundaryRef}
285
- className="relative flex min-w-0 max-w-full basis-full w-full shrink-0 justify-end sm:ml-auto sm:basis-auto sm:w-auto sm:shrink"
286
- >
514
+ <div
515
+ data-testid="prompt-preview"
516
+ className={`prompt-preview min-w-0 flex-1 text-sm leading-6 text-[var(--color-text-primary)]${hasPrompt && !hasDescription ? ' font-mono whitespace-pre-wrap' : ''}${isSingleLine ? ' prompt-preview-single-line' : ''}`}
517
+ >
518
+ {hasDescription ? renderInlineMarkdown(description) : prompt || children}
519
+ </div>
520
+ {hasPrompt ? (
521
+ <div data-testid="prompt-utilities" className="flex shrink-0 items-center gap-1">
287
522
  <button
288
- ref={triggerRef}
523
+ ref={expandTriggerRef}
289
524
  type="button"
290
- aria-label="More prompt actions"
291
- aria-haspopup="menu"
292
- aria-expanded={menuOpen}
293
- aria-controls={menuId}
294
- className={actionClassName}
295
- onMouseDown={(event) => {
296
- if (menuOpen) event.preventDefault();
297
- }}
298
- onClick={() => {
299
- setMenuOpen((open) => !open);
300
- if (menuOpen) triggerRef.current?.focus();
301
- }}
525
+ aria-label="Expand prompt"
526
+ title="Expand prompt"
527
+ className={promptUtilityClassName}
528
+ onClick={() => setDialogOpen(true)}
302
529
  >
303
- <span>More</span>
304
- <i className="fa-solid fa-chevron-down text-xs" aria-hidden="true" />
530
+ <i className="fa-solid fa-expand" aria-hidden="true" />
305
531
  </button>
306
-
307
- {menuOpen ? (
308
- <div
309
- id={menuId}
310
- role="menu"
311
- aria-label="More prompt actions"
312
- className="absolute top-full right-0 z-20 mt-2 w-max max-w-[min(20rem,calc(100vw-2rem))] rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-bg-secondary)] p-1 shadow-[var(--shadow-sm)]"
313
- onKeyDown={handleMenuKeyDown}
314
- onBlur={(event) => {
315
- if (!event.currentTarget.contains(event.relatedTarget)) {
316
- closeMenu();
317
- }
318
- }}
319
- >
320
- {secondary.map((action, index) => (
321
- <a
322
- key={`${action.label}-${action.href}-${index}`}
323
- ref={(node) => { itemRefs.current[index] = node; }}
324
- role="menuitem"
325
- tabIndex={-1}
326
- href={action.href}
327
- target="_blank"
328
- rel="noopener noreferrer"
329
- aria-label={action.label}
330
- className="flex min-w-0 max-w-full cursor-pointer items-center gap-2 rounded-[var(--radius-lg)] px-3 py-2 text-sm text-[var(--color-text-primary)] hover:bg-[var(--color-border)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-primary)]"
331
- onClick={closeMenuAndFocusTrigger}
332
- >
333
- <span aria-hidden="true"><ActionIcon action={action.action} /></span>
334
- <span className="min-w-0 truncate">{action.label}</span>
335
- </a>
336
- ))}
337
- </div>
338
- ) : null}
532
+ <PromptCopyButton
533
+ copyState={copyState}
534
+ className={promptUtilityClassName}
535
+ onCopy={copyPrompt}
536
+ />
339
537
  </div>
340
538
  ) : null}
341
539
  </div>
342
- </div>
540
+ ) : null}
541
+ {!dialogOpen ? (
542
+ <span role="status" aria-live="polite" className="sr-only">
543
+ {copyState === 'copied' ? 'Copied' : copyState === 'error' ? 'Copy failed' : ''}
544
+ </span>
545
+ ) : null}
546
+
547
+ <PromptDialog
548
+ isOpen={dialogOpen}
549
+ prompt={prompt}
550
+ title={hasTitle ? title!.trim() : undefined}
551
+ copyState={copyState}
552
+ onCopy={copyPrompt}
553
+ onClose={closeDialog}
554
+ />
343
555
 
344
556
  <style jsx>{`
557
+ .prompt-card-fern {
558
+ border-color: var(--color-primary);
559
+ background: var(--color-bg-secondary);
560
+ }
561
+ .prompt-well {
562
+ border-color: var(--color-border);
563
+ }
564
+ .prompt-primary-action {
565
+ background: var(--color-primary);
566
+ color: #fff;
567
+ }
568
+ .prompt-title {
569
+ color: var(--color-text-primary);
570
+ }
571
+ /* The docs themes' prose CSS is not Tailwind Typography — .prose h3 and
572
+ .prose a match descendants even inside .not-prose, and the jam theme's
573
+ body[data-theme] variants outrank Tailwind utilities. These scoped
574
+ rules win on specificity to keep the header compact and link-free. */
575
+ .prompt-card-fern .prompt-title {
576
+ margin: 0;
577
+ font-size: 1rem;
578
+ line-height: 1.5;
579
+ font-weight: 600;
580
+ letter-spacing: normal;
581
+ }
582
+ .prompt-card-fern .prompt-primary-action {
583
+ text-decoration: none;
584
+ font-weight: 600;
585
+ }
586
+ .prompt-card-fern .prompt-menu-item {
587
+ text-decoration: none;
588
+ font-weight: 400;
589
+ color: var(--color-text-primary);
590
+ }
591
+ @supports (color: color-mix(in srgb, black, white)) {
592
+ .prompt-card-fern {
593
+ border-color: color-mix(in srgb, var(--color-primary) 48%, var(--color-border));
594
+ background: color-mix(in srgb, var(--color-primary) 7%, var(--color-bg-primary));
595
+ }
596
+ .prompt-well {
597
+ border-color: color-mix(in srgb, var(--color-border) 88%, var(--color-primary));
598
+ box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-primary) 3%, transparent);
599
+ }
600
+ /* 40% primary keeps >=4.5:1 against the card background even when
601
+ --color-primary is set to the mode's background extreme; 45% fails. */
602
+ .prompt-card-fern .prompt-title {
603
+ color: color-mix(in srgb, var(--color-primary) 40%, var(--color-text-primary));
604
+ }
605
+ }
345
606
  .prompt-preview {
346
- max-height: 7.5rem;
607
+ max-height: 10rem;
347
608
  overflow-y: auto;
348
609
  overflow-wrap: anywhere;
349
610
  }
@@ -760,7 +760,11 @@ export function Sidebar({ config, layout = 'header-logo', tabsPosition: tabsPosi
760
760
  const visibilityClass = showTabsInSidebar ? '' : 'lg:hidden';
761
761
 
762
762
  return (
763
- <div className={`sidebar-tabs flex flex-col gap-2 px-4 pt-2 pb-6 ${visibilityClass}`}>
763
+ // Inline-start padding is 12px (pl-3), not 16px, so each tab's px-3
764
+ // padding lands its icon at 24px — flush with the sidebar group icons
765
+ // (ml-6) below — and the active tab's highlight left edge lands at 12px
766
+ // to match the lower nav items' pill (.sidebar-scroll ...::before).
767
+ <div className={`sidebar-tabs flex flex-col gap-2 pl-3 pr-4 pt-2 pb-6 ${visibilityClass}`}>
764
768
  {resolvedNav.tabs.map((tab) => {
765
769
  const isActive = tab.name === resolvedNav.activeTab;
766
770
 
@@ -30,9 +30,10 @@ interface CaptureResult {
30
30
  error?: string;
31
31
  }
32
32
 
33
- // Headroom for the retry path: two 15s page.goto budgets + 3s settle wait
34
- // already total 33s before the screenshot is even taken. Launch retries
35
- // (closed-target only) fail fast (<~2s) so they stay inside this 45s race.
33
+ // Headroom for the retry path: two 15s page.goto('load') budgets + 3s retry
34
+ // wait + 2s best-effort networkidle + 1s settle total ~36s worst case before
35
+ // the screenshot is taken. Launch retries (closed-target only) fail fast
36
+ // (<~2s) so they stay inside this 45s race.
36
37
  const SCREENSHOT_TIMEOUT_MS = 45000;
37
38
 
38
39
  // Playwright launch flakes intermittently in the container — the Chromium
@@ -201,22 +202,31 @@ export async function captureHomepageScreenshot(options: CaptureOptions): Promis
201
202
  // Cold render after a project-wide revalidation can transiently 404 —
202
203
  // retry once on 4xx/5xx, then capture whatever the page shows so the
203
204
  // dashboard preview reflects the real state of the site.
205
+ //
206
+ // Wait for 'load' (document + images/CSS/fonts), NOT 'networkidle'. A
207
+ // custom-domain-only subdomain 404s the client's RSC nav prefetches
208
+ // (`/docs/<page>?_rsc=…`) — they're built from the route and carry no
209
+ // ?jd_proxy=1 marker — so Next keeps retrying them and a large docs site
210
+ // (jamdesk-docs) NEVER goes network-idle. 'networkidle' timed out at 15s
211
+ // and every capture/Retry failed. 'load' fires before the prefetch storm.
212
+ const gotoOpts = { waitUntil: 'load', timeout: 15000 } as const;
204
213
  console.log(`Capturing screenshot from: ${homepageUrl}`);
205
- const firstResponse = await page.goto(homepageUrl, {
206
- waitUntil: 'networkidle',
207
- timeout: 15000,
208
- });
214
+ const firstResponse = await page.goto(homepageUrl, gotoOpts);
209
215
  const firstStatus = firstResponse?.status();
210
216
 
211
217
  if (firstStatus !== undefined && firstStatus >= 400) {
212
218
  console.log(`Screenshot got ${firstStatus} on first attempt, retrying once after 3s`);
213
219
  await page.waitForTimeout(3000);
214
- await page.goto(homepageUrl, {
215
- waitUntil: 'networkidle',
216
- timeout: 15000,
217
- });
220
+ await page.goto(homepageUrl, gotoOpts);
218
221
  }
219
222
 
223
+ // Short best-effort settle for late images/fonts — the page above already
224
+ // reached 'load', and a never-idle page (see the prefetch note above)
225
+ // would otherwise burn the full timeout for nothing. Never fail the
226
+ // capture on it. Mirrors the pdf-service networkidle fallback
227
+ // (worker-pool.ts).
228
+ await page.waitForLoadState('networkidle', { timeout: 2000 }).catch(() => {});
229
+
220
230
  // Additional wait for any late-loading content
221
231
  await page.waitForTimeout(1000);
222
232