@softize/opus 26.0.0 → 26.2.0

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 (35) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/bin/lib/check.mjs +9 -32
  3. package/bin/lib/copy.mjs +7 -4
  4. package/docs/adr/0014-structural-headers-do-not-carry-description.md +1 -1
  5. package/docs/adr/0018-modal-header-only-names-the-surface.md +1 -1
  6. package/docs/adr/0023-surfaces-share-one-anatomy-and-two-layouts.md +4 -0
  7. package/docs/adr/0057-doc-browser-permite-busca-no-chrome-do-consumidor.md +27 -0
  8. package/docs/adr/0059-subheadings-orient-without-carrying-business-rules.md +51 -0
  9. package/docs/adr/README.md +2 -0
  10. package/package.json +1 -1
  11. package/registry/skills/build-opus-ui/references/evaluations.md +8 -0
  12. package/registry/skills/build-opus-ui/references/ui-patterns.md +14 -4
  13. package/src/core/types.ts +3 -2
  14. package/src/ui/components/patterns/action-list-dialog.tsx +14 -7
  15. package/src/ui/components/patterns/form-dialog.tsx +18 -7
  16. package/src/ui/components/patterns/form.tsx +36 -8
  17. package/src/ui/components/patterns/page.tsx +22 -0
  18. package/src/ui/components/patterns/sidebar.tsx +17 -3
  19. package/src/ui/components/patterns/surface-anatomy.ts +1 -1
  20. package/src/ui/components/primitives/dialog.tsx +28 -1
  21. package/src/ui/components/primitives/drawer.tsx +30 -2
  22. package/src/ui/docs/DocBrowser.tsx +128 -81
  23. package/src/ui/docs/content/action-form-card.md +1 -1
  24. package/src/ui/docs/content/action-form-dialog.md +8 -0
  25. package/src/ui/docs/content/action-form.md +18 -5
  26. package/src/ui/docs/content/action-list-dialog.md +8 -0
  27. package/src/ui/docs/content/dialog.md +24 -2
  28. package/src/ui/docs/content/documentation-portal.md +45 -2
  29. package/src/ui/docs/content/drawer.md +24 -2
  30. package/src/ui/docs/content/page.md +23 -1
  31. package/src/ui/docs/content/sidebar.md +23 -0
  32. package/src/ui/docs/content/surface.md +8 -2
  33. package/src/ui/docs/types.ts +2 -0
  34. package/src/ui/lib/modal-layers.ts +27 -3
  35. package/src/ui/react.tsx +3 -0
@@ -147,6 +147,8 @@ interface PageBaseProps extends Omit<
147
147
 
148
148
  interface PageShorthandProps extends PageBaseProps {
149
149
  title: ReactNode;
150
+ /** Orientação opcional sobre a finalidade da página; regras pertencem ao corpo. */
151
+ description?: ReactNode;
150
152
  actions?: ReactNode;
151
153
  children: ReactNode;
152
154
  }
