@djangocfg/ui-tools 2.1.286 → 2.1.289

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 (34) hide show
  1. package/dist/{DocsLayout-ERETJLLV.mjs → DocsLayout-TKJQ5W5E.mjs} +848 -266
  2. package/dist/DocsLayout-TKJQ5W5E.mjs.map +1 -0
  3. package/dist/{DocsLayout-BCVU6TTX.cjs → DocsLayout-YDR7DSMM.cjs} +843 -261
  4. package/dist/DocsLayout-YDR7DSMM.cjs.map +1 -0
  5. package/dist/index.cjs +2 -2
  6. package/dist/index.d.cts +16 -0
  7. package/dist/index.d.ts +16 -0
  8. package/dist/index.mjs +2 -2
  9. package/package.json +9 -6
  10. package/src/tools/OpenapiViewer/.claude/.sidecar/activity.jsonl +2 -0
  11. package/src/tools/OpenapiViewer/.claude/.sidecar/history/2026-04-22.md +35 -0
  12. package/src/tools/OpenapiViewer/.claude/.sidecar/review.md +35 -0
  13. package/src/tools/OpenapiViewer/.claude/.sidecar/scan.log +3 -0
  14. package/src/tools/OpenapiViewer/.claude/.sidecar/tasks/T-001.md +18 -0
  15. package/src/tools/OpenapiViewer/.claude/.sidecar/tasks/T-002.md +18 -0
  16. package/src/tools/OpenapiViewer/.claude/.sidecar/tasks/T-003.md +18 -0
  17. package/src/tools/OpenapiViewer/.claude/.sidecar/tasks/T-004.md +18 -0
  18. package/src/tools/OpenapiViewer/.claude/.sidecar/tasks/T-005.md +18 -0
  19. package/src/tools/OpenapiViewer/.claude/.sidecar/usage.json +2 -2
  20. package/src/tools/OpenapiViewer/OpenapiViewer.story.tsx +27 -0
  21. package/src/tools/OpenapiViewer/components/DocsLayout/DocsView.tsx +326 -54
  22. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc.tsx +7 -2
  23. package/src/tools/OpenapiViewer/components/DocsLayout/SchemaCopyMenu.tsx +32 -9
  24. package/src/tools/OpenapiViewer/components/DocsLayout/Sidebar.tsx +348 -120
  25. package/src/tools/OpenapiViewer/components/DocsLayout/anchor.ts +19 -2
  26. package/src/tools/OpenapiViewer/components/DocsLayout/grouping.ts +38 -21
  27. package/src/tools/OpenapiViewer/components/DocsLayout/index.tsx +168 -50
  28. package/src/tools/OpenapiViewer/hooks/index.ts +3 -1
  29. package/src/tools/OpenapiViewer/hooks/useDocsUrlSync.ts +119 -0
  30. package/src/tools/OpenapiViewer/hooks/useOpenApiSchema.ts +127 -7
  31. package/src/tools/OpenapiViewer/types.ts +36 -1
  32. package/src/tools/OpenapiViewer/utils/scrollParent.ts +68 -0
  33. package/dist/DocsLayout-BCVU6TTX.cjs.map +0 -1
  34. package/dist/DocsLayout-ERETJLLV.mjs.map +0 -1
@@ -2,17 +2,28 @@
2
2
 
3
3
  import React, { useCallback, useEffect, useMemo, useRef } from 'react';
4
4
 
5
- import type { ApiEndpoint, OpenApiInfo, OpenApiSchema } from '../../types';
5
+ import type { ApiEndpoint, LoadedSchemaEntry, OpenApiInfo, OpenApiSchema } from '../../types';
6
+ import {
7
+ getScrollParent,
8
+ getScrollTop,
9
+ getTargetTop,
10
+ getViewportHeight,
11
+ scrollTargetTo,
12
+ type ScrollTarget,
13
+ } from '../../utils/scrollParent';
6
14
  import { deduplicateEndpoints } from '../../utils/versionManager';
7
15
  import { ApiIntroSection } from './ApiIntroSection';
8
16
  import { EndpointDoc } from './EndpointDoc';
