@softize/opus 25.6.0 → 26.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 (38) hide show
  1. package/CHANGELOG.md +108 -1
  2. package/docs/adr/0017-ui-assumes-a-fixed-desktop-layout.md +19 -0
  3. package/docs/adr/0057-doc-browser-permite-busca-no-chrome-do-consumidor.md +152 -0
  4. package/docs/adr/0058-mensagem-da-ia-aceita-imagem-e-o-chat-recebe-anexos.md +201 -0
  5. package/docs/adr/README.md +12 -0
  6. package/package.json +1 -1
  7. package/src/ai/drivers/anthropic.ts +49 -9
  8. package/src/ai/index.ts +5 -2
  9. package/src/core/ai-message.ts +20 -0
  10. package/src/core/index.ts +5 -0
  11. package/src/core/types.ts +25 -2
  12. package/src/ui/components/patterns/sidebar.tsx +32 -11
  13. package/src/ui/components/primitives/chat.tsx +179 -22
  14. package/src/ui/components/primitives/composer.tsx +397 -7
  15. package/src/ui/docs/DocBrowser.tsx +404 -69
  16. package/src/ui/docs/DocCatalog.tsx +255 -0
  17. package/src/ui/docs/DocHeader.tsx +74 -0
  18. package/src/ui/docs/browser.tsx +10 -1
  19. package/src/ui/docs/changelog.tsx +16 -41
  20. package/src/ui/docs/content/actions.md +3 -3
  21. package/src/ui/docs/content/ai.md +46 -0
  22. package/src/ui/docs/content/chat.md +97 -2
  23. package/src/ui/docs/content/composer.md +144 -1
  24. package/src/ui/docs/content/customization.md +5 -1
  25. package/src/ui/docs/content/documentation-portal.md +190 -0
  26. package/src/ui/docs/content/getting-started.md +129 -87
  27. package/src/ui/docs/content/installation.md +94 -0
  28. package/src/ui/docs/content/mcp.md +2 -2
  29. package/src/ui/docs/content/overview.md +58 -0
  30. package/src/ui/docs/content/sidebar.md +4 -0
  31. package/src/ui/docs/index.ts +8 -1
  32. package/src/ui/docs/markdown.tsx +5 -3
  33. package/src/ui/docs/records.ts +144 -0
  34. package/src/ui/docs/registry.tsx +149 -59
  35. package/src/ui/docs/types.ts +2 -0
  36. package/src/ui/lib/prepare-image.ts +386 -0
  37. package/src/ui/meta.ts +2 -2
  38. package/src/ui/react.tsx +18 -1
@@ -63,13 +63,125 @@ render(
63
63
  )
