@softize/opus 25.7.0 → 26.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.
Files changed (41) hide show
  1. package/CHANGELOG.md +95 -0
  2. package/bin/lib/check.mjs +9 -32
  3. package/bin/lib/copy.mjs +7 -4
  4. package/docs/adr/0014-structural-headers-do-not-carry-description.md +1 -1
  5. package/docs/adr/0018-modal-header-only-names-the-surface.md +1 -1
  6. package/docs/adr/0023-surfaces-share-one-anatomy-and-two-layouts.md +4 -0
  7. package/docs/adr/0058-mensagem-da-ia-aceita-imagem-e-o-chat-recebe-anexos.md +201 -0
  8. package/docs/adr/0059-subheadings-orient-without-carrying-business-rules.md +51 -0
  9. package/docs/adr/README.md +2 -0
  10. package/package.json +1 -1
  11. package/registry/skills/build-opus-ui/references/evaluations.md +8 -0
  12. package/registry/skills/build-opus-ui/references/ui-patterns.md +14 -4
  13. package/src/ai/drivers/anthropic.ts +49 -9
  14. package/src/ai/index.ts +5 -2
  15. package/src/core/ai-message.ts +20 -0
  16. package/src/core/index.ts +5 -0
  17. package/src/core/types.ts +28 -4
  18. package/src/ui/components/patterns/action-list-dialog.tsx +14 -7
  19. package/src/ui/components/patterns/form-dialog.tsx +18 -7
  20. package/src/ui/components/patterns/form.tsx +36 -8
  21. package/src/ui/components/patterns/page.tsx +22 -0
  22. package/src/ui/components/patterns/surface-anatomy.ts +1 -1
  23. package/src/ui/components/primitives/chat.tsx +179 -22
  24. package/src/ui/components/primitives/composer.tsx +397 -7
  25. package/src/ui/components/primitives/dialog.tsx +28 -1
  26. package/src/ui/components/primitives/drawer.tsx +30 -2
  27. package/src/ui/docs/content/action-form-card.md +1 -1
  28. package/src/ui/docs/content/action-form-dialog.md +8 -0
  29. package/src/ui/docs/content/action-form.md +18 -5
  30. package/src/ui/docs/content/action-list-dialog.md +8 -0
  31. package/src/ui/docs/content/ai.md +46 -0
  32. package/src/ui/docs/content/chat.md +97 -2
  33. package/src/ui/docs/content/composer.md +144 -1
  34. package/src/ui/docs/content/dialog.md +24 -2
  35. package/src/ui/docs/content/drawer.md +24 -2
  36. package/src/ui/docs/content/page.md +23 -1
  37. package/src/ui/docs/content/surface.md +8 -2
  38. package/src/ui/lib/modal-layers.ts +27 -3
  39. package/src/ui/lib/prepare-image.ts +386 -0
  40. package/src/ui/meta.ts +2 -2
  41. package/src/ui/react.tsx +21 -1
@@ -84,12 +84,21 @@ do mesmo peso visual. Nesse caso, o cancelamento muda para `outline`, salvo esco
84
84
  `cancelVariant`. Para apresentar o formulário como uma seção delimitada da página, use
85
85
  [`ActionFormCard`](/ui/action-form-card). As propriedades de cada wrapper estão na página dele.
86
86
 
87
- ## Ajuda no rótulo
87
+ ## Hint visível ou ícone de help
88
88
 