@@ -224,6 +226,7 @@ function hasOpaquePageBodyContent(node: ReactNode): boolean {
224
226
 
225
227
  export function Page({
226
228
  title,
229
+ description,
227
230
  actions,
228
231
  layout = "flow",
229
232
  className,
@@ -343,6 +346,9 @@ export function Page({
343
346
  <>
344
347
  <PageHeader>
345
348
  <PageTitle>{title}</PageTitle>
349
+ {description !== undefined && (
350
+ <PageDescription>{description}</PageDescription>
351
+ )}
346
352
  {actions !== undefined && <PageActions>{actions}</PageActions>}
347
353
  </PageHeader>
348
354
  <PageBody>{children}</PageBody>
@@ -461,6 +467,7 @@ export function PageHeader({
461
467
  slots={{
462
468
  leading: [PageBack, PageNavigation],
463
469
  title: PageTitle,
470
+ description: PageDescription,
464
471
  actions: PageActions,
465
472
  }}
466
473
  leadingPlacement="above"
@@ -496,6 +503,21 @@ export function PageTitle({
496
503
  );
497
504
  }
498
505
 
506
+ /** Orientação breve e dispensável para a decisão; não recebe regras de negócio. */
507
+ export function PageDescription({
508
+ className,
509
+ ...props
510
+ }: HTMLAttributes<HTMLParagraphElement>): ReactElement {
511
+ requireParent(useContext(PageHeaderContext), "PageDescription", "PageHeader");
512
+ return (
513
+ <p
514
+ data-slot="page-description"
515
+ className={cn(structuralHeaderClasses.page.description, className)}
516
+ {...props}
517
+ />
518
+ );
519
+ }
520
+
499
521
  export function PageActions({
500
522
  className,
501
523
  ...props
@@ -79,6 +79,8 @@ export interface SidebarItemProps {
79
79
  className?: string;
80
80
  /** Destino nativo. Com onClick, intercepta somente a navegação simples; preserva novas abas. */
81
81
  href?: string;
82
+ /** Abre o destino em nova aba, sem interceptar a navegação nativa. */
83
+ newTab?: boolean;
82
84
  onClick?: () => void;
83
85
  }
84
86
 
@@ -98,11 +100,19 @@ export function SidebarItem({
98
100
  className,
99
101
  onClick,
100
102
  href,
103
+ newTab = false,
101
104
  }: SidebarItemProps): React.ReactElement {
102
105
  const collapsed = useSidebarCollapsed();
103
106
  const Target = href !== undefined && !disabled ? "a" : "button";
104
107
  const targetProps =
105
- Target === "a" ? { href } : { type: "button" as const, disabled };
108
+ Target === "a"
109
+ ? {
110
+ href,
111
+ ...(newTab ? { target: "_blank", rel: "noopener noreferrer" } : {}),
112
+ }
113
+ : { type: "button" as const, disabled };
114
+ const accessibleLabel =
115
+ Target === "a" && newTab ? `${label} (abre em nova aba)` : label;
106
116
  const activate = (
107
117
  event: React.MouseEvent<HTMLAnchorElement | HTMLButtonElement>,
108
118
  ) => {
@@ -113,6 +123,7 @@ export function SidebarItem({
113
123
  event.ctrlKey ||
114
124
  event.shiftKey ||
115
125
  event.altKey ||
126
+ newTab ||
116
127
  !onClick
117
128
  )
118
129
  return;
@@ -129,7 +140,7 @@ export function SidebarItem({
129
140
  const button = (
130
141
  <Target
131
142
  {...targetProps}
132
- aria-label={label}
143
+ aria-label={accessibleLabel}
133
144
  aria-current={active ? "page" : undefined}
134
145
  onClick={activate}
135
146
  className={cn(
@@ -156,7 +167,7 @@ export function SidebarItem({
156
167
  <Tooltip>
157
168
  <TooltipTrigger asChild>{button}</TooltipTrigger>
158
169
  <TooltipContent side="right">
159
- {label}
170
+ {accessibleLabel}
160
171
  {tooltipHint}
161
172
  </TooltipContent>
162
173
  </Tooltip>
@@ -275,6 +286,9 @@ export function SidebarItem({
275
286
  {badge}
276
287
  </span>
277
288
  )}
289
+ {Target === "a" && newTab && (
290
+ <span className="sr-only"> (abre em nova aba)</span>
291
+ )}
278
292
  </Target>
279
293
  {hasActions && (
280
294
  <div
@@ -60,7 +60,7 @@ interface FamilyAnatomy {
60
60
  regions: Record<SurfaceLayout, RegionAnatomy>;
61
61
  /** Escala visual do título. O nível semântico do heading é escolhido à parte. */
62
62
  title: Record<SurfaceLayout, string>;
63
- /** Descrição no header, quando a família a oferece (ADRs 0014 e 0018 excluem modais). */
63
+ /** Descrição no header, quando a família a oferece (ADR 0059 inclui subheadings opcionais em modais). */
64
64
  description: string;
65
65
  /** Espaço entre mídia, título e ações na linha do header. */
66
66
  headerGap: string;
@@ -5,6 +5,7 @@ import { cn } from "../../lib/cn.ts";
5
5
  import {
6
6
  SurfaceLayoutProvider,
7
7
  surfaceActionsClasses,
8
+ surfaceDescriptionClasses,
8
9
  surfaceHeaderRowClasses,
9
10
  surfaceRegionClasses,
10
11
  surfaceTitleClasses,
@@ -26,6 +27,7 @@ import {
26
27
  exemptLayerFirst,
27
28
  focusIsInLayer,
28
29
  handleModalTabKey,
30
+ skipHelpOnAutoFocus,
29
31
  isLayerInteraction,
30
32
  routeEscapeToLayer,
31
33
  useLayerPortal,
@@ -415,6 +417,7 @@ export function DialogContent({
415
417
  onEscapeKeyDown,
416
418
  onPointerDownOutside,
417
419
  onInteractOutside,
420
+ onOpenAutoFocus,
418
421
  onCloseAutoFocus,
419
422
  onKeyDown,
420
423
  ref,
@@ -481,6 +484,7 @@ export function DialogContent({
481
484
  aria-describedby={ariaDescribedBy}
482
485
  // O `AlertDialog` do Radix cancela sozinho o clique fora; só o Esc é composto.
483
486
  onEscapeKeyDown={dismiss.onEscapeKeyDown}
487
+ onOpenAutoFocus={onOpenAutoFocus}
484
488
  onCloseAutoFocus={dismiss.onCloseAutoFocus}
485
489
  onKeyDown={onKeyDown}
486
490
  {...props}
@@ -532,6 +536,10 @@ export function DialogContent({
532
536
  onPointerDownOutside={dismiss.onPointerDownOutside}
533
537
  onInteractOutside={dismiss.onInteractOutside}
534
538
  onCloseAutoFocus={dismiss.onCloseAutoFocus}
539
+ onOpenAutoFocus={(event) => {
540
+ onOpenAutoFocus?.(event);
541
+ skipHelpOnAutoFocus(event);
542
+ }}
535
543
  onKeyDown={handleKeyDown}
536
544
  {...props}
537
545
  >
@@ -584,6 +592,7 @@ export function DialogHeader({
584
592
  leading: DialogNavigation,
585
593
  media: DialogMedia,
586
594
  title: DialogTitle,
595
+ description: DialogDescription,
587
596
  actions: DialogActions,
588
597
  }}
589
598
  leadingPlacement="inline"
@@ -645,7 +654,11 @@ export function DialogIntro({
645
654
  <StructuralHeader
646
655
  name="DialogIntro"
647
656
  slot="dialog-intro"
648
- slots={{ title: DialogTitle, actions: DialogIntroActions }}
657
+ slots={{
658
+ title: DialogTitle,
659
+ description: DialogDescription,
660
+ actions: DialogIntroActions,
661
+ }}
649
662
  leadingPlacement="inline"
650
663
  titleRequired
651
664
  className={cn("text-left", className)}
@@ -770,3 +783,17 @@ export function DialogTitle({
770
783
  </Title>
771
784
  );
772
785
  }
786
+
787
+ /** Orientação opcional sob o título; condições e consequências ficam no corpo. */
788
+ export function DialogDescription({
789
+ className,
790
+ ...props
791
+ }: React.ComponentProps<"p">) {
792
+ return (
793
+ <p
794
+ data-slot="dialog-description"
795
+ className={cn("mt-1", surfaceDescriptionClasses("dialog"), className)}
796
+ {...props}
797
+ />
798
+ );
799
+ }
@@ -5,6 +5,7 @@ import { cn } from "../../lib/cn.ts";
5
5
  import {
6
6
  SurfaceLayoutProvider,
7
7
  surfaceActionsClasses,
8
+ surfaceDescriptionClasses,
8
9
  surfaceHeaderRowClasses,
9
10
  surfaceRegionClasses,
10
11
  surfaceTitleClasses,
@@ -28,6 +29,7 @@ import {
28
29
  LAYER_MODAL_ATTRIBUTE,
29
30
  LayerPortalProvider,
30
31
  handleModalTabKey,
32
+ skipHelpOnAutoFocus,
31
33
  useLayerPortal,
32
34
  useModalLayerEntry,
33
35
  } from "../../lib/modal-layers.ts";
@@ -113,7 +115,9 @@ function DrawerOverlay({
113
115
  return (
114
116
  <DrawerPrimitive.Overlay
115
117
  data-slot="drawer-overlay"
116
- {...{ [LAYER_MODAL_ATTRIBUTE]: layer.container !== undefined || undefined }}
118
+ {...{
119
+ [LAYER_MODAL_ATTRIBUTE]: layer.container !== undefined || undefined,
120
+ }}
117
121
  className={cn(overlayClasses, layer.zIndexClass, className)}
118
122
  {...props}
119
123
  />
@@ -146,6 +150,7 @@ function DrawerContent({
146
150
  onEscapeKeyDown,
147
151
  onPointerDownOutside,
148
152
  onInteractOutside,
153
+ onOpenAutoFocus,
149
154
  onCloseAutoFocus,
150
155
  onKeyDown,
151
156
  ref,
@@ -218,6 +223,10 @@ function DrawerContent({
218
223
  onPointerDownOutside={dismiss.onPointerDownOutside}
219
224
  onInteractOutside={dismiss.onInteractOutside}
220
225
  onCloseAutoFocus={dismiss.onCloseAutoFocus}
226
+ onOpenAutoFocus={(event) => {
227
+ onOpenAutoFocus?.(event);
228
+ skipHelpOnAutoFocus(event);
229
+ }}
221
230
  onKeyDown={handleKeyDown}
222
231
  {...props}
223
232
  >
@@ -251,6 +260,7 @@ function DrawerHeader({
251
260
  leading: DrawerNavigation,
252
261
  media: DrawerMedia,
253
262
  title: DrawerTitle,
263
+ description: DrawerDescription,
254
264
  actions: DrawerActions,
255
265
  }}
256
266
  leadingPlacement="inline"
@@ -314,7 +324,11 @@ function DrawerIntro({
314
324
  <StructuralHeader
315
325
  name="DrawerIntro"
316
326
  slot="drawer-intro"
317
- slots={{ title: DrawerTitle, actions: DrawerIntroActions }}
327
+ slots={{
328
+ title: DrawerTitle,
329
+ description: DrawerDescription,
330
+ actions: DrawerIntroActions,
331
+ }}
318
332
  leadingPlacement="inline"
319
333
  titleRequired
320
334
  className={cn("text-left", className)}
@@ -435,3 +449,17 @@ export {
435
449
  DrawerFooter,
436
450
  DrawerTitle,
437
451
  };
452
+
453
+ /** Orientação opcional sob o título; condições e consequências ficam no corpo. */
454
+ export function DrawerDescription({
455
+ className,
456
+ ...props
457
+ }: React.ComponentProps<"p">) {
458
+ return (
459
+ <p
460
+ data-slot="drawer-description"
461
+ className={cn("mt-1", surfaceDescriptionClasses("drawer"), className)}
462
+ {...props}
463
+ />
464
+ );
465
+ }
@@ -12,8 +12,15 @@
12
12
  * Nos dois, navegar emite `popstate`: host que guarda o path por conta própria acompanha.
13
13
  * `basePath` adapta o prefixo das URLs (`/docs` no Maestro, `/__docs` nos apps).
14
14
  */
15
- import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
16
- import { ArrowLeft, ArrowRight, Search } from "lucide-react";
15
+ import {
16
+ Fragment,
17
+ useEffect,
18
+ useMemo,
19
+ useRef,
20
+ useState,
21
+ type ReactNode,
22
+ } from "react";
23
+ import { ArrowLeft, ArrowRight, ExternalLink, Search } from "lucide-react";
17
24
  import type { DocEntry, DocGroup, DocSection } from "./types.ts";
18
25
  import { Pane, Split, type PaneSize } from "../components/patterns/split.tsx";
19
26
  import { Input } from "../components/primitives/input.tsx";
@@ -33,14 +40,28 @@ import {
33
40
  } from "../components/patterns/sidebar.tsx";
34
41
  import { navigate, usePathname } from "../router.ts";
35
42
 
43
+ function documentEntries(
44
+ sections: DocSection[],
45
+ ): Array<{ entry: DocEntry; context: string }> {
46
+ const entriesFrom = (
47
+ groups: DocGroup[],
48
+ parent: string,
49
+ ): Array<{ entry: DocEntry; context: string }> =>
50
+ groups.flatMap((group) => {
51
+ const context = group.label?.trim() || parent;
52
+ return [
53
+ ...group.pages.map((entry) => ({ entry, context })),
54
+ ...entriesFrom(group.groups ?? [], context),
55
+ ];
56
+ });
57
+ return sections.flatMap((section) =>
58
+ entriesFrom(section.groups, section.label),
59
+ );
60
+ }
61
+
36
62
  function findPage(sections: DocSection[], slug: string): DocEntry | undefined {
37
- for (const s of sections) {
38
- for (const g of s.groups) {
39
- const p = g.pages.find((p) => p.slug === slug);
40
- if (p) return p;
41
- }
42
- }
43
- return undefined;
63
+ return documentEntries(sections).find(({ entry }) => entry.slug === slug)
64
+ ?.entry;
44
65
  }
45
66
 
46
67
  interface DocBrowserSearchCopy {
@@ -58,7 +79,7 @@ export interface DocBrowserProps {
58
79
  basePath?: string;
59
80
  /** Path controlado pelo host (modo Maestro). Ausente = standalone (gerencia o próprio). */
60
81
  path?: string;
61
- /** Seções a exibir (seção → grupo → página). */
82
+ /** Seções a exibir; grupos podem conter páginas e subgrupos. */
62
83
  sections: DocSection[];
63
84
  /** Slugs antigos que devem abrir e substituir a URL pela página canônica. */
64
85
  redirects?: Readonly<Record<string, string>>;
@@ -79,8 +100,13 @@ export interface DocBrowserProps {
79
100
  additionalPages?: DocEntry[];
80
101
  /** Substitui a leitura, mantendo a árvore; o host resolve conteúdo, título e rota. */
81
102
  content?: ReactNode;
82
- /** Destinos fora dos capítulos; não participam da sequência de leitura. */
83
- sidebarLinks?: Array<{ label: string; href: string; active?: boolean }>;
103
+ /** Destinos fora dos capítulos; `newTab` abre em nova aba com ícone e descrição acessível. */
104
+ sidebarLinks?: Array<{
105
+ label: string;
106
+ href: string;
107
+ active?: boolean;
108
+ newTab?: boolean;
109
+ }>;
84
110
  /** Seções e grupos começam recolhidos; a busca revela resultados sem mudar a escolha manual. */
85
111
  sidebarInitiallyCollapsed?: boolean;
86
112
  }
@@ -116,14 +142,7 @@ function EditorialDocument({
116
142
  const [headings, setHeadings] = useState<
117
143
  Array<{ id: string; title: string }>
118
144
  >([]);
119
- const entries = sections.flatMap((section) =>
120
- section.groups.flatMap((group) =>
121
- group.pages.map((entry) => ({
122
- entry,
123
- context: group.label || section.label,
124
- })),
125
- ),
126
- );
145
+ const entries = documentEntries(sections);
127
146
  const index = entries.findIndex(({ entry }) => entry.slug === page.slug);
128
147
  const previous = index > 0 ? entries[index - 1]?.entry : undefined;
129
148
  const next = index >= 0 ? entries[index + 1]?.entry : undefined;
@@ -271,9 +290,14 @@ function searchable(value: string): string {
271
290
  .toLocaleLowerCase("pt-BR");
272
291
  }
273
292
 
293
+ type SidebarGroup = Omit<DocGroup, "groups"> & {
294
+ navigationKey: string;
295
+ groups: SidebarGroup[];
296
+ };
297
+
274
298
  type SidebarSection = Omit<DocSection, "groups"> & {
275
299
  navigationKey: string;
276
- groups: Array<DocGroup & { navigationKey: string }>;
300
+ groups: SidebarGroup[];
277
301
  };
278
302
 
279
303
  function filterSections(
@@ -282,25 +306,35 @@ function filterSections(
282
306
  ): SidebarSection[] {
283
307
  const terms = searchable(query).split(/\s+/).filter(Boolean);
284
308
  // Identidade vem do manifesto completo, antes de remover ramos pela busca.
309
+ const indexGroups = (groups: DocGroup[], parent: string): SidebarGroup[] =>
310
+ groups.map((group, index) => {
311
+ const navigationKey = `${parent}:${index}`;
312
+ return {
313
+ ...group,
314
+ navigationKey,
315
+ groups: indexGroups(group.groups ?? [], navigationKey),
316
+ };
317
+ });
285
318
  const indexedSections = sections.map((section, sectionIndex) => ({
286
319
  ...section,
287
320
  navigationKey: `section:${sectionIndex}`,
288
- groups: section.groups.map((group, groupIndex) => ({
289
- ...group,
290
- navigationKey: `group:${sectionIndex}:${groupIndex}`,
291
- })),
321
+ groups: indexGroups(section.groups, `group:${sectionIndex}`),
292
322
  }));
293
- if (terms.length === 0) return indexedSections;
294
- return indexedSections.flatMap((section) => {
295
- const groups = section.groups.flatMap((group) => {
323
+ const matchingGroups = (groups: SidebarGroup[]): SidebarGroup[] =>
324
+ groups.flatMap((group) => {
296
325
  const pages = group.pages.filter((page) => {
297
326
  const text = searchable(
298
327
  `${page.title}\n${page.navigationTitle ?? ""}\n${page.badge ?? ""}\n${page.searchText ?? ""}`,
299
328
  );
300
329
  return terms.every((term) => text.includes(term));
301
330
  });
302
- return pages.length === 0 ? [] : [{ ...group, pages }];
331
+ const children = matchingGroups(group.groups);
332
+ return pages.length === 0 && children.length === 0
333
+ ? []
334
+ : [{ ...group, pages, groups: children }];
303
335
  });
336
+ return indexedSections.flatMap((section) => {
337
+ const groups = matchingGroups(section.groups);
304
338
  return groups.length === 0 ? [] : [{ ...section, groups }];
305
339
  });
306
340
  }
@@ -327,22 +361,21 @@ function DocSidebarTree({
327
361
 
328
362
  useEffect(() => {
329
363
  if (forceExpanded || initiallyCollapsed) return;
330
- const activeGroup = sections
331
- .flatMap((section) =>
332
- section.groups.map((group) => ({
333
- key: group.navigationKey,
334
- group,
335
- })),
336
- )
337
- .find(({ group }) =>
338
- group.pages.some((page) => page.slug === activeSlug),
339
- );
340
-
341
- if (activeGroup === undefined) return;
364
+ const activePath = (groups: SidebarGroup[]): string[] => {
365
+ for (const group of groups) {
366
+ if (group.pages.some((page) => page.slug === activeSlug))
367
+ return [group.navigationKey];
368
+ const children = activePath(group.groups);
369
+ if (children.length > 0) return [group.navigationKey, ...children];
370
+ }
371
+ return [];
372
+ };
373
+ const keys = sections.flatMap((section) => activePath(section.groups));
374
+ if (keys.length === 0) return;
342
375
  setToggledBranches((current) => {
343
- if (!current.has(activeGroup.key)) return current;
376
+ if (!keys.some((key) => current.has(key))) return current;
344
377
  const next = new Set(current);
345
- next.delete(activeGroup.key);
378
+ keys.forEach((key) => next.delete(key));
346
379
  return next;
347
380
  });
348
381
  }, [activeSlug, forceExpanded, sections, initiallyCollapsed]);
@@ -372,6 +405,36 @@ function DocSidebarTree({
372
405
  />
373
406
  );
374
407
 
408
+ const renderGroup = (group: SidebarGroup): ReactNode => {
409
+ const key = group.navigationKey;
410
+ const children = [
411
+ ...group.pages.map(renderPage),
412
+ ...group.groups.map(renderGroup),
413
+ ];
414
+ if ((group.label ?? "").trim() === "") {
415
+ return <Fragment key={key}>{children}</Fragment>;
416
+ }
417
+ const expanded =
418
+ forceExpanded ||
419
+ (initiallyCollapsed
420
+ ? toggledBranches.has(key)
421
+ : !toggledBranches.has(key));
422
+ return (
423
+ <div key={key} className="space-y-0.5">
424
+ <SidebarItem
425
+ label={group.label!}
426
+ icon={group.icon}
427
+ expanded={expanded}
428
+ onToggle={forceExpanded ? undefined : () => toggleGroup(key)}
429
+ onClick={() => {
430
+ if (!forceExpanded) toggleGroup(key);
431
+ }}
432
+ />
433
+ {expanded && <SidebarTreeGroup>{children}</SidebarTreeGroup>}
434
+ </div>
435
+ );
436
+ };
437
+
375
438
  return (
376
439
  <nav
377
440
  aria-label="Navegação da documentação"
@@ -384,38 +447,7 @@ function DocSidebarTree({
384
447
  initiallyCollapsed && section.label.trim() !== "";
385
448
  const sectionExpanded =
386
449
  forceExpanded || toggledBranches.has(sectionBranch);
387
- const groups = section.groups.map((group) => {
388
- const key = group.navigationKey;
389
- if (group.pages.length === 0) return null;
390
- if ((group.label ?? "").trim() === "") {
391
- return group.pages.map((page) => renderPage(page));
392
- }
393
-
394
- const expanded =
395
- forceExpanded ||
396
- (initiallyCollapsed
397
- ? toggledBranches.has(key)
398
- : !toggledBranches.has(key));
399
- return (
400
- <div key={key} className="space-y-0.5">
401
- <SidebarItem
402
- label={group.label!}
403
- icon={group.icon}
404
- expanded={expanded}
405
- onToggle={forceExpanded ? undefined : () => toggleGroup(key)}
406
- // Durante a busca, o grupo é contexto dos resultados e permanece aberto.
407
- onClick={() => {
408
- if (!forceExpanded) toggleGroup(key);
409
- }}
410
- />
411
- {expanded && (
412
- <SidebarTreeGroup>
413
- {group.pages.map((page) => renderPage(page))}
414
- </SidebarTreeGroup>
415
- )}
416
- </div>
417
- );
418
- });
450
+ const groups = section.groups.map(renderGroup);
419
451
  return (
420
452
  <div key={sectionBranch} className="space-y-0.5">
421
453
  {collapsibleSection ? (
@@ -452,9 +484,24 @@ function DocSidebarTree({
452
484
  key={link.href}
453
485
  href={link.href}
454
486
  active={link.active}
455
- onClick={() => navigate(link.href)}
487
+ newTab={link.newTab}
488
+ badge={
489
+ link.newTab ? (
490
+ <ExternalLink
491
+ aria-hidden="true"
492
+ className="size-3.5 text-muted-foreground"
493
+ />
494
+ ) : undefined
495
+ }
496
+ onClick={
497
+ link.newTab || /^https?:\/\//i.test(link.href)
498
+ ? undefined
499
+ : () => navigate(link.href)
500
+ }
456
501
  label={link.label}
457
- icon={initiallyCollapsed ? <span aria-hidden="true" /> : undefined}
502
+ icon={
503
+ initiallyCollapsed ? <span aria-hidden="true" /> : undefined
504
+ }
458
505
  />
459
506
  ))}
460
507
  </div>
@@ -485,8 +532,8 @@ export function DocBrowser({
485
532
  // hooks); o que muda é quem vence no `??`.
486
533
  const livePath = usePathname();
487
534
  const path = controlledPath ?? livePath;
488
- // Default = 1ª página do 1º grupo da 1ª seção (a doc abre na 1ª página).
489
- const defaultSlug = sections[0]?.groups[0]?.pages[0]?.slug ?? "";
535
+ // Abre na primeira página do percurso, inclusive dentro de subgrupos.
536
+ const defaultSlug = documentEntries(sections)[0]?.entry.slug ?? "";
490
537
  // Slug = TODOS os segmentos após o basePath, unidos (rota aninhada: `/docs/guias/deploy`
491
538
  // → `guias/deploy`). Slug flat de 1 segmento continua igual (`join` de 1 elemento = ele).
492
539
  const rel = path.startsWith(basePath) ? path.slice(basePath.length) : "";
@@ -567,14 +614,14 @@ export function DocBrowser({
567
614
  <div className="min-h-0 flex-1 overflow-y-auto px-3 py-6">
568
615
  <p
569
616
  className={
570
- visibleSections.length === 0
617
+ query.trim() !== "" && visibleSections.length === 0
571
618
  ? "px-2 text-sm text-muted-foreground"
572
619
  : "sr-only"
573
620
  }
574
621
  role={search === false ? undefined : "status"}
575
622
  aria-atomic={search === false ? undefined : "true"}
576
623
  >
577
- {visibleSections.length === 0
624
+ {query.trim() !== "" && visibleSections.length === 0
578
625
  ? (searchOptions.emptyMessage ??
579
626
  "Nenhum documento encontrado. Tente outro termo.")
580
627
  : ""}
@@ -28,7 +28,7 @@ a mesma.
28
28
  | ------------------- | ---------------------------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
29
29
  | `title` | `string` | | Título do cabeçalho. Sem ele e sem `description`, o card começa diretamente pelo corpo. |
30
30
  | `titleLevel` | `1 \| 2 \| 3 \| 4 \| 5 \| 6` | `3` | Nível do heading do título. Muda a hierarquia do documento, não a escala visual. |
31
- | `description` | `string` | | Texto de apoio abaixo do título. |
31
+ | `description` | `string` | | Orientação opcional sobre a finalidade. Regras e consequências ficam junto dos campos ou no corpo. |
32
32
  | `cardClassName` | `string` | | Classes aplicadas à superfície do card. |
33
33
  | `className` | `string` | | Classes aplicadas ao `<form>` interno. |
34
34
  | Demais propriedades | Propriedades de `ActionForm` | | `action`, `defaultValues`, `fieldOptions`, `submitLabel`, `cancelLabel`, `cancelVariant`, `onCancel`, `onSuccess`, `disabled`, `onLoadingChange` e `children` mantêm o comportamento de `ActionForm`. `body` e `footer` não são aceitas: o card fornece o corpo e o rodapé. |
@@ -35,12 +35,20 @@ salvo escolha explícita em `cancelVariant`.
35
35
 
36
36
  Para consultar uma coleção no mesmo formato de modal, use [`ActionListDialog`](/ui/action-list-dialog).
37
37
 
38
+ ## Orientação e contexto
39
+
40
+ `description` apresenta a finalidade no cabeçalho, como “Escolha onde a pasta e suas subpastas
41
+ aparecem.”. É opcional; não repita o título. `intro` apresenta no corpo informações necessárias
42
+ à decisão que não pertencem a um campo específico. Regras, condições e consequências não ficam
43
+ somente no subheading. Quando ambos são fornecidos, ambos participam de `aria-describedby`.
44
+
38
45
  ## Propriedades de ActionFormDialog
39
46
 
40
47
  | Propriedade | Tipo | Padrão | Descrição |
41
48
  | --------------------- | ------------------------------------------------- | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
42
49
  | `open / onOpenChange` | `boolean / (open: boolean) => void` | | Estado controlado do modal. `onOpenChange(false)` é chamado no sucesso e, quando não há `onCancel` próprio, ao cancelar. |
43
50
  | `title` | `string` | | Operação e recurso exibidos no cabeçalho, como `Criar workspace` ou `Editar workspace`. |
51
+ | `description` | `ReactNode` | | Orientação opcional sob o título; não recebe regras de negócio. |
44
52
  | `intro` | `ReactNode` | | Contexto relevante no início do corpo, como texto ou `Alert`. |
45
53
  | `submitLabel` | `string` | `'Salvar'` | Resultado da ação principal. Preserve o padrão em criação e edição comuns; sobrescreva apenas quando a operação tiver outro efeito, como `Renomear` ou `Criar nova versão`. |
46
54
  | `footerDistribution` | `'content' \| 'equal'` | `'content'` | Mantém a largura das ações pelo conteúdo ou divide a faixa igualmente. |