64
64
  ```
65
65
 
66
+ ## Com imagens anexadas
67
+
68
+ Quando a mensagem pode levar imagens, como um print de relatório para a IA reproduzir, passe
69
+ `attachments`. O composer ganha o botão **Anexar imagem** na barra e uma faixa acima do campo, com a
70
+ miniatura, o nome e o estado de cada imagem. Colar uma imagem no campo e soltá-la sobre o composer
71
+ também anexam.
72
+
73
+ O estado é do aplicativo, porque é ele quem envia e guarda a imagem. O composer faz a triagem e
74
+ entrega os arquivos aceitos em `onAddFiles`; o aplicativo acrescenta cada um à lista como
75
+ `uploading`, prepara a imagem com [`prepareImage`](#preparar-a-imagem-antes-do-envio), envia e muda o
76
+ estado para `ready` ou `failed`. Depois do envio da mensagem, é o aplicativo que limpa a lista e
77
+ revoga os endereços das miniaturas que criou.
78
+
79
+ ```tsx preview col
80
+ const [text, setText] = React.useState('')
81
+ const [items, setItems] = React.useState<ComposerAttachment[]>([])
82
+ const nextId = React.useRef(1)
83
+ const previews = React.useRef<Record<string, string>>({})
84
+
85
+ // A miniatura é um endereço de objeto, que segura a imagem na memória até ser revogado.
86
+ function release(id: string) {
87
+ const url = previews.current[id]
88
+ if (url !== undefined) URL.revokeObjectURL(url)
89
+ delete previews.current[id]
90
+ }
91
+ React.useEffect(() => () => Object.keys(previews.current).forEach(release), [])
92
+
93
+ async function add(files: File[]) {
94
+ for (const file of files) {
95
+ const id = String(nextId.current++)
96
+ const prepared = await prepareImage(file)
97
+ if (!prepared.ok) {
98
+ setItems((list) => [...list, { id, name: file.name, status: 'failed', error: prepared.message }])
99
+ continue
100
+ }
101
+ previews.current[id] = URL.createObjectURL(prepared.file)
102
+ setItems((list) => [
103
+ ...list,
104
+ { id, name: prepared.file.name, status: 'uploading', previewUrl: previews.current[id] },
105
+ ])
106
+ // Aqui o aplicativo envia `prepared.file` ao próprio servidor.
107
+ await new Promise((r) => setTimeout(r, 800))
108
+ setItems((list) => list.map((item) => (item.id === id ? { ...item, status: 'ready' } : item)))
109
+ }
110
+ }
111
+
112
+ render(
113
+ <Composer
114
+ value={text}
115
+ onChange={setText}
116
+ onSubmit={() => {
117
+ items.forEach((item) => release(item.id))
118
+ setText('')
119
+ setItems([])
120
+ }}
121
+ placeholder="Cole, solte ou anexe um print…"
122
+ attachments={{
123
+ items,
124
+ onAddFiles: (files) => void add(files),
125
+ onRemove: (id) => {
126
+ release(id)
127
+ setItems((list) => list.filter((item) => item.id !== id))
128
+ },
129
+ }}
130
+ />,
131
+ )
132
+ ```
133
+
134
+ O envio passa com texto ou com pelo menos uma imagem pronta. Enquanto alguma imagem está sendo
135
+ enviada, o botão de enviar espera; uma imagem com falha também bloqueia o envio até ser removida ou
136
+ reenviada pelo aplicativo, para que nada do que está na faixa fique de fora sem aviso. Quem usa leitor
137
+ de tela ouve quando uma imagem fica pronta ou falha, e, se o botão de anexar ficar desabilitado ao
138
+ atingir o limite, o foco volta ao campo.
139
+
140
+ A triagem aceita PNG, JPEG e WebP, até quatro imagens por mensagem; `accept` e `max` mudam esses
141
+ limites. `accept` é um subconjunto desses três formatos, comparado com o tipo exato do arquivo, sem
142
+ curinga como `image/*`. O que fica de fora é explicado acima do campo, como “Cada mensagem aceita até 4 imagens.”.
143
+ Para dar outra explicação, passe `onReject`: o composer deixa de mostrar o aviso e entrega os
144
+ arquivos recusados, com o motivo, ao aplicativo.
145
+
146
+ Colar texto continua colando texto, mesmo quando a área de transferência também traz uma imagem, como
147
+ ao copiar células de uma planilha. Uma imagem colada sem texto, como um print, recebe um nome que a
148
+ pessoa reconhece na conversa: “Imagem colada 1.png”. A numeração não volta atrás: remover a primeira e
149
+ colar outra dá “Imagem colada 3.png”, e nunca duas imagens com o mesmo nome no mesmo composer.
150
+
151
+ O soltar que o composer recebe não sobe para os contêineres do aplicativo. Dentro do
152
+ [Chat](/ui/chat#imagens-na-conversa), quem recebe o soltar é a conversa inteira.
153
+
154
+ ## Preparar a imagem antes do envio
155
+
156
+ `prepareImage(file, options)` deixa a imagem pronta para ir à conversa. Ela lê só o cabeçalho do
157
+ arquivo para conferir o formato, pelos bytes e não pela extensão, e as dimensões; uma foto acima de
158
+ `maxPixels` é recusada antes de ser aberta, porque decodificá-la ocuparia centenas de megabytes de
159
+ memória. Depois, reduz a imagem até o lado maior caber em `maxEdge`, mantendo a proporção e a
160
+ orientação gravada pela câmera, de modo que a foto tirada com o celular em pé chega em pé; e codifica
161
+ a imagem de novo, o que também descarta metadados como a localização. PNG continua PNG quando cabe em `maxBytes`; JPEG, WebP e o PNG que passa do teto viram
162
+ JPEG com qualidade 0,85, sobre fundo branco.
163
+
164
+ ```ts
165
+ const result = await prepareImage(file, { maxEdge: 1568, maxBytes: 3 * 1024 * 1024 })
166
+ if (result.ok) {
167
+ await upload(result.file) // result.mediaType, result.width, result.height, result.size
168
+ } else {
169
+ showError(result.message) // "Imagens animadas não são aceitas. Envie uma imagem parada…"
170
+ }
171
+ ```
172
+
173
+ A função não lança erro. Uma imagem fora dos formatos aceitos, animada, ilegível, com pixels demais ou
174
+ que continua acima do teto depois de reduzida volta como `{ ok: false, reason, message }`, com a explicação pronta para a
175
+ pessoa. Os padrões repetem os tetos do primeiro consumidor; passe os do seu produto, de preferência os
176
+ mesmos que o servidor confere.
177
+
66
178
  ## Propriedades de Composer
67
179
 
68
180
  | Propriedade | Tipo | Padrão | Descrição |
69
181
  |---|---|---|---|
70
182
  | `value` | `string` | | Texto controlado; o dono do estado é quem compõe. |
71
183
  | `onChange` | `(value: string) => void` | | Recebe cada alteração do texto. |
72
- | `onSubmit` | `() => void` | | Chamado por Enter (sem Shift) ou pelo botão de enviar, somente quando há texto e nem `busy` nem `submitDisabled` estão ativos. |
184
+ | `onSubmit` | `() => void` | | Chamado por Enter (sem Shift) ou pelo botão de enviar, somente quando há texto ou imagem pronta, nenhuma imagem está sendo enviada ou com falha, e nem `busy` nem `submitDisabled` estão ativos. |
73
185
  | `onHistoryPrevious` / `onHistoryNext` | `() => boolean` | | Navegação por ↑/↓ no histórico do dono; retorne `true` quando a tecla foi consumida. |
74
186
  | `busy` | `boolean` | `false` | Bloqueia o envio enquanto a operação anterior corre e troca o ícone de enviar por um spinner. O campo continua editável. |
75
187
  | `submitDisabled` | `boolean` | `false` | Bloqueia o envio por uma condição do consumidor, como um seletor ainda sem escolha. |
@@ -77,4 +189,35 @@ render(
77
189
  | `rows` | `number` | `1` | Linhas iniciais do campo, que cresce com o conteúdo até `10rem`. |
78
190
  | `autoFocus` | `boolean` | | Foca o campo ao montar. |
79
191
  | `actions` | `ReactNode` | | Controles discretos à esquerda do botão de enviar; com eles, o composer passa a ter duas linhas. |
192
+ | `attachments` | `ComposerAttachments` | | Liga as imagens anexadas: botão **Anexar imagem**, faixa acima do campo, colar e soltar. Com ele, o composer passa a ter duas linhas. |
80
193
  | `className` | `string` | | Classes aplicadas à moldura do composer. |
194
+
195
+ ## Propriedades de ComposerAttachments
196
+
197
+ | Propriedade | Tipo | Padrão | Descrição |
198
+ |---|---|---|---|
199
+ | `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. |
201
+ | `onRemove` | `(id: string) => void` | | A pessoa removeu uma imagem da faixa; o foco volta ao campo. |
202
+ | `max` | `number` | `4` | Limite de imagens por mensagem. O botão de anexar fica desabilitado quando ele é atingido. |
203
+ | `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. |
205
+
206
+ `ComposerAttachment` é `{ id, name, status, previewUrl?, error? }`. `status` vale `uploading`,
207
+ `ready` ou `failed`; `previewUrl` é o endereço da miniatura e `error` é o motivo mostrado quando a
208
+ imagem falha.
209
+
210
+ ## Opções de prepareImage
211
+
212
+ | Opção | Tipo | Padrão | Descrição |
213
+ |---|---|---|---|
214
+ | `maxEdge` | `number` | `1568` | Lado maior, em pixels, depois da redução. Imagens menores não são ampliadas. |
215
+ | `maxBytes` | `number` | 3 MB | Teto da imagem preparada. Acima dele, o PNG vira `lossyType`; o que continua acima é recusado. |
216
+ | `accept` | `readonly AiImageMediaType[]` | PNG, JPEG e WebP | Formatos aceitos na entrada, conferidos pelos primeiros bytes. |
217
+ | `lossyType` | `'image/jpeg' \| 'image/webp'` | `'image/jpeg'` | Formato de JPEG, WebP e do PNG que passa do teto. |
218
+ | `quality` | `number` | `0.85` | Qualidade da codificação com perdas, de 0 a 1. |
219
+ | `maxPixels` | `number` | `40_000_000` | Teto de largura × altura da imagem original, lido do cabeçalho antes de decodificar. |
220
+
221
+ O resultado é `{ ok: true, file, mediaType, width, height, size, resized }` ou
222
+ `{ ok: false, reason, message }`, em que `reason` vale `unsupported-type`, `animated`,
223
+ `too-many-pixels`, `too-large` ou `unreadable`.
@@ -168,7 +168,7 @@ componente da feature em vez de copiá-lo.
168
168
 
169
169
  ## Largura mínima e densidade
170
170
 
171
- O Opus oferece suporte a partir de `64rem` e não muda a composição conforme a largura da viewport.
171
+ A UI das aplicações oferece suporte a partir de `64rem` e não muda a composição conforme a largura da viewport.
172
172
  Props de tamanho, como `size="sm"`, escolhem a densidade do componente; não representam
173
173
  breakpoints. Overflow e limites contra a janela continuam protegendo o conteúdo.
174
174
 
@@ -176,6 +176,10 @@ Não adicione em `className` uma adaptação isolada por viewport ou container.
176
176
  futura precisa começar pelo shell e formar um contrato comum para navegação, splits, tabelas e
177
177
  componentes.
178
178
 
179
+ O [portal de documentação](/docs/documentation-portal) tem uma exceção própria para adaptar
180
+ cabeçalho, leitura editorial e catálogos em telas estreitas. Essa exceção não altera o contrato
181
+ dos componentes demonstrados nos exemplos vivos nem autoriza adaptações isoladas na aplicação.
182
+
179
183
  ## O que fica fora das alavancas
180
184
 
181
185
  Alguns detalhes fazem parte da identidade visual do Opus e são iguais em todo projeto: a faixa do
@@ -0,0 +1,190 @@
1
+ # Publicação de documentação
2
+
3
+ Quando uma aplicação precisa publicar seus próprios Markdown, use a superfície leve
4
+ `@softize/opus/ui/docs/browser`. Ela oferece cabeçalho, navegação e leitura sem incluir o catálogo
5
+ do Opus nem o compilador de exemplos vivos.
6
+
7
+ ## Componha o portal
8
+
9
+ O aplicativo escolhe a marca, as seções, as rotas e as ações. `DocHeader` alinha os controles no
10
+ topo; `DocBrowser` pesquisa as páginas e organiza a leitura. O valor da busca é compartilhado,
11
+ mas o campo aparece somente no cabeçalho:
12
+
13
+ ```tsx
14
+ import { useState } from 'react'
15
+ import { DocBrowser, DocHeader, type DocSection } from '@softize/opus/ui/docs/browser'
16
+ import { Input } from '@softize/opus/ui/react'
17
+
18
+ export function Documentation({ sections }: { sections: DocSection[] }) {
19
+ const [query, setQuery] = useState('')
20
+ return (
21
+ <div className="flex h-dvh flex-col">
22
+ <DocHeader
23
+ brand={<a href="/docs">Meu produto</a>}
24
+ search={<Input type="search" aria-label="Buscar na documentação"
25
+ value={query} onChange={(event) => setQuery(event.target.value)} />}
26
+ />
27
+ <main className="min-h-0 flex-1">
28
+ <DocBrowser sections={sections} sidebarSize="17rem" editorial sidebarInitiallyCollapsed
29
+ search={{ placement: 'external', value: query }} />
30
+ </main>
31
+ </div>
32
+ )
33
+ }
34
+ ```
35
+
36
+ Marca, busca e ações usam o mesmo alinhamento vertical. O campo tem até `22rem` e fica centralizado
37
+ no cabeçalho, com colunas de mesma largura nas extremidades. A navegação superior é opcional; quando
38
+ for necessária, ocupa uma linha própria. Em telas menores que `64rem`, a busca ganha sua própria linha. Não é necessário
39
+ copiar esse grid no aplicativo. Links e controles mantêm seu comportamento nativo: o aplicativo
40
+ continua responsável por navegar, trocar tema ou abrir o repositório.
41
+
42
+ ## Declare um percurso de leitura
43
+
44
+ As `sections` organizam grupos e páginas na ordem declarada. Use essa ordem para aproximar
45
+ conceitos relacionados, não para reproduzir automaticamente a árvore de arquivos. Cada página
46
+ tem `slug`, `title` e `render`; `searchText` acrescenta conteúdo à busca e `navigationTitle` permite
47
+ um rótulo lateral curto sem mudar o título completo.
48
+
49
+ Com `editorial`, a leitura recebe contexto, sumário dos títulos renderizados e links de página
50
+ anterior e seguinte. `additionalPages` preserva destinos diretos que não entram nessa sequência.
51
+ A busca documental do topo mantém seu rótulo, valor e função ao navegar para catálogos e detalhes;
52
+ continua pesquisando os guias na lateral. Consultas locais ficam no conteúdo da própria página e
53
+ não substituem nem alteram esse campo.
54
+
55
+ O portal organiza a descoberta; não é uma fronteira de autorização. Só envie ao navegador fontes
56
+ que a pessoa possa consultar, inclusive aquelas disponíveis por URL direta.
57
+
58
+ A adaptação do cabeçalho, da leitura editorial e dos catálogos à largura da tela é uma exceção
59
+ restrita ao portal documental. A UI das aplicações e os componentes dos exemplos vivos preservam
60
+ o contrato desktop a partir de `64rem`. O aplicativo organiza a moldura do portal em telas estreitas;
61
+ `DocBrowser` não muda sozinho a orientação de `Split` nem transforma os exemplos em layouts móveis.
62
+
63
+ ## Consulte decisões e entregas
64
+
65
+ ADRs e changelog mantêm o mapa dos guias, sem acrescentar uma árvore para cada registro. Use `sidebarLinks` para oferecer entradas
66
+ no mesmo nível dos grupos, após os capítulos, sem incluí-las na sequência anterior/próxima.
67
+ `DocCatalog` recebe registros derivados dos Markdown e uma consulta local independente do cabeçalho.
68
+ Passe o catálogo em `content` para preservar a lateral:
69
+
70
+ ```tsx
71
+ import { useEffect, useState } from 'react'
72
+ import { DocBrowser, DocHeader, DocCatalog, DocRecordDetail, releaseRecords, type DocSection } from '@softize/opus/ui/docs/browser'
73
+ import { Input, Markdown, usePathname } from '@softize/opus/ui/react'
74
+
75
+ export function DocumentationPortal({ source, sections }: {
76
+ source: string; sections: DocSection[]
77
+ }) {
78
+ const path = usePathname()
79
+ const [documentationQuery, setDocumentationQuery] = useState('')
80
+ const [catalogQuery, setCatalogQuery] = useState('')
81
+ const isChangelog = path === '/changelog' || path.startsWith('/changelog/')
82
+ const records = releaseRecords(source, '/changelog')
83
+ const record = records.find((entry) => entry.href === path)
84
+ useEffect(() => {
85
+ if (!isChangelog) setCatalogQuery('')
86
+ }, [isChangelog])
87
+ return <div className="flex h-dvh flex-col">
88
+ <DocHeader brand={<a href="/docs">Meu produto</a>}
89
+ search={<Input type="search" aria-label="Buscar na documentação"
90
+ value={documentationQuery} onChange={(event) => setDocumentationQuery(event.target.value)} />} />
91
+ <main className="min-h-0 flex-1">
92
+ <DocBrowser sections={sections} sidebarInitiallyCollapsed
93
+ search={{ placement: 'external', value: documentationQuery }}
94
+ sidebarLinks={[{ label: 'Changelog', href: '/changelog', active: isChangelog }]}
95
+ content={isChangelog ? (
96
+ record ? <DocRecordDetail catalogLabel="Changelog"><Markdown content={record.body} /></DocRecordDetail>
97
+ : path === '/changelog' ? <DocCatalog title="Changelog" description="Mudanças por versão."
98
+ kind="releases" records={records} query={catalogQuery} onQueryChange={setCatalogQuery} />
99
+ : <DocRecordDetail catalogLabel="Changelog"><Markdown content="# Registro não encontrado" /></DocRecordDetail>
100
+ ) : undefined} />
101
+ </main>
102
+ </div>
103
+ }
104
+ ```
105
+
106
+ O catálogo oferece busca textual sobre o conteúdo completo e apresenta os registros mais recentes
107
+ primeiro, sem tabela, filtros, controles de ordenação ou paginação. Decisões mostram título, data,
108
+ estado declarado e o primeiro parágrafo do contexto. Changelog retoma a leitura das versões com
109
+ texto integral e cuidados de atualização em destaque.
110
+
111
+ Não faz chamadas de rede: o aplicativo fornece os arquivos descobertos no build. Esse
112
+ índice estático não substitui `ActionList` em coleções de recursos servidas por uma action.
113
+ A consulta local pesquisa somente os registros; a busca do cabeçalho continua alimentando a árvore.
114
+ O host controla os dois valores separadamente. Preserve a consulta local ao abrir um registro e
115
+ voltar, e limpe-a ao trocar de catálogo; não limpe a consulta documental pela navegação.
116
+
117
+ `decisionRecord(source, href)` extrai número, título, data e status declarados no cabeçalho de uma
118
+ ADR. A lista apresenta o estado declarado; ressalvas e andamento continuam no texto integral.
119
+ Ausência de data ou status aparece como não informada; títulos que não identificam uma ADR
120
+ não geram registros. `releaseRecords(source, basePath)` preserva a convenção `## versão — data` do
121
+ changelog e suas instruções de incompatibilidade. Histórico dentro de uma versão não vira uma
122
+ release inventada.
123
+
124
+ `content`, `breaking` e `afterBreaking` preservam a sequência da versão no resumo: texto inicial,
125
+ cuidados de atualização e seções seguintes. O detalhe usa `body`, com o Markdown integral.
126
+
127
+ O aplicativo resolve a URL do registro e passa seu conteúdo para `DocRecordDetail`, também no
128
+ slot `content`. O registro mostra `catalogLabel` antes do título, sem breadcrumb. A navegação fica
129
+ na lateral; `active` identifica o catálogo aberto. Preserve a mesma instância de `DocBrowser`
130
+ entre guias, catálogo e registro para manter os ramos abertos pela pessoa.
131
+
132
+ Quando recebe `content`, o navegador não escolhe uma página dos guias, não aplica seus redirects
133
+ e não atualiza o título da aba. O aplicativo resolve a rota e o título desse conteúdo. O catálogo
134
+ não define as rotas nem a autorização do aplicativo.
135
+
136
+ ## Propriedades de DocHeader
137
+
138
+ | Propriedade | Tipo | Padrão | Uso |
139
+ | --- | --- | --- | --- |
140
+ | `brand` | `ReactNode` | Obrigatória | Marca ou link de entrada. |
141
+ | `navigation` | `ReactNode` | — | Navegação opcional; sem ela, não há segunda linha de abas. |
142
+ | `search` | `ReactNode` | — | Campo de busca controlado pelo aplicativo. |
143
+ | `actions` | `ReactNode` | — | Controles como tema e repositório. |
144
+ | `brandWidth` | `string` | `17rem` | Coluna da marca, normalmente igual à lateral. |
145
+ | `className`, `style` e atributos de `header` | Atributos HTML | — | Identificação e ajustes locais sem substituir a composição. |
146
+
147
+ ## Propriedades de DocBrowser
148
+
149
+ | Propriedade | Tipo | Padrão | Uso |
150
+ | --- | --- | --- | --- |
151
+ | `sections` | `DocSection[]` | Obrigatória na entrada leve | Seções, grupos e páginas na ordem de leitura. |
152
+ | `basePath` | `string` | `/docs` | Prefixo das rotas. |
153
+ | `path` | `string` | Rota compartilhada | Caminho controlado por um host externo. |
154
+ | `redirects` | `Record<string, string>` | `{}` | Slugs anteriores e seus destinos canônicos. |
155
+ | `documentTitle` | `(title: string) => string` | — | Título da aba a partir da página ativa. |
156
+ | `search` | `boolean` ou `DocBrowserSearchOptions` | `false` | Busca lateral ou valor externo; consulta título, rótulo, badge e `searchText`. |
157
+ | `sidebarSize` | `PaneSize` | `14rem` | Largura inicial da navegação. |
158
+ | `sidebar` | `boolean` | `true` | Exibe a árvore; `false` mantém uma leitura simples sem lateral. |
159
+ | `editorial` | `boolean` | `false` | Contexto, sumário e sequência, quando a lateral está presente. |
160
+ | `navigationLabel` | `string` | `Documentação` | Nome da área no contexto editorial; por exemplo, `UI` para o catálogo de componentes. |
161
+ | `additionalPages` | `DocEntry[]` | `[]` | Destinos diretos fora da árvore e da sequência. |
162
+ | `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. |
163
+ | `sidebarLinks` | `{ label: string; href: string; active?: boolean }[]` | `[]` | Destinos globais fora dos capítulos; `active` marca o catálogo atual. |
164
+ | `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. |
165
+
166
+ A entrada histórica `@softize/opus/ui/docs` inclui o catálogo e oferece suas páginas quando
167
+ `sections` é omitida. Para conteúdo próprio, prefira a entrada leve do exemplo.
168
+
169
+ `DocBrowserSearchOptions` é exportado pelas duas entradas documentais. Com `placement: 'sidebar'`
170
+ (ou sem `placement`), o navegador mantém a consulta local. Com `placement: 'external'`, `value`
171
+ é obrigatório e o host fornece o campo. Nos dois modos, `label`, `placeholder` e `emptyMessage`
172
+ são opcionais; os dois primeiros só afetam o campo lateral.
173
+
174
+ ## Propriedades de DocCatalog
175
+
176
+ | Propriedade | Tipo | Padrão | Uso |
177
+ | --- | --- | --- | --- |
178
+ | `title` | `string` | Obrigatória | Título do catálogo. |
179
+ | `description` | `string` | Obrigatória | Contexto para a consulta. |
180
+ | `records` | `readonly DocumentationRecord[]` | Obrigatória | Índice estático com identidade, título, URL, corpo e texto pesquisável. Data, status, `summary`, `content`, `breaking` e `afterBreaking` são opcionais: contexto da ADR e partes do resumo da versão. |
181
+ | `query` | `string` | Obrigatória | Valor do campo local, independente da busca documental do topo. |
182
+ | `onQueryChange` | `(query: string) => void` | Obrigatória | Atualiza a consulta controlada pelo host quando a pessoa escreve no campo local. |
183
+ | `kind` | `'decisions' \| 'releases'` | Obrigatória | Lista de decisões com contexto ou leitura contínua das versões. |
184
+
185
+ ## Propriedades de DocRecordDetail
186
+
187
+ | Propriedade | Tipo | Padrão | Uso |
188
+ | --- | --- | --- | --- |
189
+ | `catalogLabel` | `string` | Obrigatória | Nome do catálogo antes do título, sem breadcrumb. |
190
+ | `children` | `ReactNode` | Obrigatória | Conteúdo completo do registro. |
@@ -4,123 +4,165 @@ title: Primeiros passos
4
4
 
5
5
  # Primeiros passos
6
6
 
7
- O Opus ajuda cliente e servidor a preservar as mesmas regras à medida que uma aplicação
8
- evolui. Entidades e contratos de action ficam em uma camada compartilhada; o runtime, a UI e
9
- as ferramentas usam essas definições sem exigir schemas e tipos paralelos.
7
+ Vamos calcular o preço de uma assinatura a partir do tamanho da equipe. Ao terminar, você terá
8
+ uma operação com entrada validada, um handler no servidor e um teste que confirma a regra.
10
9
 
11
- ## O modelo
10
+ Neste exemplo, cada pessoa custa R$ 29 por mês. A consulta é pública, não grava dados e não
11
+ precisa de banco. Isso permite acompanhar o caminho completo de uma action antes de configurar
12
+ um servidor HTTP ou uma interface.
12
13
 
13
- As regras de uma aplicação costumam aparecer em vários lugares: validação, transporte, API,
14
- formulários e documentação. No Opus, entidades, dicionários e contratos de action descrevem essas
15
- regras uma vez. A API e a web consomem a mesma definição, enquanto runtime, adapters, UI, CLI
16
- e verificações ajudam a mantê-la consistente. Cada projeto acrescenta seu domínio sobre essa
17
- base versionada.
14
+ ## Prepare o projeto
18
15
 
19
- ## Começar do zero
20
-
21
- Para um projeto novo, use `opus create`: ele gera o app com os pré-requisitos do protocolo, da UI e
22
- do preview já configurados, e os gates passam desde o início.
23
-
24
- O Opus e a Base ficam no npm público: a instalação não exige token nem configuração de escopo.
25
- Crie o projeto e materialize as duas camadas antes dos gates:
16
+ Se você já trabalha em um projeto Opus, siga para o contrato. Para criar um projeto novo, use um
17
+ ambiente com Node.js e pnpm instalados:
26
18
 
27
19
  ```bash
28
20
  pnpm dlx @softize/opus create meu-app
29
- cd meu-app && git init && pnpm install && pnpm run setup && pnpm test
21
+ cd meu-app
22
+ git init
23
+ pnpm install
24
+ pnpm run setup
25
+ pnpm test
30
26
  ```
31
27
 
32
- O comando cria a estrutura inicial e instala a versão estável disponível. Em uma máquina nova,
33
- se o `dlx` informar que o diretório global de binários não está no `PATH`, execute `pnpm setup`
34
- e abra outra sessão do terminal. Se o setup encontrar uma seção antiga do pnpm no arquivo de
35
- inicialização do shell (`ERR_PNPM_BAD_SHELL_SECTION`), `pnpm setup --force` substitui somente
36
- esse bloco.
28
+ O projeto inclui TypeScript, Vite, React, um domínio de tarefas e os comandos de teste e
29
+ verificação. `setup` materializa as instruções e os artefatos do Opus e da Base usados pelo
30
+ projeto. Para adotar em um aplicativo existente, criar um monorepo ou resolver uma falha de
31
+ instalação, consulte [Instalação](/docs/installation).
32
+
33
+ ## Declare o contrato
34
+
35
+ O contrato diz o que a operação recebe, o que devolve e quem pode chamá-la. Ele fica em um arquivo
36
+ que cliente e servidor conseguem importar, sem banco, segredo ou driver de servidor.
37
37
 
38
- Durante as primeiras 24 horas de uma release, a quarentena do pnpm pode resolver silenciosamente
39
- uma versão anterior. Se o CLI responder com “Comando desconhecido”, informe a versão explicitamente
40
- com `pnpm dlx @softize/opus@<versão> create meu-app` ou desative a quarentena apenas nessa chamada
41
- com `pnpm --config.minimum-release-age=0 dlx …`. Dentro do projeto, o workspace exclui Opus e Base
42
- da quarentena para preservar o par validado.
38
+ Crie `src/domains/subscription/actions/quote.contract.ts`:
43
39
 
44
- O app criado inclui:
40
+ ```ts
41
+ import { defineContract } from '@softize/opus/core'
42
+ import { z } from 'zod'
43
+
44
+ export const subscriptionQuoteContract = defineContract({
45
+ name: 'subscription.quote',
46
+ kind: 'simple',
47
+ summary: 'Calcula a mensalidade de uma equipe',
48
+ description: 'A assinatura custa R$ 29 por pessoa por mês, com pelo menos uma pessoa.',
49
+ input: z.object({ seats: z.number().int().positive() }),
50
+ output: z.object({ monthlyTotalInCents: z.number().int().nonnegative() }),
51
+ effect: 'read',
52
+ personalData: false,
53
+ public: true,
54
+ })
55
+ ```
45
56
 
46
- - `opus.config.ts` e um domínio de exemplo documentado, sem violações no `opus check`;
47
- - Vite, React e o tema do Opus sobre Tailwind v4, configurado por CSS;
48
- - um dev server que respeita a variável `PORT`, para que um orquestrador de preview escolha a
49
- porta;
50
- - `opus.json`, com a versão adotada, e as instruções, skills e hooks gerenciados.
57
+ `seats` aceita somente um inteiro positivo. O valor devolvido usa centavos: três pessoas custam
58
+ `8700`, ou R$ 87. `public: true` permite consultar o preço sem login; quando esse campo é omitido,
59
+ a action exige uma pessoa autenticada.
51
60
 
52
- A Base cuida do método geral de trabalho, da memória e da revisão; o Opus acrescenta o conhecimento
53
- específico do SDK.
61
+ O nome `subscription.quote` identifica a operação. O `kind: 'simple'` a define como uma chamada
62
+ sem formulário, coleção ou página de detalhe. Os outros tipos estão em
63
+ [Actions e contratos](/docs/actions).
54
64
 
55
- Em um monorepo, crie primeiro a raiz com `--monorepo` e depois cada app dentro dela. O `create`
56
- detecta o `pnpm-workspace.yaml` e gera só os arquivos do app, sem um workspace aninhado. O que a raiz
57
- precisa ter, como o glob de packages, `allowBuilds` e as exclusões de quarentena para Opus e Base,
58
- aparece como aviso, sem alterar os arquivos da raiz:
65
+ ## Ligue a regra ao contrato
59
66
 
60
- ```bash
61
- pnpm dlx @softize/opus create meu-cliente --monorepo # a raiz do workspace
62
- cd meu-cliente && git init
63
- pnpm dlx @softize/opus create apps/portal # o app (modo detectado)
67
+ O handler recebe a entrada validada e calcula a resposta. `bindAction` liga esse código ao
68
+ contrato sem colocar a implementação no módulo que o cliente importa.
69
+
70
+ Crie `src/domains/subscription/actions/quote.ts`:
71
+
72
+ ```ts
73
+ import { bindAction } from '@softize/opus/core'
74
+ import { subscriptionQuoteContract } from './quote.contract.ts'
75
+
76
+ export const subscriptionQuote = bindAction(subscriptionQuoteContract, {
77
+ handler: async (_ctx, input) => ({
78
+ monthlyTotalInCents: input.seats * 2900,
79
+ }),
80
+ })
64
81
  ```
65
82
 
66
- ## Carregar o Opus em um projeto existente
83
+ O contrato permanece compartilhável. O binding fica do lado do servidor, onde também poderão
84
+ entrar banco, integrações e outras dependências da regra.
67
85
 
68
- O `opus.json` registra a versão do Opus adotada pelo app; se ele ainda não existe, o setup o cria.
69
- `opus setup` roda em cada app e grava o marcador, o inventário de copy e as projeções específicas do
70
- SDK. `base setup` materializa na raiz do repositório o método geral, a memória, os agentes e a
71
- revisão. Os componentes vêm de `@softize/opus/ui/react`, e o tema, de um arquivo CSS.
86
+ ## Confira o resultado e o limite da entrada
72
87
 
73
- ```bash
74
- # Bootstrap idempotente de um projeto existente.
75
- pnpm add @softize/opus
76
- pnpm add -D @softize/base
77
- pnpm exec opus setup
78
- pnpm exec base setup
88
+ `runAction` executa a action pela fronteira do contrato. Assim, o teste verifica tanto a conta
89
+ quanto a validação que ocorre antes do handler.
90
+
91
+ Crie `src/domains/subscription/actions/quote.test.ts`:
92
+
93
+ ```ts
94
+ import { runAction } from '@softize/opus/testing'
95
+ import { expect, it } from 'vitest'
96
+ import { subscriptionQuote } from './quote.ts'
97
+
98
+ it('calcula a assinatura de três pessoas', async () => {
99
+ const { output } = await runAction(subscriptionQuote, { seats: 3 })
100
+ expect(output).toEqual({ monthlyTotalInCents: 8700 })
101
+ })
102
+
103
+ it('recusa uma equipe sem pessoas', async () => {
104
+ await expect(runAction(subscriptionQuote, { seats: 0 })).rejects.toMatchObject({
105
+ code: 'validation.invalid_input',
106
+ })
107
+ })
79
108
  ```
80
109
 
81
- ```css
82
- /* index.css do app — o tema do Opus e os componentes do pacote no scan do Tailwind. */
83
- @import '@softize/opus/ui/theme.css';
84
- @source '../node_modules/@softize/opus/src/ui/**/*.{ts,tsx}';
110
+ Execute `pnpm test`. Os dois testes devem passar. Não foi necessário criar um servidor, simular
111
+ login ou configurar banco: o teste usa a operação ligada ao seu handler.
112
+
113
+ ## Inclua a action no domínio
114
+
115
+ Um domínio reúne as operações e os dados de um assunto do produto. Registre a nova action em
116
+ `src/domains/subscription/index.ts`:
117
+
118
+ ```ts
119
+ import { defineDomain } from '@softize/opus/core'
120
+ import { subscriptionQuote } from './actions/quote.ts'
121
+
122
+ export const subscriptionDomain = defineDomain({
123
+ name: 'subscription',
124
+ description: 'Assinaturas de equipes e cálculo de mensalidade.',
125
+ actions: { subscriptionQuote },
126
+ })
85
127
  ```
