@softize/opus 26.2.0 → 27.1.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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,63 @@ Depois de qualquer bump, rode os gates (`typecheck` · `test` · `opus check` ·
7
7
  `opus copy --check` · `base copy check` · `manifest:check`) — eles apontam o que a
8
8
  mudança cobra do seu código.
9
9
 
10
+ ## 27.1.0 — 2026-10-13
11
+
12
+ Portais documentais podem oferecer navegação recolhível em telas estreitas com
13
+ `DocBrowser.collapsibleNavigation`. O menu ocupa a área disponível, preserva os ramos
14
+ abertos e mostra os resultados da busca; selecionar um destino interno ou pressionar
15
+ Escape retorna à leitura. A composição desktop e consumidores sem a opção não mudam.
16
+
17
+ **Ao adotar:** remova as regras CSS móveis do host que limitavam `Split` e `Pane` a
18
+ uma faixa curta. Preserve a instância de `DocBrowser` entre áreas, sem uma `key`
19
+ que remonte o navegador a cada troca; isso mantém a busca, os ramos abertos e o foco.
20
+ Mapas distintos usam `basePath` distintos para preservar a abertura dos ramos de cada
21
+ área. Uma consulta já preenchida na montagem não abre o menu; editar a busca revela
22
+ os resultados.
23
+
24
+ `DocSection.readingSequence: false` mantém uma seção na navegação e na busca, mas a retira
25
+ dos links de leitura anterior/próxima. Isso permite consultar registros históricos sem
26
+ apresentá-los como continuidade de guias vigentes.
27
+
28
+ ## 27.0.0 — 2026-10-13
29
+
30
+ O aplicativo passa a saber, na recusa de um anexo, o que entrou na mesma ação, e o glob do runtime
31
+ de design passa a seguir a semântica usual de `**`. A seção **Breaking** traz as migrações, que
32
+ atingem só quem chama `onReject` diretamente ou passa um handler com um segundo parâmetro opcional de
33
+ outro tipo.
34
+
35
+ - **Recusa com os aceitos da mesma ação.** `ComposerAttachments.onReject` recebe um segundo
36
+ argumento, `ComposerRejectionContext`, com `accepted`: os arquivos aceitos na mesma escolha,
37
+ colagem ou soltura, ou uma lista vazia quando nada passou. Assim o aplicativo explica uma soltura
38
+ mista, como uma imagem e um PDF arrastados juntos, sem guardar estado entre as duas chamadas. O
39
+ tipo `ComposerRejectionContext` sai de `@softize/opus/ui/react`.
40
+ - **Ordem das chamadas documentada.** Em uma ação com arquivos aceitos e recusados, `onAddFiles`
41
+ roda antes de `onReject`, de forma síncrona e uma vez cada. O mesmo vale para o soltar sobre a
42
+ conversa do `Chat`. A ordem já era essa; agora faz parte do contrato.
43
+ - **`**` casa zero ou mais pastas no glob de `opusDesign`.** Em `entry` e `scenarios`, `**` como
44
+ pasta inteira segue a semântica usual. Antes, `design/scenarios/**/*.ts` exigia pelo menos uma
45
+ subpasta e ignorava, sem aviso, o cenário salvo direto em `design/scenarios/`. Um projeto com
46
+ `**/` no padrão pode passar a carregar arquivos que antes ficavam de fora; se dois cenários da
47
+ mesma action passarem a entrar, o runtime de design deixa de subir e o erro lista a action.
48
+ - **`?` literal no glob.** Antes, o `?` virava quantificador de regex, e `design/*/a?.ts` casava
49
+ `design/x/.ts`. Os demais padrões sem `**` continuam casando os mesmos arquivos.
50
+
51
+ ### Breaking
52
+
53
+ **O contexto de `onReject` é obrigatório na assinatura.** Quem implementa o callback com um só
54
+ parâmetro não muda nada. Duas situações deixam de compilar:
55
+
56
+ - quem chama `onReject` diretamente, como ao reaproveitar o tipo `ComposerAttachments` em uma área de
57
+ soltar própria. Migração: passe `{ accepted }`, com uma lista vazia quando nada foi aceito:
58
+
59
+ ```ts
60
+ // antes: attachments.onReject?.(rejected)
61
+ attachments.onReject?.(rejected, { accepted })
62
+ ```
63
+
64
+ - quem passa direto um handler que já declarava um segundo parâmetro opcional de outro tipo. Migração:
65
+ envolva o handler, como em `(rejections) => handle(rejections)`.
66
+
10
67
  ## 26.2.0 — 2026-10-09
