@softize/opus 27.0.0 → 28.0.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 (42) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/bin/lib/check.mjs +139 -30
  3. package/docs/adr/0015-action-size-follows-interaction-density.md +2 -0
  4. package/docs/adr/0017-ui-assumes-a-fixed-desktop-layout.md +14 -8
  5. package/docs/adr/0029-page-header-is-the-single-page-introduction.md +3 -1
  6. package/docs/adr/0057-doc-browser-permite-busca-no-chrome-do-consumidor.md +39 -5
  7. package/docs/adr/0060-surface-composition-separates-chrome-and-intro.md +161 -0
  8. package/docs/adr/README.md +2 -0
  9. package/package.json +1 -1
  10. package/registry/skills/build-opus-ui/SKILL.md +11 -11
  11. package/registry/skills/build-opus-ui/references/evaluations.md +10 -0
  12. package/registry/skills/build-opus-ui/references/ui-patterns.md +17 -15
  13. package/src/ui/components/patterns/list.tsx +62 -53
  14. package/src/ui/components/patterns/page.tsx +146 -38
  15. package/src/ui/components/patterns/presentation.tsx +44 -59
  16. package/src/ui/components/patterns/structural-header.tsx +42 -6
  17. package/src/ui/components/patterns/surface-anatomy.ts +52 -22
  18. package/src/ui/components/patterns/surface.tsx +2 -3
  19. package/src/ui/components/primitives/control.ts +2 -2
  20. package/src/ui/components/primitives/dialog.tsx +7 -2
  21. package/src/ui/components/primitives/drawer.tsx +7 -2
  22. package/src/ui/components/primitives/icon-picker.tsx +271 -34
  23. package/src/ui/components/primitives/pagination.tsx +5 -4
  24. package/src/ui/components/primitives/tabs.tsx +15 -2
  25. package/src/ui/components/primitives/toggle.tsx +1 -1
  26. package/src/ui/docs/DocBrowser.tsx +122 -12
  27. package/src/ui/docs/content/action-list.md +11 -2
  28. package/src/ui/docs/content/button.md +7 -7
  29. package/src/ui/docs/content/compositions.md +35 -23
  30. package/src/ui/docs/content/dialog.md +18 -6
  31. package/src/ui/docs/content/documentation-portal.md +40 -1
  32. package/src/ui/docs/content/drawer.md +17 -5
  33. package/src/ui/docs/content/icon-picker.md +37 -19
  34. package/src/ui/docs/content/input.md +1 -1
  35. package/src/ui/docs/content/page.md +169 -69
  36. package/src/ui/docs/content/pagination.md +7 -8
  37. package/src/ui/docs/content/presentation.md +4 -3
  38. package/src/ui/docs/content/surface.md +8 -5
  39. package/src/ui/docs/content/tabs.md +12 -4
  40. package/src/ui/docs/types.ts +2 -0
  41. package/src/ui/meta.ts +1 -1
  42. package/src/ui/react.tsx +9 -1
@@ -15,15 +15,25 @@
15
15
  import {
16
16
  Fragment,
17
17
  useEffect,
18
+ useId,
18
19
  useMemo,
19
20
  useRef,
20
21
  useState,
22
+ useSyncExternalStore,
21
23
  type ReactNode,
22
24
  } from "react";
23
- import { ArrowLeft, ArrowRight, ExternalLink, Search } from "lucide-react";
25
+ import {
26
+ ArrowLeft,
27
+ ArrowRight,
28
+ ExternalLink,
29
+ Menu,
30
+ Search,
31
+ X,
32
+ } from "lucide-react";
24
33
  import type { DocEntry, DocGroup, DocSection } from "./types.ts";
25
34
  import { Pane, Split, type PaneSize } from "../components/patterns/split.tsx";
26
35
  import { Input } from "../components/primitives/input.tsx";
36
+ import { Button } from "../components/primitives/button.tsx";
27
37
  import {
28
38
  Breadcrumb,
29
39
  BreadcrumbList,
@@ -109,6 +119,8 @@ export interface DocBrowserProps {
109
119
  }>;
110
120
  /** Seções e grupos começam recolhidos; a busca revela resultados sem mudar a escolha manual. */
111
121
  sidebarInitiallyCollapsed?: boolean;
122
+ /** Em telas de até 48rem, oferece navegação recolhível no lugar da lateral fixa. Default false. */
123
+ collapsibleNavigation?: boolean;
112
124
  }
113
125
 
