@softize/opus 26.2.0 → 27.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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,45 @@ 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.0.0 — 2026-10-13
11
+
12
+ O aplicativo passa a saber, na recusa de um anexo, o que entrou na mesma ação, e o glob do runtime
13
+ de design passa a seguir a semântica usual de `**`. A seção **Breaking** traz as migrações, que
14
+ atingem só quem chama `onReject` diretamente ou passa um handler com um segundo parâmetro opcional de
15
+ outro tipo.
16
+
17
+ - **Recusa com os aceitos da mesma ação.** `ComposerAttachments.onReject` recebe um segundo
18
+ argumento, `ComposerRejectionContext`, com `accepted`: os arquivos aceitos na mesma escolha,
19
+ colagem ou soltura, ou uma lista vazia quando nada passou. Assim o aplicativo explica uma soltura
20
+ mista, como uma imagem e um PDF arrastados juntos, sem guardar estado entre as duas chamadas. O
21
+ tipo `ComposerRejectionContext` sai de `@softize/opus/ui/react`.
22
+ - **Ordem das chamadas documentada.** Em uma ação com arquivos aceitos e recusados, `onAddFiles`
23
+ roda antes de `onReject`, de forma síncrona e uma vez cada. O mesmo vale para o soltar sobre a
24
+ conversa do `Chat`. A ordem já era essa; agora faz parte do contrato.
25
+ - **`**` casa zero ou mais pastas no glob de `opusDesign`.** Em `entry` e `scenarios`, `**` como
26
+ pasta inteira segue a semântica usual. Antes, `design/scenarios/**/*.ts` exigia pelo menos uma
27
+ subpasta e ignorava, sem aviso, o cenário salvo direto em `design/scenarios/`. Um projeto com
28
+ `**/` no padrão pode passar a carregar arquivos que antes ficavam de fora; se dois cenários da
29
+ mesma action passarem a entrar, o runtime de design deixa de subir e o erro lista a action.
30
+ - **`?` literal no glob.** Antes, o `?` virava quantificador de regex, e `design/*/a?.ts` casava
31
+ `design/x/.ts`. Os demais padrões sem `**` continuam casando os mesmos arquivos.
32
+
33
+ ### Breaking
34
+
35
+ **O contexto de `onReject` é obrigatório na assinatura.** Quem implementa o callback com um só
36
+ parâmetro não muda nada. Duas situações deixam de compilar:
37
+
38
+ - quem chama `onReject` diretamente, como ao reaproveitar o tipo `ComposerAttachments` em uma área de
39
+ soltar própria. Migração: passe `{ accepted }`, com uma lista vazia quando nada foi aceito:
40
+
41
+ ```ts
42
+ // antes: attachments.onReject?.(rejected)
43
+ attachments.onReject?.(rejected, { accepted })
44
+ ```
45
+
46
+ - quem passa direto um handler que já declarava um segundo parâmetro opcional de outro tipo. Migração:
47
+ envolva o handler, como em `(rejections) => handle(rejections)`.
48
+
10
49
  ## 26.2.0 — 2026-10-09
11
50
 
12
51
  Portais podem organizar capítulos em subgrupos e abrir ferramentas relacionadas sem substituir
@@ -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.0.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);
@@ -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
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