9
- import { endpointAnchor } from './anchor';
17
+ import { SchemaCopyMenu } from './SchemaCopyMenu';
10
18
 
11
19
  export interface DocsViewHandle {
12
20
  scrollToAnchor: (anchor: string) => void;
13
21
  }
14
22
 
15
- interface DocsViewProps {
23
+ // ─── Props ───────────────────────────────────────────────────────────────────
24
+
25
+ interface SelectorProps {
26
+ grouping?: 'selector';
16
27
  info: OpenApiInfo | null;
17
28
  rawSchema: OpenApiSchema | null;
18
29
  resolvedBaseUrl?: string;
@@ -20,48 +31,169 @@ interface DocsViewProps {
20
31
  selectedVersion: string;
21
32
  loadedEndpoint: ApiEndpoint | null;
22
33
  onTryEndpoint: (ep: ApiEndpoint) => void;
23
- onActiveChange: (anchor: string | null) => void;
34
+ onActiveChange: (anchor: string | null, schemaId: string | null) => void;
35
+ }
36
+
37
+ interface SectionsProps {
38
+ grouping: 'sections';
39
+ /** Per-schema data (info + endpoints). Rendered in order. */
40
+ schemasData: LoadedSchemaEntry[];
41
+ selectedVersion: string;
42
+ loadedEndpoint: ApiEndpoint | null;
43
+ onTryEndpoint: (ep: ApiEndpoint) => void;
44
+ onActiveChange: (anchor: string | null, schemaId: string | null) => void;
45
+ }
46
+
47
+ type DocsViewProps = SelectorProps | SectionsProps;
48
+
49
+ // ─── View-model types ────────────────────────────────────────────────────────
50
+
51
+ interface EndpointRow {
52
+ key: string;
53
+ endpoint: ApiEndpoint;
54
+ isLoaded: boolean;
55
+ schemaId: string | null;
56
+ }
57
+
58
+ type SectionState =
59
+ | { kind: 'ready'; rows: EndpointRow[] }
60
+ | { kind: 'loading' }
61
+ | { kind: 'error'; message: string }
62
+ | { kind: 'empty' };
63
+
64
+ interface SchemaSectionVM {
65
+ schemaId: string;
66
+ title: string;
67
+ version: string | null;
68
+ description: string | null;
69
+ state: SectionState;
70
+ /** Copy-for-AI payload. ``null`` when the section is still loading
71
+ * or failed — the dropdown stays disabled. */
72
+ rawSchema: OpenApiSchema | null;
73
+ baseUrl: string | undefined;
74
+ allEndpoints: ApiEndpoint[];
75
+ }
76
+
77
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
78
+
79
+ /** Pixel offset from the top of the scroll container where the viewer
80
+ * should "park" sections. Reads ``--navbar-height`` for back-compat
81
+ * with pages that already set it; defaults to ``0`` for embedded /
82
+ * no-navbar setups (the common case when hosted in a shell). */
83
+ const readNavbarOffset = () => {
84
+ if (typeof document === 'undefined') return 0;
85
+ const raw = getComputedStyle(document.documentElement).getPropertyValue('--navbar-height');
86
+ const parsed = parseInt(raw || '', 10);
87
+ return Number.isFinite(parsed) ? parsed : 0;
88
+ };
89
+
90
+ const isSameEndpoint = (a: ApiEndpoint | null, b: ApiEndpoint) =>
91
+ a !== null && a.method === b.method && a.path === b.path;
92
+
93
+ function buildEndpointRow(
94
+ ep: ApiEndpoint,
95
+ loadedEndpoint: ApiEndpoint | null,
96
+ schemaId: string | null,
97
+ ): EndpointRow {
98
+ const keySchema = schemaId ? `${schemaId}-` : '';
99
+ return {
100
+ key: `${keySchema}${ep.method}-${ep.path}`,
101
+ endpoint: ep,
102
+ isLoaded: isSameEndpoint(loadedEndpoint, ep),
103
+ schemaId,
104
+ };
105
+ }
106
+
107
+ function buildSchemaSectionVM(
108
+ entry: LoadedSchemaEntry,
109
+ selectedVersion: string,
110
+ loadedEndpoint: ApiEndpoint | null,
111
+ ): SchemaSectionVM {
112
+ const title = entry.info?.title ?? entry.source.name;
113
+ const version = entry.info?.version ?? null;
114
+ const description = entry.info?.description ?? null;
115
+
116
+ let state: SectionState;
117
+ if (entry.loading) {
118
+ state = { kind: 'loading' };
119
+ } else if (entry.error) {
120
+ state = { kind: 'error', message: entry.error };
121
+ } else {
122
+ const visible = deduplicateEndpoints(entry.endpoints, selectedVersion);
123
+ state = visible.length === 0
124
+ ? { kind: 'empty' }
125
+ : {
126
+ kind: 'ready',
127
+ rows: visible.map((ep) => buildEndpointRow(ep, loadedEndpoint, entry.source.id)),
128
+ };
129
+ }
130
+
131
+ return {
132
+ schemaId: entry.source.id,
133
+ title,
134
+ version,
135
+ description,
136
+ state,
137
+ rawSchema: entry.rawSchema,
138
+ baseUrl: entry.resolvedBaseUrl,
139
+ allEndpoints: entry.endpoints,
140
+ };
24
141
  }
25
142
 
143
+ // ─── Component ───────────────────────────────────────────────────────────────
144
+
26
145
  export const DocsView = React.forwardRef<DocsViewHandle, DocsViewProps>(function DocsView(
27
- {
28
- info,
29
- rawSchema,
30
- resolvedBaseUrl,
31
- endpoints,
32
- selectedVersion,
33
- loadedEndpoint,
34
- onTryEndpoint,
35
- onActiveChange,
36
- },
146
+ props,
37
147
  ref,
38
148
  ) {
39
149
  const scrollRef = useRef<HTMLDivElement | null>(null);
150
+ const scrollTargetRef = useRef<ScrollTarget | null>(null);
151
+ const { onActiveChange } = props;
40
152
 
41
- const visibleEndpoints = useMemo(
42
- () => deduplicateEndpoints(endpoints, selectedVersion),
43
- [endpoints, selectedVersion],
44
- );
45
-
46
- // Scroll a given section into view. Imperative handle so the
47
- // sidebar (not a descendant) can trigger this without props drilling.
48
- const scrollToAnchor = useCallback((anchor: string) => {
49
- const root = scrollRef.current;
50
- if (!root) return;
51
- const el = root.querySelector<HTMLElement>(`[data-endpoint-anchor="${anchor}"]`);
52
- if (!el) return;
53
- el.scrollIntoView({ behavior: 'smooth', block: 'start' });
153
+ // Resolve the real scroll container once the ref is attached. In
154
+ // standalone pages that's ``window``; inside an ``overflow-auto``
155
+ // shell (dev playground, modal) it's the wrapping DIV.
156
+ const ensureScrollTarget = useCallback((): ScrollTarget | null => {
157
+ if (scrollTargetRef.current) return scrollTargetRef.current;
158
+ if (!scrollRef.current) return null;
159
+ scrollTargetRef.current = getScrollParent(scrollRef.current);
160
+ return scrollTargetRef.current;
54
161
  }, []);
55
162
 
163
+ // Scroll a given section into view. Works against whichever ancestor
164
+ // actually scrolls — window for standalone, the overflow-auto parent
165
+ // for embedded layouts — so callers don't need to know the difference.
166
+ const scrollToAnchor = useCallback(
167
+ (anchor: string) => {
168
+ const root = scrollRef.current;
169
+ if (!root) return;
170
+ const el = root.querySelector<HTMLElement>(`[data-endpoint-anchor="${anchor}"]`);
171
+ if (!el) return;
172
+ const target = ensureScrollTarget();
173
+ if (!target) return;
174
+ const navbar = readNavbarOffset();
175
+ const top =
176
+ el.getBoundingClientRect().top -
177
+ getTargetTop(target) +
178
+ getScrollTop(target) -
179
+ navbar -
180
+ 8;
181
+ scrollTargetTo(target, top);
182
+ },
183
+ [ensureScrollTarget],
184
+ );
185
+
56
186
  React.useImperativeHandle(ref, () => ({ scrollToAnchor }), [scrollToAnchor]);
57
187
 
58
188
  // Scrollspy: pick the topmost endpoint section whose top is near the
59
- // upper third of the viewport. Runs on every scroll event (rAF-throttled)
60
- // an IntersectionObserver has flaky behaviour for "which one is active"
61
- // when several sections overlap the root margin band at once.
189
+ // upper quarter of the viewport. Listens on the real scroll container
190
+ // (see ``ensureScrollTarget``) because ``scroll`` events on a nested
191
+ // overflow:auto element do NOT bubble up to window.
62
192
  useEffect(() => {
63
193
  const root = scrollRef.current;
64
194
  if (!root) return;
195
+ const target = ensureScrollTarget();
196
+ if (!target) return;
65
197
 
66
198
  let rafId = 0;
67
199
  let lastActive: string | null = null;
@@ -70,20 +202,22 @@ export const DocsView = React.forwardRef<DocsViewHandle, DocsViewProps>(function
70
202
  rafId = 0;
71
203
  const sections = root.querySelectorAll<HTMLElement>('[data-endpoint-anchor]');
72
204
  if (sections.length === 0) return;
73
- const rootTop = root.getBoundingClientRect().top;
74
- const threshold = rootTop + root.clientHeight * 0.25;
75
- let active: string | null = null;
205
+ const navbar = readNavbarOffset();
206
+ const viewportTop = getTargetTop(target);
207
+ const threshold = viewportTop + navbar + getViewportHeight(target) * 0.25;
208
+ let active: HTMLElement | null = null;
76
209
  for (const s of Array.from(sections)) {
77
210
  const top = s.getBoundingClientRect().top;
78
211
  if (top <= threshold) {
79
- active = s.dataset.endpointAnchor ?? null;
212
+ active = s;
80
213
  } else {
81
214
  break;
82
215
  }
83
216
  }
84
- if (active !== lastActive) {
85
- lastActive = active;
86
- onActiveChange(active);
217
+ const anchor = active?.dataset.endpointAnchor ?? null;
218
+ if (anchor !== lastActive) {
219
+ lastActive = anchor;
220
+ onActiveChange(anchor, active?.dataset.schemaId || null);
87
221
  }
88
222
  };
89
223
 
@@ -93,15 +227,49 @@ export const DocsView = React.forwardRef<DocsViewHandle, DocsViewProps>(function
93
227
  };
94
228
 
95
229
  compute();
96
- root.addEventListener('scroll', onScroll, { passive: true });
230
+ target.addEventListener('scroll', onScroll, { passive: true });
231
+ // Resize always bubbles to window — listen there regardless of target.
232
+ window.addEventListener('resize', onScroll, { passive: true });
97
233
  return () => {
98
- root.removeEventListener('scroll', onScroll);
234
+ target.removeEventListener('scroll', onScroll);
235
+ window.removeEventListener('resize', onScroll);
99
236
  if (rafId) cancelAnimationFrame(rafId);
100
237
  };
101
- }, [visibleEndpoints, onActiveChange]);
238
+ }, [onActiveChange, ensureScrollTarget, props]);
239
+
240
+ if (props.grouping === 'sections') {
241
+ return <SectionsBody scrollRef={scrollRef} {...props} />;
242
+ }
243
+
244
+ return <SelectorBody scrollRef={scrollRef} {...props} />;
245
+ });
246
+
247
+ // ─── Selector body (single active schema) ────────────────────────────────────
248
+
249
+ function SelectorBody({
250
+ scrollRef,
251
+ info,
252
+ rawSchema,
253
+ resolvedBaseUrl,
254
+ endpoints,
255
+ selectedVersion,
256
+ loadedEndpoint,
257
+ onTryEndpoint,
258
+ }: SelectorProps & { scrollRef: React.RefObject<HTMLDivElement | null> }) {
259
+ const visibleEndpoints = useMemo(
260
+ () => deduplicateEndpoints(endpoints, selectedVersion),
261
+ [endpoints, selectedVersion],
262
+ );
263
+
264
+ const rows = useMemo<EndpointRow[]>(
265
+ () => visibleEndpoints.map((ep) => buildEndpointRow(ep, loadedEndpoint, ep.schemaId ?? null)),
266
+ [visibleEndpoints, loadedEndpoint],
267
+ );
268
+
269
+ const isEmpty = rows.length === 0;
102
270
 
103
271
  return (
104
- <div ref={scrollRef} className="flex-1 overflow-y-auto min-h-0">
272
+ <div ref={scrollRef}>
105
273
  <div className="mx-auto w-full max-w-[860px] px-6 md:px-10 lg:px-14 py-12">
106
274
  {info && (
107
275
  <ApiIntroSection
@@ -111,27 +279,131 @@ export const DocsView = React.forwardRef<DocsViewHandle, DocsViewProps>(function
111
279
  resolvedBaseUrl={resolvedBaseUrl}
112
280
  />
113
281
  )}
114
- {visibleEndpoints.length === 0 ? (
282
+ {isEmpty ? (
115
283
  <div className="py-16 text-center text-sm text-muted-foreground">
116
284
  No endpoints to display.
117
285
  </div>
118
286
  ) : (
119
287
  <div className="divide-y divide-border/60">
120
- {visibleEndpoints.map((ep) => {
121
- const isLoaded =
122
- loadedEndpoint?.method === ep.method && loadedEndpoint?.path === ep.path;
123
- return (
124
- <EndpointDoc
125
- key={`${ep.method}-${ep.path}`}
126
- endpoint={ep}
127
- isLoadedInPlayground={isLoaded}
128
- onTryIt={() => onTryEndpoint(ep)}
129
- />
130
- );
131
- })}
288
+ {rows.map((row) => (
289
+ <EndpointDoc
290
+ key={row.key}
291
+ endpoint={row.endpoint}
292
+ isLoadedInPlayground={row.isLoaded}
293
+ onTryIt={() => onTryEndpoint(row.endpoint)}
294
+ schemaId={row.schemaId}
295
+ />
296
+ ))}
132
297
  </div>
133
298
  )}
134
299
  </div>
135
300
  </div>
136
301
  );
302
+ }
303
+
304
+ // ─── Sections body (all schemas concatenated) ────────────────────────────────
305
+
306
+ function SectionsBody({
307
+ scrollRef,
308
+ schemasData,
309
+ selectedVersion,
310
+ loadedEndpoint,
311
+ onTryEndpoint,
312
+ }: SectionsProps & { scrollRef: React.RefObject<HTMLDivElement | null> }) {
313
+ const sections = useMemo<SchemaSectionVM[]>(
314
+ () => schemasData.map((e) => buildSchemaSectionVM(e, selectedVersion, loadedEndpoint)),
315
+ [schemasData, selectedVersion, loadedEndpoint],
316
+ );
317
+
318
+ return (
319
+ <div ref={scrollRef}>
320
+ <div className="mx-auto w-full max-w-[860px] px-6 md:px-10 lg:px-14 py-12 space-y-16">
321
+ {sections.map((section) => (
322
+ <SchemaSectionView key={section.schemaId} section={section} onTryEndpoint={onTryEndpoint} />
323
+ ))}
324
+ </div>
325
+ </div>
326
+ );
327
+ }
328
+
329
+ const SchemaSectionView = React.memo(function SchemaSectionView({
330
+ section,
331
+ onTryEndpoint,
332
+ }: {
333
+ section: SchemaSectionVM;
334
+ onTryEndpoint: (ep: ApiEndpoint) => void;
335
+ }) {
336
+ const canCopy = section.rawSchema !== null && section.allEndpoints.length > 0;
337
+ return (
338
+ <section data-schema-anchor={section.schemaId} className="scroll-mt-20">
339
+ <header className="mb-8 pb-4 border-b">
340
+ <div className="flex items-start justify-between gap-4">
341
+ <div className="flex items-baseline gap-3 min-w-0">
342
+ <h2 className="text-2xl font-semibold tracking-tight">{section.title}</h2>
343
+ {section.version && (
344
+ <span className="font-mono text-xs text-muted-foreground/70">
345
+ v{section.version}
346
+ </span>
347
+ )}
348
+ </div>
349
+ {canCopy && (
350
+ <SchemaCopyMenu
351
+ schema={section.rawSchema}
352
+ endpoints={section.allEndpoints}
353
+ baseUrl={section.baseUrl}
354
+ />
355
+ )}
356
+ </div>
357
+ {section.description && (
358
+ <p className="mt-2 text-sm text-muted-foreground whitespace-pre-wrap">
359
+ {section.description}
360
+ </p>
361
+ )}
362
+ </header>
363
+ <SchemaSectionStateView section={section} onTryEndpoint={onTryEndpoint} />
364
+ </section>
365
+ );
137
366
  });
367
+
368
+ function SchemaSectionStateView({
369
+ section,
370
+ onTryEndpoint,
371
+ }: {
372
+ section: SchemaSectionVM;
373
+ onTryEndpoint: (ep: ApiEndpoint) => void;
374
+ }) {
375
+ switch (section.state.kind) {
376
+ case 'loading':
377
+ return (
378
+ <div className="py-8 text-center text-sm text-muted-foreground">
379
+ Loading {section.title}…
380
+ </div>
381
+ );
382
+ case 'error':
383
+ return (
384
+ <div className="py-8 text-center text-sm text-destructive">
385
+ Failed to load {section.title}: {section.state.message}
386
+ </div>
387
+ );
388
+ case 'empty':
389
+ return (
390
+ <div className="py-8 text-center text-sm text-muted-foreground">
391
+ No endpoints in this schema.
392
+ </div>
393
+ );
394
+ case 'ready':
395
+ return (
396
+ <div className="divide-y divide-border/60">
397
+ {section.state.rows.map((row) => (
398
+ <EndpointDoc
399
+ key={row.key}
400
+ endpoint={row.endpoint}
401
+ isLoadedInPlayground={row.isLoaded}
402
+ onTryIt={() => onTryEndpoint(row.endpoint)}
403
+ schemaId={row.schemaId}
404
+ />
405
+ ))}
406
+ </div>
407
+ );
408
+ }
409
+ }
@@ -18,10 +18,14 @@ interface EndpointDocProps {
18
18
  /** Is this endpoint currently loaded in the sticky playground? */
19
19
  isLoadedInPlayground: boolean;
20
20
  onTryIt: () => void;
21
+ /** Scoping prefix for the anchor, so endpoints from different schemas
22
+ * don't collide on a single page. Falls back to ``endpoint.schemaId``. */
23
+ schemaId?: string | null;
21
24
  }
22
25
 
23
- export function EndpointDoc({ endpoint, isLoadedInPlayground, onTryIt }: EndpointDocProps) {
24
- const anchor = endpointAnchor(endpoint);
26
+ export function EndpointDoc({ endpoint, isLoadedInPlayground, onTryIt, schemaId }: EndpointDocProps) {
27
+ const scopedSchemaId = schemaId ?? endpoint.schemaId ?? null;
28
+ const anchor = endpointAnchor(endpoint, scopedSchemaId);
25
29
  const pathParams = endpoint.parameters?.filter((p) => endpoint.path.includes(`{${p.name}}`)) ?? [];
26
30
  const queryParams = endpoint.parameters?.filter((p) => !endpoint.path.includes(`{${p.name}}`)) ?? [];
27
31
 
@@ -43,6 +47,7 @@ export function EndpointDoc({ endpoint, isLoadedInPlayground, onTryIt }: Endpoin
43
47
  <section
44
48
  id={anchor}
45
49
  data-endpoint-anchor={anchor}
50
+ data-schema-id={scopedSchemaId ?? ''}
46
51
  className="scroll-mt-24 py-10 first:pt-0"
47
52
  >
48
53
  <header className="space-y-4">
@@ -12,6 +12,7 @@ import {
12
12
  DropdownMenuSeparator,
13
13
  DropdownMenuTrigger,
14
14
  } from '@djangocfg/ui-core/components';
15
+ import { toast } from '@djangocfg/ui-core/hooks';
15
16
 
16
17
  import type { ApiEndpoint, OpenApiSchema } from '../../types';
17
18
  import {
@@ -44,6 +45,11 @@ interface SchemaCopyMenuProps {
44
45
  /** Resolved base URL that gets embedded into the copy so the AI
45
46
  * receives working URLs, not the ones originally in ``schema.servers``. */
46
47
  baseUrl?: string;
48
+ /** Trigger appearance.
49
+ * - ``button`` (default) — labelled pill with icon + chevron.
50
+ * - ``icon`` — square ghost button, used in tight spots like the
51
+ * sidebar header where there is no room for "Copy for AI". */
52
+ variant?: 'button' | 'icon';
47
53
  }
48
54
 
49
55
  /**
@@ -52,7 +58,7 @@ interface SchemaCopyMenuProps {
52
58
  * dereferencing + stringifying a large schema can be non-trivial — sizes
53
59
  * are displayed after the first successful copy, via a tiny cache.
54
60
  */
55
- export function SchemaCopyMenu({ schema, endpoints, baseUrl }: SchemaCopyMenuProps) {
61
+ export function SchemaCopyMenu({ schema, endpoints, baseUrl, variant = 'button' }: SchemaCopyMenuProps) {
56
62
  const [sizeCache, setSizeCache] = useState<Partial<Record<Flavour, string>>>({});
57
63
  const [justCopied, setJustCopied] = useState<Flavour | null>(null);
58
64
  const [open, setOpen] = useState(false);
@@ -73,14 +79,18 @@ export function SchemaCopyMenu({ schema, endpoints, baseUrl }: SchemaCopyMenuPro
73
79
  async (flavour: Flavour) => {
74
80
  if (!isReady) return;
75
81
  const text = build(flavour);
82
+ const label = FLAVOUR_LABELS[flavour].title;
76
83
  try {
77
84
  await navigator.clipboard.writeText(text);
78
- setSizeCache((prev) => ({ ...prev, [flavour]: formatBytes(text) }));
85
+ const size = formatBytes(text);
86
+ setSizeCache((prev) => ({ ...prev, [flavour]: size }));
79
87
  setJustCopied(flavour);
80
88
  setTimeout(() => setJustCopied(null), 1500);
81
89
  setOpen(false);
82
- } catch {
83
- // Silent: clipboard perm denied. CopyButton handles the same case.
90
+ toast.success(`Copied ${label}`, { description: size });
91
+ } catch (err) {
92
+ const message = err instanceof Error ? err.message : 'Clipboard permission denied';
93
+ toast.error('Copy failed', { description: message });
84
94
  }
85
95
  },
86
96
  [build, isReady],
@@ -91,11 +101,24 @@ export function SchemaCopyMenu({ schema, endpoints, baseUrl }: SchemaCopyMenuPro
91
101
  return (
92
102
  <DropdownMenu open={open} onOpenChange={setOpen}>
93
103
  <DropdownMenuTrigger asChild>
94
- <Button variant="outline" size="sm" className="h-8 gap-1.5 text-xs" disabled={!isReady}>
95
- <Sparkles className="h-3 w-3" />
96
- Copy for AI
97
- <ChevronDown className="h-3 w-3 opacity-60" />
98
- </Button>
104
+ {variant === 'icon' ? (
105
+ <Button
106
+ variant="ghost"
107
+ size="icon"
108
+ className="h-7 w-7 shrink-0"
109
+ disabled={!isReady}
110
+ title="Copy schema for AI"
111
+ aria-label="Copy schema for AI"
112
+ >
113
+ <Sparkles className="h-3.5 w-3.5" />
114
+ </Button>
115
+ ) : (
116
+ <Button variant="outline" size="sm" className="h-8 gap-1.5 text-xs" disabled={!isReady}>
117
+ <Sparkles className="h-3 w-3" />
118
+ Copy for AI
119
+ <ChevronDown className="h-3 w-3 opacity-60" />
120
+ </Button>
121
+ )}
99
122
  </DropdownMenuTrigger>
100
123
  <DropdownMenuContent align="end" className="w-72">
101
124
  <DropdownMenuLabel className="text-[10px] uppercase tracking-wider text-muted-foreground/70">