11
68
 
12
69
  Portais podem organizar capítulos em subgrupos e abrir ferramentas relacionadas sem substituir
@@ -2,7 +2,7 @@
2
2
 
3
3
  - **Status:** aceita.
4
4
  - **Data:** 2026-09-15.
5
- - **Emenda:** 2026-10-07; portal documental delimitado pela ADR 0057.
5
+ - **Emendas:** 2026-10-07 e 2026-10-13; portal documental delimitado pela ADR 0057.
6
6
 
7
7
  ## Contexto
8
8
 
@@ -32,8 +32,9 @@ nesse caso, o cancelamento usa `outline`.
32
32
  ### Exceção: portal de documentação
33
33
 
34
34
  O portal documental tem uma jornada de leitura distinta da UI operacional das aplicações. Conforme
35
- a ADR 0057, `DocHeader`, a leitura editorial de `DocBrowser` e `DocCatalog`/`DocRecordDetail`
36
- podem adaptar cabeçalho, espaçamento e sumário à largura da tela. A exceção foi aprovada para
35
+ a ADR 0057, `DocHeader`, a leitura editorial e a navegação móvel opcional de `DocBrowser`,
36
+ além de `DocCatalog`/`DocRecordDetail`, podem adaptar cabeçalho, navegação, espaçamento e
37
+ sumário à largura da tela. A exceção foi aprovada para
37
38
  preservar a composição validada nos portais do Opus e da Grand Brasil.
38
39
 
39
40
  A exceção não torna responsivos `Split`, `Sidebar`, formulários, tabelas ou outros componentes da
@@ -41,9 +42,13 @@ UI das aplicações. O host documental continua responsável pela composição d
41
42
  estreita. Exemplos vivos usam o mesmo contrato desktop dos componentes demonstrados; o portal não
42
43
  promete que todos os exemplos sejam adequados a telas menores que `64rem`.
43
44
 
44
- O gate permite variantes de viewport somente nos três arquivos documentais identificados na ADR
45
- 0057. Arquivos novos, mesmo dentro de `docs/`, continuam sujeitos à regra até uma decisão explícita.
46
- Container queries não entram na exceção.
45
+ O gate permite variantes de viewport somente nas funções editoriais identificadas pela ADR 0057.
46
+ A consulta direta de largura via `matchMedia` tem uma exceção separada: somente os helpers
47
+ `subscribeCompactNavigation` e `compactNavigationSnapshot` de `DocBrowser.tsx` podem consultar
48
+ a constante `compactNavigationQuery`, fixada em `(max-width: 48rem)`. Isso não libera variantes
49
+ na função `DocBrowser`. Chamadas diretas com consultas dinâmicas não identificáveis são recusadas;
50
+ preferências explícitas continuam permitidas. Arquivos novos, mesmo dentro de `docs/`, seguem
51
+ a regra. Container queries não entram na exceção.
47
52
 
48
53
  ## Consequências
49
54
 
@@ -67,7 +72,8 @@ Container queries não entram na exceção.
67
72
  ## Verificação
68
73
 
69
74
  - Um teste de inventário reprova variantes de largura e container queries na UI viva.
70
- - O mesmo teste delimita a exceção documental por arquivo e comprova que ela não alcança componentes
71
- da aplicação, outros arquivos documentais ou container queries.
75
+ - O mesmo teste delimita a exceção documental por função e verifica chamadas diretas de `matchMedia`,
76
+ inclusive constantes e consultas dinâmicas. Mutações fora dos helpers, do arquivo ou do breakpoint
77
+ documentados reprovam; a exceção não alcança componentes da aplicação nem container queries.
72
78
  - Testes estruturais fixam layouts, larguras de modal e distribuição dos footers.
73
79
  - Documentação pública declara a largura mínima, o default por conteúdo e a exceção `equal`.
@@ -1,8 +1,37 @@
1
1
  # ADR 0057 — Portal documental compartilha navegação, busca e leitura
2
2
 