114
126
  function documentNavigation(
@@ -142,11 +154,15 @@ function EditorialDocument({
142
154
  const [headings, setHeadings] = useState<
143
155
  Array<{ id: string; title: string }>
144
156
  >([]);
145
- const entries = documentEntries(sections);
157
+ const entries = documentEntries(
158
+ sections.filter((section) => section.readingSequence !== false),
159
+ );
146
160
  const index = entries.findIndex(({ entry }) => entry.slug === page.slug);
147
161
  const previous = index > 0 ? entries[index - 1]?.entry : undefined;
148
162
  const next = index >= 0 ? entries[index + 1]?.entry : undefined;
149
- const context = entries[index]?.context;
163
+ const context = documentEntries(sections).find(
164
+ ({ entry }) => entry.slug === page.slug,
165
+ )?.context;
150
166
 
151
167
  useEffect(() => {
152
168
  const article = articleRef.current;
@@ -303,8 +319,10 @@ type SidebarSection = Omit<DocSection, "groups"> & {
303
319
  function filterSections(
304
320
  sections: DocSection[],
305
321
  query: string,
322
+ basePath: string,
306
323
  ): SidebarSection[] {
307
324
  const terms = searchable(query).split(/\s+/).filter(Boolean);
325
+ const namespace = JSON.stringify(basePath);
308
326
  // Identidade vem do manifesto completo, antes de remover ramos pela busca.
309
327
  const indexGroups = (groups: DocGroup[], parent: string): SidebarGroup[] =>
310
328
  groups.map((group, index) => {
@@ -317,8 +335,8 @@ function filterSections(
317
335
  });
318
336
  const indexedSections = sections.map((section, sectionIndex) => ({
319
337
  ...section,
320
- navigationKey: `section:${sectionIndex}`,
321
- groups: indexGroups(section.groups, `group:${sectionIndex}`),
338
+ navigationKey: `${namespace}:section:${sectionIndex}`,
339
+ groups: indexGroups(section.groups, `${namespace}:group:${sectionIndex}`),
322
340
  }));
323
341
  const matchingGroups = (groups: SidebarGroup[]): SidebarGroup[] =>
324
342
  groups.flatMap((group) => {
@@ -343,6 +361,7 @@ function DocSidebarTree({
343
361
  sections,
344
362
  activeSlug,
345
363
  onSelect,
364
+ onNavigate = navigate,
346
365
  forceExpanded = false,
347
366
  initiallyCollapsed = false,
348
367
  links = [],
@@ -350,6 +369,7 @@ function DocSidebarTree({
350
369
  sections: SidebarSection[];
351
370
  activeSlug?: string;
352
371
  onSelect: (slug: string) => void;
372
+ onNavigate?: (href: string) => void;
353
373
  forceExpanded?: boolean;
354
374
  initiallyCollapsed?: boolean;
355
375
  links?: DocBrowserProps["sidebarLinks"];
@@ -496,7 +516,7 @@ function DocSidebarTree({
496
516
  onClick={
497
517
  link.newTab || /^https?:\/\//i.test(link.href)
498
518
  ? undefined
499
- : () => navigate(link.href)
519
+ : () => onNavigate(link.href)
500
520
  }
501
521
  label={link.label}
502
522
  icon={
@@ -510,6 +530,17 @@ function DocSidebarTree({
510
530
  );
511
531
  }
512
532
 
533
+ const compactNavigationQuery = "(max-width: 48rem)";
534
+ const subscribeCompactNavigation = (notify: () => void): (() => void) => {
535
+ const media = window.matchMedia(compactNavigationQuery);
536
+ media.addEventListener("change", notify);
537
+ return () => media.removeEventListener("change", notify);
538
+ };
539
+ const compactNavigationSnapshot = (): boolean =>
540
+ window.matchMedia(compactNavigationQuery).matches;
541
+ const serverNavigationSnapshot = (): boolean => false;
542
+ const skipNavigationSubscription = (): (() => void) => () => {};
543
+
513
544
  export function DocBrowser({
514
545
  basePath = "/docs",
515
546
  path: controlledPath,
@@ -525,8 +556,19 @@ export function DocBrowser({
525
556
  content,
526
557
  sidebarLinks = [],
527
558
  sidebarInitiallyCollapsed = false,
559
+ collapsibleNavigation = false,
528
560
  }: DocBrowserProps): React.ReactElement {
529
561
  const [sidebarQuery, setSidebarQuery] = useState("");
562
+ const watchViewport = sidebar && collapsibleNavigation;
563
+ const narrowViewport = useSyncExternalStore(
564
+ watchViewport ? subscribeCompactNavigation : skipNavigationSubscription,
565
+ watchViewport ? compactNavigationSnapshot : serverNavigationSnapshot,
566
+ serverNavigationSnapshot,
567
+ );
568
+ const compactNavigation = collapsibleNavigation && narrowViewport;
569
+ const [navigationOpen, setNavigationOpen] = useState(false);
570
+ const navigationId = useId();
571
+ const navigationButtonRef = useRef<HTMLButtonElement>(null);
530
572
  // Standalone: o path vem do router da casa (`usePathname` — pathname É o estado).
531
573
  // Controlado: o host manda, e este valor é ignorado. O hook roda sempre (regra dos
532
574
  // hooks); o que muda é quem vence no `??`.
@@ -542,9 +584,10 @@ export function DocBrowser({
542
584
  const searchOptions = typeof search === "object" ? search : {};
543
585
  const query =
544
586
  searchOptions.placement === "external" ? searchOptions.value : sidebarQuery;
587
+ const previousQuery = useRef(query);
545
588
  const visibleSections = useMemo(
546
- () => filterSections(sections, query),
547
- [query, sections],
589
+ () => filterSections(sections, query, basePath),
590
+ [query, sections, basePath],
548
591
  );
549
592
  const hasCustomContent = content !== undefined;
550
593
  const page = hasCustomContent
@@ -555,7 +598,40 @@ export function DocBrowser({
555
598
 
556
599
  // `navigate` emite `popstate`, então o host é notificado nos dois modos — inclusive o
557
600
  // que ainda escuta o evento na unha, que era como este componente avisava antes.
558
- const go = (to: string): void => navigate(to);
601
+ const go = (to: string): void => {
602
+ if (compactNavigation) {
603
+ setNavigationOpen(false);
604
+ navigationButtonRef.current?.focus();
605
+ }
606
+ navigate(to);
607
+ };
608
+
609
+ useEffect(() => {
610
+ setNavigationOpen(false);
611
+ if (
612
+ collapsibleNavigation &&
613
+ document.getElementById(navigationId)?.contains(document.activeElement)
614
+ )
615
+ navigationButtonRef.current?.focus();
616
+ }, [path, collapsibleNavigation, navigationId]);
617
+
618
+ useEffect(() => {
619
+ if (previousQuery.current === query) return;
620
+ previousQuery.current = query;
621
+ if (collapsibleNavigation && query.trim()) setNavigationOpen(true);
622
+ }, [collapsibleNavigation, query]);
623
+
624
+ useEffect(() => {
625
+ if (!sidebar || !compactNavigation || !navigationOpen) return;
626
+ const closeOnEscape = (event: KeyboardEvent): void => {
627
+ if (event.key !== "Escape" || event.defaultPrevented) return;
628
+ event.preventDefault();
629
+ setNavigationOpen(false);
630
+ navigationButtonRef.current?.focus();
631
+ };
632
+ document.addEventListener("keydown", closeOnEscape);
633
+ return () => document.removeEventListener("keydown", closeOnEscape);
634
+ }, [sidebar, compactNavigation, navigationOpen]);
559
635
 
560
636
  useEffect(() => {
561
637
  if (!hasCustomContent && requestedSlug !== slug)
@@ -592,9 +668,14 @@ export function DocBrowser({
592
668
  <div className="mx-auto max-w-3xl px-8 py-8">{page?.render()}</div>
593
669
  );
594
670
 
595
- return (
671
+ const browser = (
596
672
  <Split className="h-full">
597
- <Pane initialSize={sidebarSize}>
673
+ <Pane
674
+ id={collapsibleNavigation ? navigationId : undefined}
675
+ initialSize={compactNavigation ? undefined : sidebarSize}
676
+ grow={compactNavigation}
677
+ className={compactNavigation && !navigationOpen ? "hidden" : undefined}
678
+ >
598
679
  <Sidebar className="w-full">
599
680
  <div className="flex min-h-0 flex-1 flex-col">
600
681
  {search !== false && searchOptions.placement !== "external" && (
@@ -632,6 +713,7 @@ export function DocBrowser({
632
713
  forceExpanded={query.trim() !== ""}
633
714
  initiallyCollapsed={sidebarInitiallyCollapsed}
634
715
  links={sidebarLinks}
716
+ onNavigate={go}
635
717
  onSelect={(nextSlug) => go(`${basePath}/${nextSlug}`)}
636
718
  />
637
719
  </div>
@@ -641,10 +723,38 @@ export function DocBrowser({
641
723
  <Pane
642
724
  key={hasCustomContent ? path : page?.slug}
643
725
  grow
644
- className="overflow-y-auto"
726
+ className={
727
+ compactNavigation && navigationOpen ? "hidden" : "overflow-y-auto"
728
+ }
645
729
  >
646
730
  {readingContent}
647
731
  </Pane>
648
732
  </Split>
649
733
  );
734
+
735
+ if (!collapsibleNavigation) return browser;
736
+ return (
737
+ <div data-slot="doc-browser" className="flex h-full min-h-0 flex-col">
738
+ {compactNavigation && (
739
+ <div className="shrink-0 border-b border-border p-2">
740
+ <Button
741
+ ref={navigationButtonRef}
742
+ context="neutral"
743
+ variant="ghost"
744
+ aria-controls={navigationId}
745
+ aria-expanded={navigationOpen}
746
+ onClick={() => setNavigationOpen((open) => !open)}
747
+ >
748
+ {navigationOpen ? (
749
+ <X aria-hidden="true" />
750
+ ) : (
751
+ <Menu aria-hidden="true" />
752
+ )}
753
+ Navegação
754
+ </Button>
755
+ </div>
756
+ )}
757
+ {browser}
758
+ </div>
759
+ );
650
760
  }
@@ -536,7 +536,7 @@ render(
536
536
  | `text` | `{ fields }` — liga a busca; convenção: param `q` no input. Ela aparece depois dos filtros inline e imediatamente antes do acesso aos filtros avançados. A busca participa da contagem de controles inline: com mais de um controle, a barra exibe as labels; quando ela é o único recorte, sua label é omitida. |
537
537
  | `sort` | `{ fields, default }` — ordenação inicial; header ordenável escreve `sort: 'chave:dir'`. |
538
538
  | `periods` | `{ value, label, default? }[]` — o controle de período (presets + Personalizado com calendário); `default: true` marca o preset inicial, senão vale o primeiro; materializa em `from`/`to` no input. |
539
- | `grouping` | `{ fields }` — dimensões enumeráveis disponíveis em **Agrupar por**. Cada nome referencia um filtro `select` ou `lookup` com até 12 opções declaradas ou resolvidas por `filterOptions`. Não define agrupamento inicial. |
539
+ | `grouping` | `{ fields }` — dimensões enumeráveis disponíveis em **Agrupar por**. Cada nome referencia um filtro `select` ou `lookup` com até 12 opções declaradas ou resolvidas por `filterOptions`. Não define agrupamento inicial. |
540
540
 
541
541
  ## useListAction
542
542
 
@@ -547,6 +547,14 @@ declarativo (`items`, `total`, `error`, `isLoading`, `isFetching`, `refetch`) so
547
547
  chamar a action. `options.enabled` suspende a consulta enquanto for `false`; sem a opção, vale
548
548
  `true`.
549
549
 
550
+ ## Controles da visualização
551
+
552
+ Controles específicos da visualização ativa, como a orientação de um comparativo, usam
553
+ `viewControls`. O slot fica imediatamente após o seletor de visualização. As configurações da lista
554
+ aparecem antes desse conjunto. O consumidor controla seu estado e quando exibi-lo. Isso mantém a
555
+ configuração visual junto à escolha da visualização, sem deslocar ações sobre o recorte
556
+ para fora de `toolbarActions`.
557
+
550
558
  ## Propriedades de ActionList
551
559
 
552
560
  | Propriedade | Tipo | Padrão | Descrição |
@@ -555,7 +563,7 @@ chamar a action. `options.enabled` suspende a consulta enquanto for `false`; sem
555
563
  | `input` | `TInput` | | O escopo base, como `{ workspaceId }`. A toolbar acrescenta busca, filtros e paginação sem sobrescrever essas chaves. |
556
564
  | `layout` | `'flow' \| 'fill'` | `'fill'` como filha direta de `PageBody`, `SurfaceBody` ou do corpo da `Presentation` em uma página `fill`; `'flow'` nos demais casos | Em `fill`, ocupa a altura de um ancestral limitado e rola somente a região de resultados; na tabela, o cabeçalho fica fixo. Sem ancestral limitado, equivale a `flow`. |
557
565
  | `cells` | `Record<string, (item) => ReactNode>` | | Células custom por cima das colunas do contrato (chave = column.key). |
558
- | `filterOptions` | `Record<string, SelectOption[]>` | | Opções de runtime para filtros select/lookup. Em uma dimensão de agrupamento, a coleção precisa ser completa e ter de 1 a 12 itens. |
566
+ | `filterOptions` | `Record<string, SelectOption[]>` | | Opções de runtime para filtros select/lookup. Em uma dimensão de agrupamento, a coleção precisa ser completa e ter de 1 a 12 itens. |
559
567
  | `filterDefaults` | `Record<string, string>` | | Valores de repouso aplicados à consulta; o `X` só aparece quando o filtro diverge deles e retorna a eles. |
560
568
  | `filterIcons` | `Record<string, ReactNode>` | | Ícones leading dos filtros, indexados pelo nome declarado no contrato. |
561
569
  | `advancedFilters` | `{ columns?: 1 \| 2 \| 3; sectionIcons?: Record<string, ReactNode>; sectionVariant?: 'plain' \| 'framed' }` | `{ columns: 1, sectionVariant: 'plain' }` | Define as colunas, os ícones e o tratamento dos grupos; o Opus deriva a largura correspondente do diálogo. |
@@ -575,5 +583,6 @@ chamar a action. `options.enabled` suspende a consulta enquanto for `false`; sem
575
583
  | `errorMessage` | `string` | `'Não foi possível carregar.'` | Título do aviso de erro; a ação de tentar de novo refaz a consulta. |
576
584
  | `retryLabel` | `string` | `'Tentar de novo'` | Nome acessível e tooltip da ação de recuperação. |
577
585
  | `onRowClick` | `(item: TItem) => void` | | Clique na linha (ex.: navegar para o detalhe) — só na tabela. |
586
+ | `viewControls` | `ReactNode` | | Controles da visualização ativa, imediatamente após o seletor de visualização. |
578
587
  | `toolbarActions` | `ReactNode` | | Operações ligadas ao recorte atual, no fim da barra e separadas do grupo de controles. Os nós são renderizados como recebidos: nome acessível e tooltip de uma ação somente com ícone ficam com o consumidor. |
579
588
  | `rowActions` | `(item: TItem) => ReactNode` | | Ações por linha (coluna final, à direita). A partir de duas, entram em um grupo com intervalo compacto; uma só fica solta, sem grupo. Cliques ali não disparam o `onRowClick`. |
@@ -32,22 +32,22 @@ há texto visível. O glifo dentro do controle acompanha o tamanho (0.875rem em
32
32
  | Nome | Medida | Uso |
33
33
  | --------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------- |
34
34
  | `xs` | 1.5rem | Ação dentro de um campo (`InputGroupButton`). |
35
- | `sm` | 2rem | Operação densa em toolbar ou dentro de uma seção, ao lado de `Select` e `Tabs` `sm`. |
36
- | `default` | 2.25rem | Decisão da superfície: header de Page, criação no cabeçalho de uma coleção e footer de Dialog ou Drawer; é também a linha de `Input`. |
35
+ | `sm` | 2rem | Headers, criação de coleções e operações densas de toolbar ou seção. |
36
+ | `default` | 2.25rem | Footers de Dialog e Drawer e controles comuns; é também a linha de `Input`. |
37
37
  | `lg` | 2.5rem | Chamada principal com mais área de toque. |
38
38
  | `icon-xs` | 1.5rem | Ação só de ícone dentro de um campo ou de uma linha densa; é o quadrado de `ActionTrigger` com `icon`. |
39
- | `icon-sm` | 1.75rem | Ação só de ícone em composições compactas que precisam de mais presença; também é o quadrado das setas do pager de `ActionList`. |
39
+ | `icon-sm` | 2rem | Ação só de ícone em composições compactas que precisam de mais presença; também é o quadrado das setas do pager de `ActionList`. |
40
40
  | `icon` | 2.25rem | Ação só de ícone na linha padrão. |
41
41
  | `icon-lg` | 2.5rem | Ação só de ícone ao lado de um `lg`. |
42
42
 
43
43
  Escolha o tamanho pela região, não pela importância visual: `variant` e `context` resolvem a
44
- hierarquia da ação. Headers de Page, a criação no cabeçalho de uma coleção e footers de
45
- [`Dialog`](/ui/dialog) e [`Drawer`](/ui/drawer) usam `default`; a criação de uma coleção é um botão textual, sem
46
- ícone, como mostra [`Surface`](/ui/surface). Operações densas de toolbar ou de seção usam `sm`;
44
+ hierarquia da ação. Headers de Page, Dialog, Drawer e Surface usam `sm`/`icon-sm`. A criação
45
+ no cabeçalho de uma coleção usa `sm` textual, sem ícone. Footers de
46
+ [`Dialog`](/ui/dialog) e [`Drawer`](/ui/drawer) conservam `default`. Operações densas de toolbar ou de seção usam `sm`;
47
47
  ações dentro de linha ou célula usam `xs` ou `icon-xs`. Assim a mesma decisão mantém a mesma altura
48
48
  mesmo quando uma superfície troca uma ação secundária por uma primária.
49
49
 
50
- O retorno de `PageBack` usa `icon`. O fechamento de Dialog e Drawer é um `CloseButton` circular
50
+ O retorno de `PageBack` usa `icon-sm`. O fechamento de Dialog e Drawer é um `CloseButton` circular
51
51
  (`shape="pill"`), `neutral` e `subtle`, no quadrado `icon-sm` por padrão, ou `icon-xs` com
52
52
  `closeSize="xs"`.
53
53
 
@@ -24,8 +24,8 @@ Cada família dá um nome próprio a cada região. A tabela mostra a correspond
24
24
  | Navegação | `PageNavigation` ou `PageBack` | `DialogNavigation` | `DrawerNavigation` | Não existe |
25
25
  | Título no cabeçalho | `PageTitle` | `DialogTitle` | `DrawerTitle` | `SurfaceTitle` |
26
26
  | Ações do cabeçalho | `PageActions` | `DialogActions` | `DrawerActions` | `SurfaceActions` |
27
- | Intro | `Surface level={1}` no início do `PageBody` | `DialogIntro` | `DrawerIntro` | Não existe |
28
- | Ações da intro | Ações da `Surface level={1}` | `DialogIntroActions` | `DrawerIntroActions` | Não existe |
27
+ | Intro | `PageIntro` no início do `PageBody` | `DialogIntro` | `DrawerIntro` | Não existe |
28
+ | Ações da intro | `PageIntroActions` | `DialogIntroActions` | `DrawerIntroActions` | Não existe |
29
29
  | Corpo | `PageBody` | `DialogBody` | `DrawerBody` | `SurfaceBody` |
30
30
  | Rodapé | `PageFooter` | `DialogFooter` | `DrawerFooter` | `SurfaceFooter` |
31
31
  | Divisores | Antes do `PageFooter` | `sectioned` (padrão) | `sectioned` (padrão) | `sectioned` |
@@ -97,7 +97,7 @@ function DialogWithIntro({ layout, label }) {
97
97
  <DialogContent layout={layout}>
98
98
  <DialogHeader>
99
99
  <DialogActions>
100
- <Button variant="ghost" size="icon" aria-label="Abrir conversa">
100
+ <Button variant="ghost" size="icon-sm" aria-label="Abrir conversa">
101
101
  <MessageCircle />
102
102
  </Button>
103
103
  </DialogActions>
@@ -142,7 +142,7 @@ function CompactDialog({ layout, label }) {
142
142
  <Button variant="outline" size="sm">
143
143
  Ver histórico
144
144
  </Button>
145
- <Button variant="ghost" size="icon" aria-label="Abrir conversa">
145
+ <Button variant="ghost" size="icon-sm" aria-label="Abrir conversa">
146
146
  <MessageCircle />
147
147
  </Button>
148
148
  </DialogActions>
@@ -197,7 +197,7 @@ function OrderField() {
197
197
  function CollapseButton() {
198
198
  return (
199
199
  <DrawerClose asChild>
200
- <Button variant="ghost" size="icon" aria-label="Recolher painel">
200
+ <Button variant="ghost" size="icon-sm" aria-label="Recolher painel">
201
201
  <ChevronsRight />
202
202
  </Button>
203
203
  </DrawerClose>
@@ -216,7 +216,7 @@ function DrawerWithIntro({ layout, label }) {
216
216
  <CollapseButton />
217
217
  </DrawerNavigation>
218
218
  <DrawerActions>
219
- <Button variant="ghost" size="icon" aria-label="Abrir conversa">
219
+ <Button variant="ghost" size="icon-sm" aria-label="Abrir conversa">
220
220
  <MessageCircle />
221
221
  </Button>
222
222
  </DrawerActions>
@@ -261,7 +261,7 @@ function CompactDrawer({ layout, label }) {
261
261
  <Button variant="outline" size="sm">
262
262
  Ver histórico
263
263
  </Button>
264
- <Button variant="ghost" size="icon" aria-label="Abrir conversa">
264
+ <Button variant="ghost" size="icon-sm" aria-label="Abrir conversa">
265
265
  <MessageCircle />
266
266
  </Button>
267
267
  </DrawerActions>
@@ -300,8 +300,8 @@ painel.
300
300
 
301
301
  ## Page
302
302
 
303
- A `Page` não tem um slot de intro. Com intro, o `PageHeader` guarda a navegação e as ações da
304
- página, e uma `Surface level={1}` abre o `PageBody` com o título e as ações do conteúdo. A página
303
+ Na composição com intro, o `PageHeader` guarda a navegação e as ações da
304
+ página, e uma `PageIntro` abre o `PageBody` com o título e as ações do conteúdo. A página
305
305
  precisa de exatamente um heading principal, por isso o `PageHeader` dessa composição não leva
306
306
  `PageTitle`.
307
307
 
@@ -322,27 +322,20 @@ render(
322
322
  </Breadcrumb>
323
323
  </PageNavigation>
324
324
  <PageActions>
325
- <Button variant="ghost" size="icon" aria-label="Abrir conversa">
325
+ <Button variant="ghost" size="icon-sm" aria-label="Abrir conversa">
326
326
  <MessageCircle />
327
327
  </Button>
328
328
  </PageActions>
329
329
  </PageHeader>
330
330
  <PageBody>
331
- <Surface
332
- as="section"
333
- level={1}
334
- title="Editar ordem de serviço"
335
- actions={
336
- <Button variant="outline" size="sm">
337
- Ver histórico
338
- </Button>
339
- }
340
- >
331
+ <PageIntro>
332
+ <PageTitle>Editar ordem de serviço</PageTitle>
333
+ <PageIntroActions><Button variant="outline" size="sm">Ver histórico</Button></PageIntroActions>
334
+ </PageIntro>
341
335
  <div className="space-y-2">
342
336
  <Label htmlFor="page-intro-owner">Responsável</Label>
343
337
  <Input id="page-intro-owner" defaultValue="Marina Alves" />
344
338
  </div>
345
- </Surface>
346
339
  </PageBody>
347
340
  <PageFooter>
348
341
  <ButtonGroup mode="spaced">
@@ -380,7 +373,7 @@ render(
380
373
  <Button variant="outline" size="sm">
381
374
  Ver histórico
382
375
  </Button>
383
- <Button variant="ghost" size="icon" aria-label="Abrir conversa">
376
+ <Button variant="ghost" size="icon-sm" aria-label="Abrir conversa">
384
377
  <MessageCircle />
385
378
  </Button>
386
379
  </PageActions>
@@ -449,7 +442,7 @@ render(
449
442
  <Button variant="outline" size="sm">
450
443
  Ver histórico
451
444
  </Button>
452
- <Button variant="ghost" size="icon" aria-label="Abrir conversa">
445
+ <Button variant="ghost" size="icon-sm" aria-label="Abrir conversa">
453
446
  <MessageCircle />
454
447
  </Button>
455
448
  </SurfaceActions>
@@ -477,3 +470,22 @@ Os dois panes recebem conteúdos diferentes sem mudar de componente: o primeiro
477
470
  [`Sidebar`](/ui/sidebar), que já tem anatomia própria, e o segundo, uma `Surface` completa. A
478
471
  rolagem também pertence ao conteúdo: a `Surface` ocupa a altura do pane com `flex h-full flex-col`,
479
472
  e o `SurfaceBody` declara `min-h-0 flex-1 overflow-y-auto` para rolar entre o cabeçalho e o rodapé.
473
+
474
+ ## Ritmo entre regiões e blocos
475
+
476
+ Surface (incluindo cards e seções), Page, Dialog, Drawer e Tabs organizam o conteúdo
477
+ com intervalos de `1rem`. Os bodies separam seus blocos diretos; grupos internos podem
478
+ ter densidade própria. DetailGroup e Separator não criam margens externas.
479
+
480
+ Sem delimitação, Surface e Tabs não adicionam padding inferior. Uma Surface com moldura
481
+ ou elevação, assim como Dialog e Drawer, reserva `1rem` até a borda. No layout contínuo,
482
+ o intervalo vem da região anterior. No seccionado, há `1rem` entre cada divisória e o
483
+ conteúdo adjacente. Não some padding e gap para expressar o mesmo intervalo.
484
+
485
+ Page conserva os recuos laterais e a largura de leitura. Seu body não acrescenta padding
486
+ inferior quando termina a página; antes de um footer seccionado, reserva `1rem` até a linha.
487
+ O modo `flush` remove recuos do body para uma lista ou tabela controlar sua própria borda,
488
+ mas mantém o intervalo entre blocos.
489
+
490
+ Ao migrar, remova `space-y`, `gap`, `pt` e `pb` locais que apenas reconstruíam esses defaults
491
+ nos slots. Preserve classes de layout internas, como uma grade ou um grupo de campos compacto.
@@ -48,7 +48,7 @@ distinta da página.
48
48
  ```
49
49
 
50
50
  O corpo cresce até o limite da janela e passa a rolar; cabeçalho e rodapé permanecem visíveis.
51
- Seus filhos diretos recebem o ritmo estrutural de `1.5rem`. Um grupo compacto, uma grade ou uma
51
+ Seus filhos diretos recebem o ritmo estrutural de `1rem`. Um grupo compacto, uma grade ou uma
52
52
  linha declara a própria relação em um wrapper filho; `flush` fica reservado ao conteúdo que
53
53
  controla todo o inset.
54
54
  O backdrop escurece a página sem aplicar desfoque.
@@ -126,13 +126,13 @@ fluxo modal encadeado, em que a superfície permanece aberta e troca de etapa.
126
126
  <DialogContent>
127
127
  <DialogHeader>
128
128
  <DialogNavigation>
129
- <Button variant="ghost" size="icon" aria-label="Voltar para a lista">
129
+ <Button variant="ghost" size="icon-sm" aria-label="Voltar para a lista">
130
130
  <ArrowLeft />
131
131
  </Button>
132
132
  </DialogNavigation>
133
133
  <DialogTitle>Evento 4821</DialogTitle>
134
134
  <DialogActions>
135
- <Button variant="ghost" size="icon" aria-label="Mais ações">
135
+ <Button variant="ghost" size="icon-sm" aria-label="Mais ações">
136
136
  <Ellipsis />
137
137
  </Button>
138
138
  </DialogActions>
@@ -146,9 +146,10 @@ fluxo modal encadeado, em que a superfície permanece aberta e troca de etapa.
146
146
  </Dialog>
147
147
  ```
148
148
 
149
- Voltar e ações do cabeçalho usam `Button` `ghost` no tamanho `icon` (2.25rem), o mesmo que a
150
- [`Presentation`](/ui/presentation) usa no voltar. O botão de fechar é menor: um `CloseButton` no
151
- quadrado `icon-sm` (1.75rem) por padrão.
149
+ O header compartilha a geometria de Page e Surface delimitada: padding de `1rem` e
150
+ linha mínima de `2rem`, nos dois layouts. Declare `size="sm"` nos botões textuais e
151
+ `size="icon-sm"` nos de ícone (`2rem`), incluindo o retorno. `CloseButton` já usa
152
+ `icon-sm` por padrão. Conteúdo maior pode expandir a linha; rodapés preservam sua escala.
152
153
 
153
154
  O cabeçalho aceita `DialogDescription` como subheading opcional para orientar sobre a finalidade.
154
155
  Omita quando repetir o título. Condições, regras de negócio e consequências necessárias à decisão
@@ -656,3 +657,14 @@ comparar as duas formas em cada `layout`, abra [Composições](/ui/compositions)
656
657
  | `id` | `string` | | Identificador para associação acessível explícita. |
657
658
  | `className` | `string` | | Ajusta a apresentação do parágrafo. |
658
659
  | `…props` | `ComponentProps<"p">` | | Atributos nativos. |
660
+
661
+ ## Espaçamento do corpo
662
+
663
+ O body separa os blocos diretos por `1rem`. Dialog e Drawer são superfícies delimitadas:
664
+ o conteúdo mantém `1rem` até a borda inferior, mesmo sem uma propriedade `framed`.
665
+ Em `sectioned`, o corpo tem `1rem` entre o conteúdo e cada divisória. Em `continuous`,
666
+ o cabeçalho fornece o intervalo superior; sem cabeçalho, o próprio corpo abre esse recuo.
667
+ Use `flush` quando o conteúdo controlar seus próprios recuos.
668
+
669
+ Na intro, `DialogTitle` usa a mesma escala de `PageTitle` (`1.875rem`). No header
670
+ compacto, o título conserva a escala de `1.125rem`. O nível semântico não altera essas medidas.
@@ -88,7 +88,45 @@ que a pessoa possa consultar, inclusive aquelas disponíveis por URL direta.
88
88
  A adaptação do cabeçalho, da leitura editorial e dos catálogos à largura da tela é uma exceção
89
89
  restrita ao portal documental. A UI das aplicações e os componentes dos exemplos vivos preservam
90
90
  o contrato desktop a partir de `64rem`. O aplicativo organiza a moldura do portal em telas estreitas;
91
- `DocBrowser` não muda sozinho a orientação de `Split` nem transforma os exemplos em layouts móveis.
91
+ `DocBrowser` não transforma os exemplos em layouts móveis.
92
+
93
+ ### Navegação em telas estreitas
94
+
95
+ Em portais autônomos, use `collapsibleNavigation` para oferecer um menu recolhível em telas
96
+ de até `48rem`. O botão Navegação abre a árvore na área disponível, no lugar da leitura.
97
+ Selecionar um destino interno ou pressionar Escape recolhe o menu. A busca global abre
98
+ os resultados quando a consulta muda; uma consulta já preenchida na montagem não abre
99
+ o menu sobre a leitura. Ramos abertos manualmente são preservados. Acima desse limite, a lateral
100
+ permanece visível. Sem essa opção, a composição existente não muda.
101
+
102
+ ```tsx
103
+ <DocBrowser sections={sections} editorial collapsibleNavigation />
104
+ ```
105
+
106
+ Ao adotar a opção, retire do host as regras móveis que limitam a altura ou a dimensão
107
+ inicial de `Split` e `Pane`, como a antiga faixa de `14rem`. O navegador passa a organizar
108
+ essa área; a moldura externa continua sendo do aplicativo. Preserve a mesma instância
109
+ de `DocBrowser` ao trocar de área: uma `key` baseada na área descarta o botão que
110
+ recebeu o foco e os ramos abertos. Áreas com mapas diferentes usam `basePath` distintos:
111
+ cada uma preserva seus próprios ramos, sem expandir capítulos da outra. Catálogos que
112
+ compartilham o mapa mantêm o mesmo `basePath`. Dentro de cada mapa, a ordem dos ramos
113
+ deve permanecer estável durante a navegação. Escape retorna ao botão Navegação e mantém a consulta.
114
+
115
+ ### Histórico fora da sequência de leitura
116
+
117
+ Um arquivo histórico pode continuar disponível no menu e na busca sem aparecer como próxima
118
+ leitura de um guia vigente. Declare `readingSequence: false` na sua `DocSection`:
119
+
120
+ ```tsx
121
+ const archive: DocSection = {
122
+ label: 'Registros históricos',
123
+ readingSequence: false,
124
+ groups: [{ pages: historicalPages }],
125
+ }
126
+ ```
127
+
128
+ A opção afeta somente os links anterior/próxima. URLs diretas e contexto continuam disponíveis;
129
+ ela não é uma restrição de acesso. Seções sem essa declaração participam da sequência.
92
130
 
93
131
  ## Consulte decisões e entregas
94
132
 
@@ -205,6 +243,7 @@ não define as rotas nem a autorização do aplicativo.
205
243
  | `content` | `ReactNode` | — | Conteúdo do host no lugar da página documental, mantendo a lateral. O host resolve rota e título; `null` deixa a leitura vazia. |
206
244
  | `sidebarLinks` | `{ label: string; href: string; active?: boolean; newTab?: boolean }[]` | `[]` | Destinos globais fora dos capítulos; `active` marca o catálogo atual e `newTab` abre em outra aba com ícone e descrição acessível. |
207
245
  | `sidebarInitiallyCollapsed` | `boolean` | `false` | Seções e grupos nomeados começam recolhidos, inclusive em links diretos. A busca revela resultados temporariamente sem alterar os ramos abertos pela pessoa. |
246
+ | `collapsibleNavigation` | `boolean` | `false` | Em telas de até `48rem`, oferece um botão para alternar entre navegação e leitura, preservando a árvore. |
208
247
 
209
248
  A entrada histórica `@softize/opus/ui/docs` inclui o catálogo e oferece suas páginas quando
210
249
  `sections` é omitida. Para conteúdo próprio, prefira a entrada leve do exemplo.
@@ -119,7 +119,7 @@ retorno de um fluxo encadeado, em que o painel permanece aberto e troca de etapa
119
119
  <DrawerContent side="right">
120
120
  <DrawerHeader>
121
121
  <DrawerNavigation>
122
- <Button variant="ghost" size="icon" aria-label="Voltar para a lista">
122
+ <Button variant="ghost" size="icon-sm" aria-label="Voltar para a lista">
123
123
  <ArrowLeft />
124
124
  </Button>
125
125
  </DrawerNavigation>
@@ -130,7 +130,7 @@ retorno de um fluxo encadeado, em que o painel permanece aberto e troca de etapa
130
130
  </DrawerMedia>
131
131
  <DrawerTitle>ABC-1D23</DrawerTitle>
132
132
  <DrawerActions>
133
- <Button variant="ghost" size="icon" aria-label="Mais ações">
133
+ <Button variant="ghost" size="icon-sm" aria-label="Mais ações">
134
134
  <Ellipsis />
135
135
  </Button>
136
136
  </DrawerActions>
@@ -144,9 +144,10 @@ retorno de um fluxo encadeado, em que o painel permanece aberto e troca de etapa
144
144
  </Drawer>
145
145
  ```
146
146
 
147
- Voltar e ações do cabeçalho usam `Button` `ghost` no tamanho `icon` (2.25rem), o mesmo que a
148
- [`Presentation`](/ui/presentation) usa no voltar. O botão de fechar é menor: um `CloseButton` no
149
- quadrado `icon-sm` (1.75rem) por padrão.
147
+ O header compartilha a geometria de Page e Surface delimitada: padding de `1rem` e
148
+ linha mínima de `2rem`, nos dois layouts. Declare `size="sm"` nos botões textuais e
149
+ `size="icon-sm"` nos de ícone (`2rem`), incluindo o retorno. `CloseButton` já usa
150
+ `icon-sm` por padrão. Conteúdo maior pode expandir a linha; rodapés preservam sua escala.
150
151
 
151
152
  O cabeçalho aceita `DrawerDescription` como subheading opcional para orientar sobre a finalidade.
152
153
  Omita quando repetir o título. Condições, regras de negócio e consequências necessárias à decisão
@@ -323,3 +324,14 @@ comparar as duas formas em cada `layout`, abra [Composições](/ui/compositions)
323
324
  | `id` | `string` | | Identificador para associação acessível explícita. |
324
325
  | `className` | `string` | | Ajusta a apresentação do parágrafo. |
325
326
  | `…props` | `ComponentProps<"p">` | | Atributos nativos. |
327
+
328
+ ## Espaçamento do corpo
329
+
330
+ O body separa os blocos diretos por `1rem`. Dialog e Drawer são superfícies delimitadas:
331
+ o conteúdo mantém `1rem` até a borda inferior, mesmo sem uma propriedade `framed`.
332
+ Em `sectioned`, o corpo tem `1rem` entre o conteúdo e cada divisória. Em `continuous`,
333
+ o cabeçalho fornece o intervalo superior; sem cabeçalho, o próprio corpo abre esse recuo.
334
+ Use `flush` quando o conteúdo controlar seus próprios recuos.
335
+
336
+ Na intro, `DrawerTitle` usa a mesma escala de `PageTitle` (`1.875rem`). No header
337
+ compacto, o título conserva a escala de `1.125rem`. O nível semântico não altera essas medidas.