89
- `FieldSpec.help` aparece como um ícone junto ao rótulo, com o texto em um tooltip. Escreva ali o
90
- critério, o efeito ou a limitação que o rótulo não diz; ajuda que só repete o rótulo deve ser omitida.
91
- Não existe texto auxiliar sob o campo: quem precisa de uma frase permanente abaixo do controle usa
92
- `FieldDescription` na composição própria.
89
+ Use `FieldSpec.hint` abaixo do controle quando a pessoa precisar da informação para preencher
90
+ corretamente ou prever o efeito da escolha. Exemplo: “Sem áreas selecionadas, a pasta aparece em
91
+ todas as áreas.” O texto permanece visível e participa do `aria-describedby` do controle, junto
92
+ do erro quando houver. Em uma composição própria, use `FieldDescription` com a mesma associação.
93
+
94
+ Use `FieldSpec.help` no ícone junto à label para detalhes complementares, definições e exemplos
95
+ opcionais. A pergunta é: sem abrir essa ajuda, a pessoa ainda consegue escolher corretamente?
96
+ Se não, o trecho necessário deve ser hint. Não use o comprimento da frase como critério nem
97
+ esconda uma condição obrigatória no tooltip. Omita texto que apenas repete a label.
98
+
99
+ Hint e help podem coexistir se tiverem funções diferentes, sem duplicar a mesma frase. Um erro
100
+ explica uma entrada inválida e sua correção; não substitui uma orientação necessária antes de
101
+ preencher. Uma regra que afeta vários campos pertence ao corpo ou ao grupo correspondente.
93
102
 
94
103
  `LabelHelp` é esse mesmo ícone, exportado para um formulário sem contrato mostrar a ajuda no mesmo
95
104
  lugar:
@@ -185,3 +194,7 @@ ciclo de vida que o pattern não cobre; validação e execução continuam iguai
185
194
  | Propriedade | Tipo | Descrição |
186
195
  | ----------- | -------- | ------------------------------------------------------------ |
187
196
  | `help` | `string` | O texto do tooltip. Vazio ou ausente, o ícone não renderiza. |