86
128
 
87
- ## Montar o runtime
129
+ No `opus.config.ts`, importe `subscriptionDomain` e acrescente-o ao array `domains` que já existe.
130
+ Essa lista informa à CLI quais domínios entram no manifest gerado pelo projeto.
88
131
 
89
- No servidor, `createRuntime` reúne as capacidades que o app usa. Cada capacidade, como banco,
90
- autenticação ou auditoria, é um adapter que pode ser trocado sem alterar o domínio. Os providers da
91
- interface estão em [Como consumir](/ui/como-consumir).
132
+ Para executar a action no runtime, registre o mesmo domínio. Este trecho pode rodar em um
133
+ arquivo do servidor ou em um teste de integração:
92
134
 
93
135
  ```ts
94
136
  import { createRuntime } from '@softize/opus/core'
95
- import { jwtAuth } from '@softize/opus/auth/jwt'
96
- import { kyselyData } from '@softize/opus/data/kysely'
97
- import { fastifyServer } from '@softize/opus/server/fastify'
98
- import { pgAudit } from '@softize/opus/audit/pg'
99
- import { workspaceDomain } from './domains/workspace/index.ts'
100
-
101
- const runtime = createRuntime({
102
- server: fastifyServer({ app }),
103
- data: kyselyData({ db }), // Kysely e Postgres
104
- auth: jwtAuth({ secret: process.env.JWT_SECRET!, mapUser: (p) => ({ id: String(p.sub) }) }),
105
- audit: [pgAudit({ pool })], // trilha de auditoria
106
- config: { env, i18n: { defaultLocale: 'pt-BR' } },
137
+ import { subscriptionDomain } from './domains/subscription/index.ts'
138
+
139
+ const runtime = createRuntime()
140
+ runtime.register([subscriptionDomain])
141
+
142
+ const result = await runtime.execute('subscription.quote', { seats: 3 }, {
143
+ user: null,
144
+ tenantId: null,
145
+ can: () => false,
107
146
  })
108
147
 
109
- // Só o que foi registrado existe para o runtime.
110
- runtime.register([workspaceDomain])
111
- await runtime.start()
148
+ if (result.ok) {
149
+ console.log(result.data) // { monthlyTotalInCents: 8700 }
150
+ }
112
151
  ```