3
- - **Status:** aceita para implementação local; publicação pendente.
3
+ - **Status:** aceita; composição original em 25.7.0, subgrupos e abertura em nova aba em 26.2.0,
4
+ navegação recolhível e sequência de leitura em 27.1.0.
4
5
  - **Data:** 2026-10-07.
5
6
 
7
+ > **Emenda — 2026-10-13, versão 27.1.0:** a composição anterior reúne as entregas
8
+ > das versões 25.7.0 e 26.2.0. Esta extensão separa presença no mapa de participação na leitura:
9
+ > `DocSection.readingSequence: false` retira a seção dos links anterior/próxima, mas
10
+ > preserva busca, contexto e URLs. A omissão mantém a sequência existente. Excluir o
11
+ > arquivo histórico da árvore impediria sua descoberta; duplicar o mapa para calcular
12
+ > continuidade criaria duas ordens concorrentes.
13
+ >
14
+ > `DocBrowser.collapsibleNavigation` é opcional e começa desligado. Quando ligado, em
15
+ > telas de até `48rem`, oferece um botão de navegação e começa recolhido. Ao abrir,
16
+ > a árvore ocupa a área disponível no lugar da leitura, sem a faixa fixa de `14rem`.
17
+ > A seleção de um destino interno e a tecla Escape recolhem o menu; editar a busca revela
18
+ > seus resultados. A consulta preenchida na montagem não abre o menu sobre a leitura.
19
+ > A árvore permanece montada para preservar ramos abertos. Acima
20
+ > desse limite, a lateral continua visível. `Split` e `Sidebar` não mudam de contrato.
21
+ > O host preserva a instância também entre Documentação e UI: remontá-la descartaria
22
+ > o botão que recebeu o foco e o estado dos ramos. Mapas distintos usam `basePath`
23
+ > distintos; as chaves dos ramos incluem essa identidade antes da posição no mapa.
24
+ > Assim, abrir um capítulo em Documentação não abre outro na mesma posição em UI,
25
+ > e voltar restaura a abertura manual. A ordem interna de cada mapa permanece estável.
26
+ > Escape retorna
27
+ > o foco ao botão Navegação, inclusive quando acionado pela busca externa; a consulta
28
+ > permanece para permitir retomar os mesmos resultados.
29
+ > Esta composição substitui os ajustes CSS duplicados nos dois portais, estendendo
30
+ > a exceção documental à montagem do navegador, não aos exemplos ou demais telas.
31
+ > A alternativa de manter uma lateral curta preservaria a rolagem que esconde destinos
32
+ > globais. Testes de teclado, redimensionamento, busca, rotas e sequência de leitura,
33
+ > além de inspeção visual nos dois consumidores, verificam a extensão antes de publicar.
34
+
6
35
  ## Contexto
7
36
 
8
37
  Portais de documentação precisam apresentar guias, decisões e entregas sem trocar o mapa de