197
+
198
+ O `hint` aceita texto ou `I18nRef` e permanece visível; o contexto do formulário entrega o texto
199
+ resolvido aos campos personalizados. O botão de `help` participa do Tab e abre por foco ou hover.
200
+ Dialog e Drawer evitam que a ajuda seja o primeiro foco automático quando há outro controle.
@@ -96,6 +96,13 @@ const roleOptions = roles.items.map((role) => ({ value: role.id, label: role.nam
96
96
  </ActionListDialog>;
97
97
  ```
98
98
 
99
+ ## Orientação e contexto
100
+
101
+ `description` apresenta a finalidade no cabeçalho, como “Escolha onde a pasta e suas subpastas
102
+ aparecem.”. É opcional; não repita o título. `intro` apresenta no corpo informações necessárias
103
+ à decisão que não pertencem a um campo específico. Regras, condições e consequências não ficam
104
+ somente no subheading. Quando ambos são fornecidos, ambos participam de `aria-describedby`.
105
+
99
106
  ## Propriedades de ActionListDialog
100
107
 
101
108
  | Propriedade | Tipo | Padrão | Descrição |
@@ -103,6 +110,7 @@ const roleOptions = roles.items.map((role) => ({ value: role.id, label: role.nam
103
110
  | `action / input` | `ListAction / TInput` | `input`: `{}` | O contrato e o escopo base da consulta. Uma mudança em `input` refaz a consulta; as invalidações declaradas por forms e triggers também. |
104
111
  | `open / onOpenChange` | `boolean / (open) => void` | | Estado controlado do modal. |
105
112
  | `title` | `string` | | Nome da lista exibido no cabeçalho fixo. |
113
+ | `description` | `ReactNode` | | Orientação opcional sob o título; não recebe regras de negócio. |
106
114
  | `intro` | `ReactNode` | | Contexto relevante no início do corpo, como texto ou `Alert`. |
107
115
  | `children` | `(items, refetch) => ReactNode` | | A composição dos itens. Carregamento, erro e vazio já são tratados pelo `ActionList`. |
108
116
  | `note` | `ReactNode` | | Contexto curto à esquerda da linha de ações. O total de itens já aparece no rodapé do `ActionList`. |
@@ -23,6 +23,7 @@ interface AiAdapter {
23
23
  runStream?(input: string | AiMessage[], opts: AiRunOptions): AsyncIterable<ChatEvent>
24
24
  }
25
25
  // opts: { system?, model?, maxTokens?, temperature? } — tudo tem default do driver.
26
+ // AiMessage: { role: 'user' | 'assistant', content: string | AiContentPart[] } (ver Mensagem com imagem).
26
27
  ```
27
28
 
28
29
  ## Driver
@@ -125,6 +126,51 @@ O driver Anthropic emite o texto em trechos, à medida que chega, quando o clien
125
126
  `messages.stream`. Com um client que só tem `create`, como um client falso de teste, o texto de cada
126
127
  passo sai num único trecho. O componente [Chat](/ui/chat) consome os dois modos.
127
128
 
129
+ ## Mensagem com imagem
130
+
131
+ Uma mensagem pode levar imagens junto com o texto, para o modelo ler um print, uma tabela ou um
132
+ gráfico. Nesse caso, `content` deixa de ser texto e passa a ser uma lista de partes, em uma forma que
133
+ não depende do provedor:
134
+
135
+ ```ts
136
+ const messages: AiMessage[] = [
137
+ {
138
+ role: 'user',
139
+ content: [
140
+ { type: 'text', text: 'Imagem 1: vendas.png' },
141
+ { type: 'image', mediaType: 'image/png', data: base64 }, // sem o prefixo data:
142
+ { type: 'text', text: 'Reproduza este relatório.' },
143
+ ],
144
+ },
145
+ ]
146
+ await runtime.aiFor(base)!.run(messages)
147
+ ```
148
+
149
+ Os formatos aceitos são PNG, JPEG e WebP, e a imagem só pode estar em uma mensagem `user`. O driver
150
+ Anthropic converte cada parte para o bloco da API, em `run` e em `runStream`; uma parte que ele não
151
+ conhece, uma imagem em outro formato ou uma imagem na fala do assistente fazem a chamada falhar com
152
+ erro antes de chegar ao provedor, em vez de a imagem sumir em silêncio. `complete` e `extract`
153
+ continuam recebendo texto.
154
+
155
+ A parte de imagem não tem nome. Para que a pessoa e o modelo possam falar da “imagem 2”, ponha uma
156
+ parte de texto com o rótulo antes de cada imagem, como no exemplo. Imagens vão antes da pergunta,
157
+ como a Anthropic recomenda.
158
+
159
+ Quando precisar só do texto de uma mensagem, por exemplo para o título da conversa ou para montar um
160
+ bloco de contexto, use `aiMessageText(message)`. Ele devolve o texto puro ou as partes de texto unidas
161
+ por uma linha em branco, sem as imagens. Interpolar `content` direto num template produz
162
+ “[object Object]” quando a mensagem tem partes, e o compilador não aponta esse uso.
163
+
164
+ ```ts
165
+ import { aiMessageText } from '@softize/opus/ai'
166
+
167
+ const title = aiMessageText(messages[0]).slice(0, 80) || 'Conversa com imagem'
168
+ ```
169
+
170
+ O pacote não guarda imagem. Enviar, guardar, expirar e mostrar de novo a imagem são do aplicativo; na
171
+ interface, o [Chat](/ui/chat#imagens-na-conversa) recebe as imagens prontas e mostra as que o
172
+ aplicativo devolve no transcript.
173
+
128
174
  ## Limites atuais
129
175
 
130
176
  `run` e `runStream` aceitam o histórico da conversa, mas não mantêm memória longa nem resumem o
@@ -168,17 +168,111 @@ com o rótulo já humanizado pelo aplicativo; ou `''`, que mostra somente os pon
168
168
  “Pensando…” como nome acessível. No modo autogerenciado, o Chat decide o indicador sozinho e ignora
169
169
  `busy` e `activity`.
170
170
 
171
+ ## Imagens na conversa
172
+
173
+ No modo controlado, a pessoa pode mandar imagens junto com o texto, como um print para a IA ler.
174
+ Passe `composerAttachments`, com a mesma forma do `attachments` do [Composer](/ui/composer#com-imagens-anexadas):
175
+ o composer ganha o botão **Anexar imagem** e a faixa de imagens, e soltar uma imagem em qualquer
176
+ ponto da conversa também anexa. `onSend` passa a receber, além do texto, as imagens prontas; a
177
+ mensagem pode ter só imagem, sem texto.
178
+
179
+ O aplicativo guarda as imagens e devolve cada uma no item `user` do transcript, em `attachments`, com
180
+ o endereço que a interface usa para mostrá-la. A fala da pessoa mostra as miniaturas acima do texto,
181
+ com o nome como texto alternativo. Quando a imagem não está mais disponível, o item traz
182
+ `status: 'expired'` e a conversa mostra um cartão com o nome e “Imagem expirada”.
183
+
184
+ ```tsx preview
185
+ const nextId = useRef(2)
186
+ const [items, setItems] = useState<ChatTranscriptItem[]>([
187
+ {
188
+ id: '1',
189
+ role: 'user',
190
+ content: 'Esse era o painel da semana passada.',
191
+ attachments: [{ id: 'a0', name: 'painel-antigo.png', mediaType: 'image/png', status: 'expired' }],
192
+ },
193
+ ])
194
+ const [pending, setPending] = useState<ComposerAttachment[]>([])
195
+ const sent = useRef<Record<string, { url: string; mediaType: string }>>({})
196
+
197
+ // Os endereços de objeto seguram as imagens na memória: revogue ao remover e ao desmontar.
198
+ function release(id: string) {
199
+ const image = sent.current[id]
200
+ if (image !== undefined) URL.revokeObjectURL(image.url)
201
+ delete sent.current[id]
202
+ }
203
+ useEffect(() => () => Object.keys(sent.current).forEach(release), [])
204
+
205
+ async function add(files: File[]) {
206
+ for (const file of files) {
207
+ const id = `a${nextId.current++}`
208
+ const prepared = await prepareImage(file)
209
+ if (!prepared.ok) {
210
+ setPending((list) => [...list, { id, name: file.name, status: 'failed', error: prepared.message }])
211
+ continue
212
+ }
213
+ // Num app real, a imagem vai ao servidor aqui, e o endereço devolvido é o da aplicação.
214
+ const url = URL.createObjectURL(prepared.file)
215
+ sent.current[id] = { url, mediaType: prepared.mediaType }
216
+ setPending((list) => [...list, { id, name: prepared.file.name, status: 'ready', previewUrl: url }])
217
+ }
218
+ }
219
+
220
+ render(
221
+ <div className="h-96 rounded-lg border">
222
+ <Chat
223
+ messages={items}
224
+ composerAttachments={{
225
+ items: pending,
226
+ onAddFiles: (files) => void add(files),
227
+ onRemove: (id) => {
228
+ release(id)
229
+ setPending((list) => list.filter((item) => item.id !== id))
230
+ },
231
+ }}
232
+ onSend={(text, attachments = []) => {
233
+ setItems((list) => [
234
+ ...list,
235
+ {
236
+ id: String(nextId.current++),
237
+ role: 'user',
238
+ content: text,
239
+ attachments: attachments.map((a) => ({
240
+ id: a.id,
241
+ name: a.name,
242
+ status: 'available',
243
+ ...sent.current[a.id],
244
+ })),
245
+ },
246
+ { id: String(nextId.current++), role: 'assistant', content: 'Recebi a mensagem.' },
247
+ ])
248
+ setPending([])
249
+ }}
250
+ />
251
+ </div>,
252
+ )
253
+ ```
254
+
255
+ As miniaturas não passam pelo Markdown: a fala da pessoa continua sendo o que ela escreveu. Sirva o
256
+ endereço pela própria aplicação ou como `data:`; o [Markdown](/ui/markdown) do pacote não carrega
257
+ imagem de outra origem, e as miniaturas seguem o mesmo cuidado. O modo autogerenciado não aceita
258
+ imagens: ele não tem como enviá-las, e `composerAttachments` é ignorado nele.
259
+
260
+ O arraste de arquivos sobre a conversa é consumido pelo Chat e não sobe para os contêineres do
261
+ aplicativo: um contêiner com o próprio soltar, como uma área de envio de arquivos, não recebe a mesma
262
+ imagem duas vezes. Sem `composerAttachments`, o arraste segue para o contêiner, como antes.
263
+
171
264
  ## Propriedades de Chat
172
265
 
173
266
  | Propriedade | Tipo | Padrão | Descrição |
174
267
  |---|---|---|---|
175
268
  | `send` | `(messages: ChatMessage[]) => Promise<string> \| AsyncIterable<ChatEvent>` | | Modo autogerenciado: recebe o histórico com a nova mensagem e devolve a resposta inteira ou um stream de eventos. Ignorado no modo controlado. |
176
269
  | `messages` | `ChatTranscriptItem[]` | | Modo controlado: o transcript vem do aplicativo. Com ele, `onSend`, `busy` e `activity` passam a valer. |
177
- | `onSend` | `(text: string) => void \| boolean \| Promise<void \| boolean>` | | Modo controlado: recebe o texto enviado; retornar `false` devolve o texto ao composer. |
270
+ | `onSend` | `(text: string, attachments?: ComposerAttachment[]) => void \| boolean \| Promise<void \| boolean>` | | Modo controlado: recebe o texto e, quando há `composerAttachments`, as imagens prontas. Retornar `false` devolve o texto ao composer; as imagens continuam na faixa. |
178
271
  | `busy` | `boolean` | `false` | Modo controlado: bloqueia o envio enquanto o turno corre; a digitação continua liberada. |
179
272
  | `activity` | `string \| null` | `undefined` | Modo controlado: indicador de atividade. `undefined` esconde, `null` mostra “Pensando…”, uma string mostra o rótulo e `''` mostra somente os pontos. |
180
273
  | `notice` | `ReactNode` | | Aviso do aplicativo acima do composer, como credencial expirada ou agente desatualizado. |
181
274
  | `composerActions` | `ReactNode` | | Seletores discretos na barra do composer, como agente, aplicativo ou escopo. |
275
+ | `composerAttachments` | `ComposerAttachments` | | Modo controlado: liga as imagens anexadas no composer e o soltar sobre a conversa. Ignorado no modo autogerenciado. |
182
276
  | `composerClassName` | `string` | | Ajusta o contêiner externo do composer sem alcançar o DOM interno. |
183
277
  | `greeting` | `string` | | Texto do estado vazio; some quando a conversa começa. |
184
278
  | `empty` | `ReactNode` | | Estado vazio composto pelo aplicativo; vence `greeting` quando os dois existem. |
@@ -195,6 +289,7 @@ com o rótulo já humanizado pelo aplicativo; ou `''`, que mostra somente os pon
195
289
  | Tipo | Forma | Uso |
196
290
  |---|---|---|
197
291
  | `ChatMessage` | `{ id?, createdAt?, role: 'user' \| 'assistant', content }` | Histórico enviado a `send` e aceito por `initialMessages`. `createdAt` é um instante ISO-8601. |
198
- | `ChatTranscriptItem` | `ChatMessage` com `role` também `'error'`, ou `{ id?, createdAt?, role: 'artifact', artifact: ChatArtifact }` | Itens de `messages` no modo controlado. Mensagens `error` aparecem destacadas no transcript. |
292
+ | `ChatTranscriptItem` | `ChatMessage` com `role` também `'error'` e `attachments?`, ou `{ id?, createdAt?, role: 'artifact', artifact: ChatArtifact }` | Itens de `messages` no modo controlado. Mensagens `error` aparecem destacadas no transcript; `attachments` só vale na fala `user`. |
293
+ | `ChatAttachment` | `{ id, name, mediaType, width?, height?, status: 'available' \| 'expired', url? }` | Imagem de uma fala da pessoa. `url` é o endereço fornecido pelo aplicativo; a imagem expirada não precisa dele. |
199
294
  | `ChatTranscriptMessage` | Os itens de `ChatTranscriptItem` que têm `content` | Argumento de `renderMessageActions`. |
200
295
  | `ChatArtifact` | `{ kind, ref, title? }` | Algo produzido na conversa, como um link ou um relatório; `ref` é o endereço ou identificador. |
@@ -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`.
@@ -150,8 +150,21 @@ Voltar e ações do cabeçalho usam `Button` `ghost` no tamanho `icon` (2.25rem)
150
150
  [`Presentation`](/ui/presentation) usa no voltar. O botão de fechar é menor: um `CloseButton` no
151
151
  quadrado `icon-sm` (1.75rem) por padrão.
152
152
 
153
- O cabeçalho não recebe descrição: um modal já é a superfície de uma tarefa, e o contexto que ajuda a
154
- decidir aparece no começo do corpo, como texto ou `Alert`.
153
+ O cabeçalho aceita `DialogDescription` como subheading opcional para orientar sobre a finalidade.
154
+ Omita quando repetir o título. Condições, regras de negócio e consequências necessárias à decisão
155
+ ficam junto dos controles ou em `DialogBody`. Uma frase curta também pode ser regra: o critério é se
156
+ pular o texto compromete a decisão, não sua extensão.
157
+
158
+ ```tsx
159
+ <DialogHeader>
160
+ <DialogTitle>Organizar pasta</DialogTitle>
161
+ <DialogDescription>Escolha onde a pasta e suas subpastas aparecem.</DialogDescription>
162
+ </DialogHeader>
163
+ ```
164
+
165
+ Na composição direta, associe o `id` do texto por `aria-describedby` em `DialogContent` quando ele
166
+ descrever a superfície para tecnologias assistivas. A regra sobre seleção vazia pertence ao
167
+ campo de áreas e permanece fora do subheading.
155
168
 
156
169
  ## Identificar a tarefa com um ícone
157
170
 
@@ -634,3 +647,12 @@ conteúdo no primeiro bloco de `DialogBody`. `DialogIntro` exige um `DialogTitle
634
647
 
635
648
  No formato compacto, mantenha `DialogTitle` e `DialogActions` no próprio `DialogHeader`. Para
636
649
  comparar as duas formas em cada `layout`, abra [Composições](/ui/compositions).
650
+
651
+ ## Propriedades de DialogDescription
652
+
653
+ | Propriedade | Tipo | Padrão | Descrição |
654
+ | --- | --- | --- | --- |
655
+ | `children` | `ReactNode` | | Orientação opcional sob o título em `DialogHeader` ou `DialogIntro`. |
656
+ | `id` | `string` | | Identificador para associação acessível explícita. |
657
+ | `className` | `string` | | Ajusta a apresentação do parágrafo. |
658
+ | `…props` | `ComponentProps<"p">` | | Atributos nativos. |
@@ -148,8 +148,21 @@ Voltar e ações do cabeçalho usam `Button` `ghost` no tamanho `icon` (2.25rem)
148
148
  [`Presentation`](/ui/presentation) usa no voltar. O botão de fechar é menor: um `CloseButton` no
149
149
  quadrado `icon-sm` (1.75rem) por padrão.
150
150
 
151
- O cabeçalho não recebe descrição: o painel já nomeia o recurso, e o contexto que ajuda a decidir
152
- aparece no começo do corpo, como texto ou `Alert`.
151
+ O cabeçalho aceita `DrawerDescription` como subheading opcional para orientar sobre a finalidade.
152
+ Omita quando repetir o título. Condições, regras de negócio e consequências necessárias à decisão
153
+ ficam junto dos controles ou em `DrawerBody`. Uma frase curta também pode ser regra: o critério é se
154
+ pular o texto compromete a decisão, não sua extensão.
155
+
156
+ ```tsx
157
+ <DrawerHeader>
158
+ <DrawerTitle>Organizar pasta</DrawerTitle>
159
+ <DrawerDescription>Escolha onde a pasta e suas subpastas aparecem.</DrawerDescription>
160
+ </DrawerHeader>
161
+ ```
162
+
163
+ Na composição direta, associe o `id` do texto por `aria-describedby` em `DrawerContent` quando ele
164
+ descrever a superfície para tecnologias assistivas. A regra sobre seleção vazia pertence ao
165
+ campo de áreas e permanece fora do subheading.
153
166
 
154
167
  ## Impedir o fechamento durante uma operação
155
168
 
@@ -301,3 +314,12 @@ Assim o título acompanha o conteúdo rolável enquanto o chrome permanece está
301
314
 
302
315
  No formato compacto, mantenha `DrawerTitle` e `DrawerActions` no próprio `DrawerHeader`. Para
303
316
  comparar as duas formas em cada `layout`, abra [Composições](/ui/compositions).
317
+
318
+ ## Propriedades de DrawerDescription
319
+
320
+ | Propriedade | Tipo | Padrão | Descrição |
321
+ | --- | --- | --- | --- |
322
+ | `children` | `ReactNode` | | Orientação opcional sob o título em `DrawerHeader` ou `DrawerIntro`. |
323
+ | `id` | `string` | | Identificador para associação acessível explícita. |
324
+ | `className` | `string` | | Ajusta a apresentação do parágrafo. |
325
+ | `…props` | `ComponentProps<"p">` | | Atributos nativos. |
@@ -1,7 +1,7 @@
1
1
  ## Esqueleto de página
2
2
 
3
3
  Use `Page` para manter cabeçalho, estado e conteúdo na mesma anatomia. `PageHeader` é a única
4
- introdução estrutural: reúne navegação contextual opcional, título e ações. O container é
4
+ introdução estrutural: reúne navegação contextual opcional, título, orientação opcional e ações. O container é
5
5
  centralizado e ocupa a largura disponível até `80rem` (`max-w-7xl`). Use `className` somente quando
6
6
  a composição pedir outro teto ou largura total.
7
7
 
@@ -41,6 +41,19 @@ render(
41
41
  );
42
42
  ```
43
43
 
44
+ ## Orientar sobre a finalidade
45
+
46
+ Use `description` na forma curta ou `PageDescription` em `PageHeader` quando uma frase breve
47
+ ajudar a reconhecer a finalidade da página. Omita texto que só repita o título. Regras de negócio,
48
+ limites e consequências ficam nos campos, ações ou corpo em que são necessários. Pular o
49
+ subheading não deve comprometer a decisão nem a conclusão correta da tarefa.
50
+
51
+ ```tsx
52
+ <Page title="Conhecimento" description="Consulte os materiais de apoio da sua área.">
53
+ <KnowledgeList />
54
+ </Page>
55
+ ```
56
+
44
57
  ## Retorno para a página pai
45
58
 
46
59
  Use `PageBack` em uma subpágina simples. O destino é explícito para continuar correto após refresh ou
@@ -311,6 +324,7 @@ estado na própria seção com [`DataState`](/ui/data-state), [`ActionView`](/ui
311
324
 
312
325
  | Propriedade | Tipo | Padrão | Descrição |
313
326
  | ----------- | -------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
327
+ | `description` | `ReactNode` | | Orientação opcional sob o título na forma curta. |
314
328
  | `title` | `ReactNode` | | O h1 da página. |
315
329
  | `actions` | `ReactNode` | | Ações contextuais no extremo oposto do cabeçalho. |
316
330
  | `layout` | `'flow' \| 'fill'` | `'flow'` | Ocupação de altura, não anatomia: `fill` limita a página ao shell para que uma região interna role e leva essa altura até uma `ActionList` composta em `PageBody`. |
@@ -414,3 +428,11 @@ ocupa a altura.
414
428
  | `retryLabel` | `string` | `'Tentar de novo'` | Texto do botão de recuperação. |
415
429
  | `children` | `ReactNode` | | Conteúdo renderizado somente em `ready`. |
416
430
  | `className` | `string` | | Classes da superfície de estado. |
431
+
432
+ ## Propriedades de PageDescription
433
+
434
+ | Propriedade | Tipo | Padrão | Descrição |
435
+ | --- | --- | --- | --- |
436
+ | `children` | `ReactNode` | | Orientação breve e opcional; filho direto de `PageHeader`. |
437
+ | `className` | `string` | | Ajusta a apresentação do parágrafo. |
438
+ | `…props` | `HTMLAttributes<HTMLParagraphElement>` | | Atributos nativos. |
@@ -131,12 +131,11 @@ render(
131
131
  as="section"
132
132
  framed
133
133
  title="Plano atual"
134
- description="Renova em 12 de maio."
135
134
  level={3}
136
135
  >
137
136
  <SurfaceBody>
138
137
  <p className="text-sm text-muted-foreground">
139
- Até 10 workspaces e 50 agentes.
138
+ Renova em 12 de maio. Até 10 workspaces e 50 agentes.
140
139
  </p>
141
140
  </SurfaceBody>
142
141
  <SurfaceFooter>
@@ -511,3 +510,10 @@ um wrapper filho declara relações locais mais compactas.
511
510
  | ----------- | ----------- | ------ | ---------------------------------------------------- |
512
511
  | `children` | `ReactNode` | | Ações persistentes, alinhadas no fim da linha. |
513
512
  | `className` | `string` | | Ajusta o rodapé, inclusive a distribuição das ações. |
513
+
514
+ ## Subheading e regras de negócio
515
+
516
+ `description` e `SurfaceDescription` apresentam brevemente a finalidade da região. A orientação
517
+ é opcional e não repete o título. A mesma regra vale para uma página (`level={1}`), uma seção ou
518
+ um card: condições, limites e consequências necessários à decisão ficam no corpo ou junto do
519
+ controle correspondente. Pular o subheading não deve comprometer a tarefa.
@@ -143,6 +143,21 @@ function syncHosts(): void {
143
143
  }
144
144
  }
145
145
 
146
+ /** A ajuda permanece no Tab, mas não toma o foco inicial do campo ao abrir um modal. */
147
+ export function skipHelpOnAutoFocus(event: Event): void {
148
+ if (event.defaultPrevented || !(event.target instanceof HTMLElement)) return;
149
+ const candidates = tabbables(event.target).filter(
150
+ (element) => element.tagName !== "A",
151
+ );
152
+ if (candidates[0]?.dataset.slot !== "label-help") return;
153
+ const control = candidates.find(
154
+ (element) => element.dataset.slot !== "label-help",
155
+ );
156
+ if (control === undefined) return;
157
+ control.focus();
158
+ if (document.activeElement === control) event.preventDefault();
159
+ }
160
+
146
161
  /** Elementos alcançáveis pelo Tab dentro de `root`, na ordem do documento. */
147
162
  function tabbables(root: HTMLElement): HTMLElement[] {
148
163
  const nodes: HTMLElement[] = [];
@@ -178,7 +193,10 @@ function tabbables(root: HTMLElement): HTMLElement[] {
178
193
  function focusFromKeyboard(element: HTMLElement): void {
179
194
  element.focus();
180
195
  // O Tab do navegador seleciona o texto do campo que recebe o foco; a passagem manual imita.
181
- if (element instanceof HTMLInputElement && typeof element.select === "function") {
196
+ if (
197
+ element instanceof HTMLInputElement &&
198
+ typeof element.select === "function"
199
+ ) {
182
200
  element.select();
183
201
  }
184
202
  }
@@ -342,7 +360,10 @@ export function registerFloatingLayer(
342
360
  // podem tratar o Tab e marcá-lo com `preventDefault`.
343
361
  const onKeyDown = (event: KeyboardEvent): void => {
344
362
  if (!isTabKey(event) || event.defaultPrevented) return;
345
- if (!layerExemptionsActive() || layerOf(document.activeElement) !== record) {
363
+ if (
364
+ !layerExemptionsActive() ||
365
+ layerOf(document.activeElement) !== record
366
+ ) {
346
367
  return;
347
368
  }
348
369
  const candidates = tabbables(host);
@@ -361,7 +382,10 @@ export function registerFloatingLayer(
361
382
  if (layerWherePersonIs() === record) escape()?.(event);
362
383
  };
363
384
  const observer = new MutationObserver(() => {
364
- if (pointerAnchor !== null && pointerAnchor.parentNode !== pointerLayer?.host) {
385
+ if (
386
+ pointerAnchor !== null &&
387
+ pointerAnchor.parentNode !== pointerLayer?.host
388
+ ) {
365
389
  // O painel apertado saiu da camada, como quando é fechado por código e vira o FAB.
366
390
  pointerAnchor = null;
367
391
  pointerLayer = null;