113
152
 
114
- Cada capacidade e o registro de domínios estão detalhados em [Runtime & adapters](/docs/runtime).
153
+ O runtime devolve um resultado com `ok` para distinguir sucesso de falha. A consulta continua
154
+ pública mesmo sem usuário. Uma action que não foi registrada não pode ser executada.
155
+
156
+ ## Continue a partir do que você precisa construir
115
157
 
116
- ## Antes de alterar o projeto
158
+ Você já declarou, executou e testou uma action. O próximo passo depende de onde a operação será
159
+ usada:
117
160
 
118
- A documentação do próprio repositório descreve o que é específico dele; esta página cobre só o que
119
- vale para todo projeto Opus.
161
+ - Para publicar a operação por HTTP e acrescentar banco ou autenticação, siga para
162
+ [Runtime e adapters](/docs/runtime).
163
+ - Para criar um formulário, uma coleção ou um detalhe, veja os tipos em
164
+ [Actions e contratos](/docs/actions) e os [componentes de UI](/ui/como-consumir).
165
+ - Para cobrir permissões e efeitos da operação, avance para [Testar actions](/docs/testing).
120
166
 
121
- - O `CLAUDE.md` do repositório e de cada app reúne o mapa do código, os gates e os cuidados locais.
122
- - A `description` de entidades e actions registra a regra de negócio, que `opus gen` projeta no
123
- manifest.
124
- - Antes de entregar, rode `opus check` (convenções das actions e da UI), `opus copy --check` e
125
- `base copy check` (inventário e política de copy) e, com banco, `opus db check` (divergência entre
126
- entidades e banco).
167
+ Antes de entregar uma mudança, execute `pnpm exec opus check`, `pnpm exec opus copy --check`
168
+ e os gates do seu repositório. Consulte [CLI](/docs/cli) para entender o que cada comando verifica.