@@ -114,10 +143,14 @@ A composição documental é uma exceção explícita à ADR 0017: `DocHeader.ts
114
143
  (na leitura editorial) e `DocCatalog.tsx` (incluindo `DocRecordDetail`) podem usar variantes de
115
144
  viewport para organizar cabeçalho, sumário e espaçamento. A exceção fica restrita a esses arquivos;
116
145
  não libera toda a pasta `docs/` nem container queries. O gate verifica as funções de composição
117
- permitidas, sem liberar outras funções ou a montagem de `Split` no mesmo arquivo.
146
+ permitidas. A emenda de 13/10 admite a consulta de largura via `matchMedia` apenas nos helpers
147
+ `subscribeCompactNavigation` e `compactNavigationSnapshot`, em `DocBrowser.tsx`, usando a
148
+ constante `compactNavigationQuery` fixada em `(max-width: 48rem)`. Variantes de viewport
149
+ continuam proibidas na função `DocBrowser`; consultas diretas dinâmicas não identificáveis
150
+ também reprovam. Preferências explícitas de acessibilidade e tema permanecem permitidas.
118
151
 
119
152
  Essa adaptação foi aprovada para o portal de leitura, não para a UI das aplicações. O host organiza
120
- sua moldura em telas estreitas; `Split`, `Sidebar` e os exemplos vivos não ganham um contrato
153
+ sua moldura; o navegador pode oferecer sua navegação móvel opcional. `Split`, `Sidebar` e os exemplos vivos não ganham um contrato
121
154
  responsivo por estarem dentro da documentação. Manter o portal inteiramente desktop descartaria
122
155
  a composição já validada; ampliar a exceção para toda a UI alteraria uma decisão fora deste escopo.
123
156
 
@@ -159,8 +192,9 @@ e separa a consulta documental da consulta local.
159
192
  - Os dois portais compartilham cabeçalho e catálogos sem compartilhar sua taxonomia específica.
160
193
  - Guias, índices e fontes acessíveis por URL direta precisam ser filtrados antes de chegar ao
161
194
  navegador quando houver restrição de acesso. Esta composição não é uma fronteira de segurança.
162
- - A consolidação é um candidato local. A publicação e a adoção pela Grand Brasil são etapas
163
- distintas, posteriores à revisão independente.
195
+ - A composição original integra a versão 25.7.0; subgrupos e abertura em nova aba, a 26.2.0;
196
+ a navegação recolhível e a sequência de leitura, a 27.1.0. A adoção por cada portal é uma
197
+ etapa própria.
164
198
 
165
199
  ## Verificação
166
200
 
@@ -116,6 +116,18 @@ imagem. Assim, retenção, expiração, governança e dado pessoal ficam com que
116
116
  imagem, e `ChatMessage` continua só com texto. Se um consumidor precisar, uma nova decisão define
117
117
  como.
118
118
 
119
+ > **Emenda — 2026-10-13:** a ordem entre as duas chamadas da triagem passa a ser garantida. Em uma
120
+ > mesma escolha, colagem ou soltura com arquivos aceitos e recusados, `onAddFiles` roda antes de
121
+ > `onReject`, de forma síncrona e uma vez cada, também no soltar sobre a conversa do `Chat`.
122
+ > `onReject` recebe um segundo argumento, `context: ComposerRejectionContext`, cujo `accepted`
123
+ > traz os arquivos aceitos na mesma ação, ou uma lista vazia quando nada passou. Com ele, o app
124
+ > explica uma soltura mista sem guardar estado entre as duas chamadas; a adoção pela Grand Brasil
125
+ > mostrou a necessidade. O contexto é obrigatório na assinatura: opcional, ele obrigaria quem
126
+ > desestrutura `{ accepted }` a tratar `undefined`, embora o composer sempre o informe. Quem
127
+ > implementa o callback com um só parâmetro não muda nada; quem chama `onReject` diretamente precisa
128
+ > passar o contexto, e um handler com segundo parâmetro opcional de outro tipo precisa ser envolvido.
129
+ > Por essas migrações, a mudança saiu como major, na 27.0.0.
130
+
119
131
  ### 6. Preparação da imagem no navegador
120
132
 
121
133
  `prepareImage(file, options)` prepara a imagem antes do envio, com os tetos que o app passa:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softize/opus",
3
- "version": "26.2.0",
3
+ "version": "27.1.0",
4
4
  "description": "End-to-end action protocol for TypeScript. Single package with subpath exports (core + adapters).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -29,14 +29,25 @@ export interface ComposerFileRejection {
29
29
  reason: "type" | "limit";
30
30
  }
31
31
 
32
+ /** O que mais aconteceu na mesma ação (escolher, colar ou soltar) que gerou as recusas. */
33
+ export interface ComposerRejectionContext {
34
+ /** Arquivos aceitos na mesma ação, já entregues a `onAddFiles`; vazio quando nenhum passou. */
35
+ accepted: File[];
36
+ }
37
+
32
38
  /**
33
39
  * Anexos da mensagem, controlados pelo app (ADR 0058). O composer faz a triagem e mostra a faixa;
34
40
  * preparar, enviar, guardar e limpar depois do envio são do app, que é o dono do transporte.
41
+ *
42
+ * Em uma mesma ação com arquivos aceitos e recusados, `onAddFiles` é chamado antes de `onReject`,
43
+ * de forma síncrona e uma vez cada. Ação sem aceito não chama `onAddFiles`; sem recusado, não
44
+ * chama `onReject`.
35
45
  */
36
46
  export interface ComposerAttachments {
37
47
  /** Anexos pendentes, na ordem em que aparecem na faixa. */
38
48
  items: ComposerAttachment[];
39
- /** Recebe os arquivos aceitos pela triagem — do botão, do colar ou do soltar. */
49
+ /** Recebe os arquivos aceitos pela triagem — do botão, do colar ou do soltar. Na mesma ação,
50
+ * roda antes de `onReject`. */
40
51
  onAddFiles: (files: File[]) => void;
41
52
  /** A pessoa removeu um anexo da faixa. */
42
53
  onRemove: (id: string) => void;
@@ -46,8 +57,10 @@ export interface ComposerAttachments {
46
57
  * Não há curinga (`image/*`): a triagem compara o tipo exato do arquivo. */
47
58
  accept?: readonly AiImageMediaType[];
48
59
  /** Recebe os arquivos recusados. Presente, a explicação da recusa é do app; ausente, o
49
- * composer a mostra acima do campo. */
50
- onReject?: (rejections: ComposerFileRejection[]) => void;
60
+ * composer a mostra acima do campo. Roda depois de `onAddFiles` na mesma ação, e
61
+ * `context.accepted` traz os arquivos aceitos nela (vazio quando nenhum passou) — o app
62
+ * distingue "nada entrou" de "parte entrou" sem guardar estado entre as duas chamadas. */
63
+ onReject?: (rejections: ComposerFileRejection[], context: ComposerRejectionContext) => void;
51
64
  }
52
65
 
53
66
  /** Limite padrão de anexos por mensagem. */
@@ -296,7 +309,8 @@ export function Composer({
296
309
  const twoRow = actions !== undefined || attachments !== undefined;
297
310
 
298
311
  /** Triagem: tipo aceito e espaço na mensagem. Os aceitos vão ao app; os recusados, ao
299
- * `onReject` do app ou ao aviso do composer. */
312
+ * `onReject` do app ou ao aviso do composer. A ordem (aceitos antes, recusados depois, com os
313
+ * aceitos no contexto) é contrato público de `ComposerAttachments`. */
300
314
  const intake = (files: File[]): void => {
301
315
  if (attachments === undefined || files.length === 0) return;
302
316
  const remaining = Math.max(0, max - items.length);
@@ -309,7 +323,7 @@ export function Composer({
309
323
  }
310
324
  if (accepted.length > 0) attachments.onAddFiles(accepted);
311
325
  if (rejected.length > 0 && attachments.onReject !== undefined) {
312
- attachments.onReject(rejected);
326
+ attachments.onReject(rejected, { accepted });
313
327
  setNotice(null);
314
328
  } else {
315
329
  setNotice(rejected.length > 0 ? rejectionMessage(rejected, accept, max) : null);
@@ -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
  }
@@ -321,9 +321,11 @@ export const eventDetail = defineDesignScenario(eventGet, (input) => ({
321
321
 
322
322
  A resposta é tipada pela entrada e pela saída do contrato; em `list`, é a página inteira. Informe ao
323
323
  plugin onde os cenários estão, com caminhos ou globs:
324
- `opusDesign({ scenarios: 'design/scenarios/*.ts' })`. O cenário vence a resposta gerada. Um cenário
325
- de uma action que não existe, ou dois cenários para a mesma action, impedem o runtime de design de
326
- subir, e o erro lista os nomes.
324
+ `opusDesign({ scenarios: 'design/scenarios/**/*.ts' })`. Nos globs, `*` casa parte do nome dentro
325
+ de uma pasta e `**` casa qualquer número de pastas, inclusive nenhuma: o padrão do exemplo carrega
326
+ tanto `design/scenarios/pedidos.ts` quanto `design/scenarios/pedidos/lista.ts`. O cenário vence a
327
+ resposta gerada. Um cenário de uma action que não existe, ou dois cenários para a mesma action,
328
+ impedem o runtime de design de subir, e o erro lista os nomes.
327
329
 
328
330
  O Opus não decide onde os cenários ficam nem se entram no repositório. Eles não fazem parte da
329
331
  aplicação: podem ficar em uma pasta ignorada pelo git e ser escritos durante a sessão de design,
@@ -261,6 +261,11 @@ O arraste de arquivos sobre a conversa é consumido pelo Chat e não sobe para o
261
261
  aplicativo: um contêiner com o próprio soltar, como uma área de envio de arquivos, não recebe a mesma
262
262
  imagem duas vezes. Sem `composerAttachments`, o arraste segue para o contêiner, como antes.
263
263
 
264
+ O soltar sobre a conversa passa pela mesma triagem do composer. Quando ele traz imagens aceitas e
265
+ arquivos recusados juntos, `onAddFiles` recebe as imagens primeiro, e `onReject`, quando presente,
266
+ recebe os recusados logo depois, com as imagens da mesma ação em `accepted`. Veja
267
+ [recusas no Composer](/ui/composer#com-imagens-anexadas).
268
+
264
269
  ## Propriedades de Chat
265
270
 
266
271
  | Propriedade | Tipo | Padrão | Descrição |
@@ -143,6 +143,23 @@ curinga como `image/*`. O que fica de fora é explicado acima do campo, como “
143
143
  Para dar outra explicação, passe `onReject`: o composer deixa de mostrar o aviso e entrega os
144
144
  arquivos recusados, com o motivo, ao aplicativo.
145
145
 
146
+ Uma mesma escolha, colagem ou soltura pode trazer arquivos aceitos e recusados, como uma imagem e um
147
+ PDF arrastados juntos. O segundo argumento de `onReject`, do tipo `ComposerRejectionContext`, traz
148
+ em `accepted` os aceitos da mesma ação, e a lista vem vazia quando nada passou. Em uma ação mista,
149
+ `onAddFiles` roda antes de `onReject`. Assim o aplicativo explica a recusa conforme o resultado, sem
150
+ guardar estado entre as duas chamadas:
151
+
152
+ ```ts
153
+ const onReject: ComposerAttachments['onReject'] = (rejections, { accepted }) => {
154
+ const names = rejections.map((r) => r.file.name).join(', ')
155
+ notify(
156
+ accepted.length > 0
157
+ ? `Parte dos arquivos foi anexada. Ficaram de fora: ${names}.`
158
+ : `Nenhum arquivo foi anexado. Ficaram de fora: ${names}.`,
159
+ )
160
+ }
161
+ ```
162
+
146
163
  Colar texto continua colando texto, mesmo quando a área de transferência também traz uma imagem, como
147
164
  ao copiar células de uma planilha. Uma imagem colada sem texto, como um print, recebe um nome que a
148
165
  pessoa reconhece na conversa: “Imagem colada 1.png”. A numeração não volta atrás: remover a primeira e
@@ -197,11 +214,11 @@ mesmos que o servidor confere.
197
214
  | Propriedade | Tipo | Padrão | Descrição |
198
215
  |---|---|---|---|
199
216
  | `items` | `ComposerAttachment[]` | | Imagens anexadas à mensagem que está sendo escrita, na ordem da faixa. |
200
- | `onAddFiles` | `(files: File[]) => void` | | Recebe os arquivos aceitos pela triagem, vindos do botão, do colar ou do soltar. |
217
+ | `onAddFiles` | `(files: File[]) => void` | | Recebe os arquivos aceitos pela triagem, vindos do botão, do colar ou do soltar. Em uma ação com aceitos e recusados, é chamado antes de `onReject`. |
201
218
  | `onRemove` | `(id: string) => void` | | A pessoa removeu uma imagem da faixa; o foco volta ao campo. |
202
219
  | `max` | `number` | `4` | Limite de imagens por mensagem. O botão de anexar fica desabilitado quando ele é atingido. |
203
220
  | `accept` | `readonly AiImageMediaType[]` | PNG, JPEG e WebP | Formatos aceitos pelo botão e pela triagem, comparados com o tipo exato do arquivo. |
204
- | `onReject` | `(rejections: ComposerFileRejection[]) => void` | | Recebe os arquivos recusados, com o motivo `type` ou `limit`. Sem ele, o composer explica a recusa acima do campo. |
221
+ | `onReject` | `(rejections: ComposerFileRejection[], context: ComposerRejectionContext) => void` | | Recebe os arquivos recusados, com o motivo `type` ou `limit`, depois de `onAddFiles`; `context.accepted` (`File[]`) traz os arquivos aceitos na mesma ação e vem vazio quando nenhum passou. Sem ele, o composer explica a recusa acima do campo. |
205
222
 
206
223
  `ComposerAttachment` é `{ id, name, status, previewUrl?, error? }`. `status` vale `uploading`,
207
224
  `ready` ou `failed`; `previewUrl` é o endereço da miniatura e `error` é o motivo mostrado quando a
@@ -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.
@@ -25,4 +25,6 @@ export interface DocGroup {
25
25
  export interface DocSection {
26
26
  label: string
27
27
  groups: DocGroup[]
28
+ /** False exclui a seção da leitura anterior/próxima, sem retirá-la da navegação ou busca. */
29
+ readingSequence?: boolean
28
30
  }
package/src/ui/react.tsx CHANGED
@@ -73,6 +73,7 @@ export type {
73
73
  ComposerAttachmentStatus,
74
74
  ComposerAttachments,
75
75
  ComposerFileRejection,
76
+ ComposerRejectionContext,
76
77
  } from "./components/primitives/composer.tsx";
77
78
  // Preparação da imagem no navegador antes do envio (ADR 0058).
78
79
  export { prepareImage } from "./lib/prepare-image.ts";
@@ -82,7 +82,9 @@ export interface OpusDesignOptions {
82
82
  * CADA rebuild — domínio novo entra no ar ao salvar o arquivo, sem editar
83
83
  * entry nenhum. Dos módulos casados entram os exports (default e named) com
84
84
  * shape de registrável; schemas/helpers exportados junto ficam de fora.
85
- * Pode misturar estáticos e globs no array.
85
+ * Pode misturar estáticos e globs no array. No glob, `*` casa parte de um
86
+ * nome dentro de uma pasta e `**` casa zero ou mais pastas:
87
+ * 'design/scenarios/**\/*.ts' inclui o arquivo solto em `design/scenarios/`.
86
88
  */
87
89
  entry?: string | string[]
88
90
 
@@ -531,17 +533,33 @@ async function loadGlobEntry(server: ViteDevServer, pattern: string): Promise<un
531
533
  }
532
534
 
533
535
  /**
534
- * Matcher próprio de propósito (sem dep nova): `*` casa um segmento (sem '/'),
535
- * `**` casa qualquer profundidade. Cobre o entry por pasta de domínio; padrão
536
- * exótico não suportado = aponte entries estáticos.
536
+ * Matcher próprio de propósito (sem dep nova): `*` casa parte de um segmento
537
+ * (sem '/'); `**` como segmento inteiro é globstar e casa zero ou mais pastas
538
+ * — `design/**\/*.ts` casa `design/a.ts`, `design/x/a.ts` e `design/x/y/a.ts`.
539
+ * No fim do padrão (`design/**`), casa qualquer arquivo abaixo da pasta. Cobre o
540
+ * entry por pasta de domínio e os cenários; padrão exótico não suportado =
541
+ * aponte entries estáticos.
542
+ *
543
+ * Exportada só para teste: o índice de `@softize/opus/vite` não a reexporta.
537
544
  */
538
- function globToRegExp(pattern: string): RegExp {
539
- const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&')
540
- // `\u0000` como placeholder do `**`: nunca ocorre num path real.
541
- const body = escaped
542
- .replace(/\*\*/g, '\u0000')
543
- .replace(/\*/g, '[^/]*')
544
- .replace(/\u0000/g, '.*')
545
+ export function globToRegExp(pattern: string): RegExp {
546
+ const segments = pattern.split('/')
547
+ const body = segments
548
+ .map((segment, index) => {
549
+ const last = index === segments.length - 1
550
+ // Globstar no começo ou no meio: o grupo opcional leva a barra junto, então
551
+ // "nenhuma pasta" casa sem exigir `//` no path.
552
+ if (segment === '**') return last ? '.*' : '(?:.*/)?'
553
+ const escaped = segment.replace(/[.+?^${}()|[\]\\]/g, '\\$&')
554
+ // `**` colado a outro texto no segmento (`a**`) mantém o comportamento
555
+ // anterior: qualquer sequência. `\u0000` é placeholder: nunca ocorre num path.
556
+ const converted = escaped
557
+ .replace(/\*\*/g, '\u0000')
558
+ .replace(/\*/g, '[^/]*')
559
+ .replace(/\u0000/g, '.*')
560
+ return last ? converted : `${converted}/`
561
+ })
562
+ .join('')
545
563
  return new RegExp(`^${body}$`)
546
564
  }
547
565