@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
@@ -0,0 +1,20 @@
1
+ import type { AiMessage } from './types.ts'
2
+
3
+ /** Separa as partes de texto de uma mensagem na leitura: cada parte continua um parágrafo. */
4
+ const TEXT_PART_SEPARATOR = '\n\n'
5
+
6
+ /**
7
+ * O texto de uma mensagem da IA (ADR 0058): o próprio `content` quando ele é texto, ou as partes
8
+ * de texto unidas por uma linha em branco. As imagens não entram.
9
+ *
10
+ * Use onde só o texto importa — título de conversa, contagem de caracteres, bloco de contexto
11
+ * montado por interpolação. Interpolar `content` direto produz "[object Object]" quando a
12
+ * mensagem tem partes, e o compilador não aponta esse uso.
13
+ */
14
+ export function aiMessageText(message: Pick<AiMessage, 'content'>): string {
15
+ const { content } = message
16
+ if (typeof content === 'string') return content
17
+ return content
18
+ .flatMap((part) => (part.type === 'text' ? [part.text] : []))
19
+ .join(TEXT_PART_SEPARATOR)
20
+ }
package/src/core/index.ts CHANGED
@@ -113,6 +113,8 @@ export type {
113
113
  AiCompleteOptions,
114
114
  AiTool,
115
115
  AiMessage,
116
+ AiContentPart,
117
+ AiImageMediaType,
116
118
  AiRunOptions,
117
119
  AiRunResult,
118
120
  BoundAi,
@@ -243,6 +245,9 @@ export type {
243
245
  } from './runtime.ts'
244
246
  export { countReturnedRecords, hasInvalidEffect, resolveActionEffect } from './action-nature.ts'
245
247
 
248
+ // — Mensagem da IA (ADR 0058) ———————————————————————————————————————————————————
249
+ export { aiMessageText } from './ai-message.ts'
250
+
246
251
  // — Assistente do recurso (ADR 0053) ————————————————————————————————————————————
247
252
  export {
248
253
  ASSISTANT_CONTEXT_DEFAULT_CHARS,
package/src/core/types.ts CHANGED
@@ -1383,10 +1383,33 @@ export interface AiTool {
1383
1383
  }
1384
1384
  }
1385
1385
 
1386
- /** Turno de conversa (histórico multi-turn do chat). */
1386
+ /** Formatos de imagem que a mensagem aceita: os três que os provedores conhecidos leem (ADR 0058). */
1387
+ export type AiImageMediaType = 'image/png' | 'image/jpeg' | 'image/webp'
1388
+
1389
+ /**
1390
+ * Parte de conteúdo de uma mensagem, sem a forma de nenhum provedor (ADR 0058). Cada driver
1391
+ * converte as partes para o formato da sua API e recusa com erro o que não conhece.
1392
+ *
1393
+ * A imagem não leva nome: o rótulo que a pessoa e o modelo usam ("Imagem 1: print.png") é uma
1394
+ * parte de texto que o consumidor monta antes dela.
1395
+ */
1396
+ export type AiContentPart =
1397
+ | { type: 'text'; text: string }
1398
+ | {
1399
+ type: 'image'
1400
+ mediaType: AiImageMediaType
1401
+ /** Bytes da imagem em base64, sem o prefixo `data:`. */
1402
+ data: string
1403
+ }
1404
+
1405
+ /**
1406
+ * Turno de conversa (histórico multi-turn do chat). `content` é texto ou uma lista de partes
1407
+ * com texto e imagem (ADR 0058). Para ler só o texto, use `aiMessageText(message)`: interpolar
1408
+ * `content` direto num template produz "[object Object]" quando a mensagem tem partes.
1409
+ */
1387
1410
  export interface AiMessage {
1388
1411
  role: 'user' | 'assistant'
1389
- content: string
1412
+ content: string | AiContentPart[]
1390
1413
  }
1391
1414
 
1392
1415
  /** Uma opção de resposta de uma pergunta de esclarecimento (`ask_user`). */
@@ -77,7 +77,9 @@ export interface SidebarItemProps {
77
77
  /** Complemento do tooltip no rail recolhido. */
78
78
  tooltipHint?: ReactNode;
79
79
  className?: string;
80
- onClick: () => void;
80
+ /** Destino nativo. Com onClick, intercepta somente a navegação simples; preserva novas abas. */
81
+ href?: string;
82
+ onClick?: () => void;
81
83
  }
82
84
 
83
85
  /** Linha única de navegação: o alvo principal e seus controles são irmãos, nunca botões aninhados. */
@@ -95,8 +97,29 @@ export function SidebarItem({
95
97
  tooltipHint,
96
98
  className,
97
99
  onClick,
100
+ href,
98
101
  }: SidebarItemProps): React.ReactElement {
99
102
  const collapsed = useSidebarCollapsed();
103
+ const Target = href !== undefined && !disabled ? "a" : "button";
104
+ const targetProps =
105
+ Target === "a" ? { href } : { type: "button" as const, disabled };
106
+ const activate = (
107
+ event: React.MouseEvent<HTMLAnchorElement | HTMLButtonElement>,
108
+ ) => {
109
+ if (href !== undefined) {
110
+ if (
111
+ event.button !== 0 ||
112
+ event.metaKey ||
113
+ event.ctrlKey ||
114
+ event.shiftKey ||
115
+ event.altKey ||
116
+ !onClick
117
+ )
118
+ return;
119
+ event.preventDefault();
120
+ }
121
+ onClick?.();
122
+ };
100
123
  const hasActions = actions !== undefined && actions !== null;
101
124
  // O rótulo cortado esmaece em vez de terminar em reticências; a medição do transbordo
102
125
  // mantém a máscara fora do rótulo que cabe inteiro na linha.
@@ -104,12 +127,11 @@ export function SidebarItem({
104
127
  useOverflowing<HTMLSpanElement>(label);
105
128
  if (collapsed) {
106
129
  const button = (
107
- <button
108
- type="button"
109
- disabled={disabled}
130
+ <Target
131
+ {...targetProps}
110
132
  aria-label={label}
111
133
  aria-current={active ? "page" : undefined}
112
- onClick={onClick}
134
+ onClick={activate}
113
135
  className={cn(
114
136
  "mx-auto grid size-10 place-items-center rounded-md transition-colors disabled:pointer-events-none disabled:opacity-40",
115
137
  "outline-none",
@@ -125,7 +147,7 @@ export function SidebarItem({
125
147
  >
126
148
  {icon}
127
149
  </span>
128
- </button>
150
+ </Target>
129
151
  );
130
152
  // `className` vai no MESMO elemento nos dois modos — a linha —, senão a prop mudaria de alvo
131
153
  // conforme o rail estivesse recolhido, e o consumidor não teria como saber o que estilizou.
@@ -209,11 +231,10 @@ export function SidebarItem({
209
231
  )}
210
232
  </span>
211
233
  )}
212
- <button
213
- type="button"
214
- disabled={disabled}
234
+ <Target
235
+ {...targetProps}
215
236
  aria-current={active ? "page" : undefined}
216
- onClick={onClick}
237
+ onClick={activate}
217
238
  className={cn(
218
239
  "flex min-w-0 flex-1 items-center gap-2.5 rounded-md px-2.5 py-1.5 text-left text-sm disabled:pointer-events-none",
219
240
  "outline-none",
@@ -254,7 +275,7 @@ export function SidebarItem({
254
275
  {badge}
255
276
  </span>
256
277
  )}
257
- </button>
278
+ </Target>
258
279
  {hasActions && (
259
280
  <div
260
281
  data-slot="sidebar-item-actions"
@@ -1,7 +1,14 @@
1
1
  import * as React from 'react'
2
+ import { ImageOffIcon } from 'lucide-react'
2
3
  import type { ChatEvent } from '../../../core/index.ts'
3
4
  import { cn } from '../../lib/cn.ts'
4
- import { Composer } from './composer.tsx'
5
+ import {
6
+ Composer,
7
+ ComposerDropBridge,
8
+ dragHasFiles,
9
+ type ComposerAttachment,
10
+ type ComposerAttachments,
11
+ } from './composer.tsx'
5
12
  import { Markdown } from './markdown.tsx'
6
13
 
7
14
  export interface ChatMessage {
@@ -20,10 +27,35 @@ export interface ChatArtifact {
20
27
  title?: string
21
28
  }
22
29
 
30
+ /** Estado de um anexo já enviado: com a imagem disponível ou expirada pela retenção do app. */
31
+ export type ChatAttachmentStatus = 'available' | 'expired'
32
+
33
+ /** Imagem anexada a uma fala da pessoa (ADR 0058). A guarda é do app, que fornece o endereço. */
34
+ export interface ChatAttachment {
35
+ id: string
36
+ /** Nome do arquivo; é o texto alternativo da miniatura e o título do cartão de expirada. */
37
+ name: string
38
+ /** Tipo da imagem (`image/png`, `image/jpeg`, `image/webp`). */
39
+ mediaType: string
40
+ width?: number
41
+ height?: number
42
+ status: ChatAttachmentStatus
43
+ /** Endereço da imagem, servido pela própria aplicação ou em `data:` (ADR 0050). Ausente na
44
+ * imagem expirada. */
45
+ url?: string
46
+ }
47
+
23
48
  /** Item do transcript no modo CONTROLADO (o app é o dono do estado — ex.: sala
24
49
  * server-autoritativa com replay, como o ChatRail do Maestro). */
25
50
  export type ChatTranscriptItem =
26
- | { id?: string; createdAt?: string; role: 'user' | 'assistant' | 'error'; content: string }
51
+ | {
52
+ id?: string
53
+ createdAt?: string
54
+ role: 'user' | 'assistant' | 'error'
55
+ content: string
56
+ /** Imagens da fala `user`, mostradas acima do texto. Ignorado nos outros papéis. */
57
+ attachments?: ChatAttachment[]
58
+ }
27
59
  | { id?: string; createdAt?: string; role: 'artifact'; artifact: ChatArtifact }
28
60
 
29
61
  export type ChatTranscriptMessage = Extract<ChatTranscriptItem, { content: string }>
@@ -37,9 +69,61 @@ function defaultHumanizeTool(name: string): string {
37
69
  return 'Trabalhando…'
38
70
  }
39
71
 
40
- /** A fala do usuário: fundo discreto, largura pelo conteúdo e sem se sobrepor ao histórico. */
41
- const UserTurn = React.memo(function UserTurn({ content }: { content: string }): React.ReactElement {
72
+ /** As imagens de uma fala da pessoa, acima do texto. São um componente próprio, e não `![]()`
73
+ * no Markdown: a fala continua sendo só o que a pessoa escreveu (ADR 0058). */
74
+ function ChatUserAttachments({ attachments }: { attachments: ChatAttachment[] }): React.ReactElement {
42
75
  return (
76
+ <ul
77
+ data-slot="chat-user-attachments"
78
+ aria-label="Imagens enviadas"
79
+ className="flex max-w-[88%] flex-wrap justify-end gap-1.5 self-end"
80
+ >
81
+ {attachments.map((attachment) =>
82
+ attachment.status === 'available' && attachment.url !== undefined ? (
83
+ <li key={attachment.id} data-slot="chat-attachment" data-status="available">
84
+ <img
85
+ src={attachment.url}
86
+ alt={attachment.name}
87
+ width={attachment.width}
88
+ height={attachment.height}
89
+ loading="lazy"
90
+ decoding="async"
91
+ className="h-24 w-auto max-w-48 rounded-lg border border-border bg-muted object-cover"
92
+ />
93
+ </li>
94
+ ) : (
95
+ <li
96
+ key={attachment.id}
97
+ data-slot="chat-attachment"
98
+ data-status={attachment.status}
99
+ className="flex max-w-48 items-center gap-2 rounded-lg border border-dashed border-border bg-muted/40 px-3 py-2 text-xs"
100
+ >
101
+ <ImageOffIcon aria-hidden className="size-4 shrink-0 text-muted-foreground" />
102
+ <span className="min-w-0 leading-tight">
103
+ <span className="block truncate font-medium text-foreground" title={attachment.name}>
104
+ {attachment.name}
105
+ </span>
106
+ {attachment.status === 'expired' && (
107
+ <span className="block text-muted-foreground">Imagem expirada</span>
108
+ )}
109
+ </span>
110
+ </li>
111
+ ),
112
+ )}
113
+ </ul>
114
+ )
115
+ }
116
+
117
+ /** A fala do usuário: fundo discreto, largura pelo conteúdo e sem se sobrepor ao histórico.
118
+ * As imagens vêm acima do texto; uma fala só com imagem não mostra balão vazio. */
119
+ const UserTurn = React.memo(function UserTurn({
120
+ content,
121
+ attachments,
122
+ }: {
123
+ content: string
124
+ attachments: ChatAttachment[] | undefined
125
+ }): React.ReactElement {
126
+ const bubble = content !== '' && (
43
127
  <div
44
128
  data-slot="chat-user"
45
129
  className="w-fit max-w-[88%] self-end break-words rounded-xl bg-muted px-3 py-2 text-sm leading-snug"
@@ -47,6 +131,14 @@ const UserTurn = React.memo(function UserTurn({ content }: { content: string }):
47
131
  <Markdown content={content} />
48
132
  </div>
49
133
  )
134
+ if (attachments === undefined || attachments.length === 0) return <>{bubble}</>
135
+ // O espaço entre imagens e texto fica neste invólucro, sem mudar o ritmo da fala só com texto.
136
+ return (
137
+ <div data-slot="chat-user-content" className="flex w-full flex-col items-end gap-1.5">
138
+ <ChatUserAttachments attachments={attachments} />
139
+ {bubble}
140
+ </div>
141
+ )
50
142
  })
51
143
 
52
144
  export interface ChatProps {
@@ -58,9 +150,11 @@ export interface ChatProps {
58
150
  /** Modo CONTROLADO: o transcript vem daqui e o app é o dono do estado (transporte
59
151
  * próprio — SSE com replay, etc.). Presente → `onSend`/`busy`/`activity` assumem. */
60
152
  messages?: ChatTranscriptItem[]
61
- /** Modo controlado: recebe o texto enviado. Retornar `false` devolve o texto ao
62
- * composer (falha de envio — nada de perder o que o usuário digitou). */
63
- onSend?: (text: string) => void | boolean | Promise<void | boolean>
153
+ /** Modo controlado: recebe o texto enviado e, só quando há `composerAttachments`, os anexos
154
+ * prontos (lista possivelmente vazia). Sem `composerAttachments`, a chamada é `onSend(text)`,
155
+ * como antes. Retornar `false` devolve o texto ao composer (falha de envio — nada de perder o
156
+ * que o usuário digitou); os anexos continuam na faixa, que é do app. */
157
+ onSend?: (text: string, attachments?: ComposerAttachment[]) => void | boolean | Promise<void | boolean>
64
158
  /** Modo controlado: trava o composer enquanto o turno corre. */
65
159
  busy?: boolean
66
160
  /** Modo controlado: indicador vivo — `undefined` esconde, `null` mostra "Pensando…",
@@ -71,6 +165,10 @@ export interface ChatProps {
71
165
  /** Seletores discretos na barra do composer (o `actions` do `<Composer>`): agente,
72
166
  * app, escopo… O lugar deles é a barra — `notice` é pra aviso, não pra controle. */
73
167
  composerActions?: React.ReactNode
168
+ /** Modo controlado: anexos da mensagem (o `attachments` do `<Composer>`, ADR 0058). Liga a
169
+ * faixa de anexos, o botão **Anexar imagem**, colar e soltar imagens sobre a conversa. O app
170
+ * prepara, envia, guarda e limpa os anexos. Ignorado no modo autogerenciado. */
171
+ composerAttachments?: ComposerAttachments
74
172
  /** Ajuste do contêiner externo do composer. O Chat mantém `p-3` por padrão; shells
75
173
  * integrados podem alinhar esse inset aos painéis vizinhos sem alcançar o DOM interno. */
76
174
  composerClassName?: string
@@ -192,7 +290,7 @@ const ChatTranscript = React.memo(function ChatTranscript({
192
290
  data-message-created-at={turn.user.createdAt}
193
291
  className="group/chat-message mb-1 flex flex-col items-end justify-end py-1"
194
292
  >
195
- <UserTurn content={turn.user.content} />
293
+ <UserTurn content={turn.user.content} attachments={turn.user.attachments} />
196
294
  {renderMessageActions !== undefined && (
197
295
  <div
198
296
  data-slot="chat-message-actions"
@@ -305,6 +403,7 @@ export function Chat({
305
403
  activity: activityProp,
306
404
  notice,
307
405
  composerActions,
406
+ composerAttachments,
308
407
  composerClassName,
309
408
  greeting,
310
409
  empty,
@@ -323,13 +422,21 @@ export function Chat({
323
422
  const [ownActivity, setOwnActivity] = React.useState<string | null>(null)
324
423
  const historyCursor = React.useRef<number | null>(null)
325
424
  const historyDraft = React.useRef('')
425
+ // Anexo só no modo controlado: o autogerenciado não tem transporte para a imagem (ADR 0058).
426
+ const attachments = controlled ? composerAttachments : undefined
427
+ const dropIntake = React.useRef<((files: File[]) => void) | null>(null)
428
+ const dragDepth = React.useRef(0)
429
+ const [dragging, setDragging] = React.useState(false)
326
430
 
327
431
  const items = controlled ? messages : ownItems
328
432
  const busy = controlled ? (busyProp ?? false) : ownBusy
329
433
  // Indicador: no controlado o app manda (undefined esconde); no autogerenciado segue o busy.
330
434
  const indicator = controlled ? activityProp : ownBusy ? ownActivity : undefined
331
435
  const inputHistory = React.useMemo(
332
- () => items.filter((item): item is ChatMessage => item.role === 'user').map((item) => item.content),
436
+ () =>
437
+ items
438
+ .filter((item): item is ChatMessage => item.role === 'user' && item.content !== '')
439
+ .map((item) => item.content),
333
440
  [items],
334
441
  )
335
442
 
@@ -379,13 +486,16 @@ export function Chat({
379
486
 
380
487
  async function submit(): Promise<void> {
381
488
  const text = input.trim()
382
- if (text === '' || busy) return
489
+ const ready = (attachments?.items ?? []).filter((item) => item.status === 'ready')
490
+ if ((text === '' && ready.length === 0) || busy) return
383
491
  historyCursor.current = null
384
492
  historyDraft.current = ''
385
493
  setInput('')
386
494
 
387
495
  if (controlled) {
388
- const ok = await onSend?.(text)
496
+ // Sem anexo ligado, a chamada é a de sempre: um handler que já tinha um segundo parâmetro
497
+ // opcional, de outro significado, não recebe uma lista inesperada.
498
+ const ok = attachments !== undefined ? await onSend?.(text, ready) : await onSend?.(text)
389
499
  if (ok === false) setInput(text)
390
500
  return
391
501
  }
@@ -444,8 +554,52 @@ export function Chat({
444
554
  }
445
555
  }
446
556
 
557
+ // Soltar sobre a conversa inteira: a contagem de profundidade ignora o entra-e-sai dos filhos.
558
+ // O arraste de arquivos sobre o Chat é consumido aqui e não sobe: um contêiner do app com o
559
+ // próprio soltar (e a própria dica) não recebe o mesmo arraste uma segunda vez.
560
+ const dropHandlers: React.HTMLAttributes<HTMLDivElement> =
561
+ attachments === undefined
562
+ ? {}
563
+ : {
564
+ onDragEnter: (e) => {
565
+ if (!dragHasFiles(e.dataTransfer)) return
566
+ e.stopPropagation()
567
+ dragDepth.current += 1
568
+ setDragging(true)
569
+ },
570
+ onDragOver: (e) => {
571
+ if (!dragHasFiles(e.dataTransfer)) return
572
+ e.preventDefault()
573
+ e.stopPropagation()
574
+ e.dataTransfer.dropEffect = 'copy'
575
+ },
576
+ onDragLeave: (e) => {
577
+ if (!dragHasFiles(e.dataTransfer)) return
578
+ e.stopPropagation()
579
+ dragDepth.current = Math.max(0, dragDepth.current - 1)
580
+ if (dragDepth.current === 0) setDragging(false)
581
+ },
582
+ onDrop: (e) => {
583
+ if (!dragHasFiles(e.dataTransfer)) return
584
+ e.preventDefault()
585
+ e.stopPropagation()
586
+ dragDepth.current = 0
587
+ setDragging(false)
588
+ dropIntake.current?.(Array.from(e.dataTransfer.files))
589
+ },
590
+ }
591
+
447
592
  return (
448
- <div data-slot="chat" className={cn('flex h-full flex-col', className)}>
593
+ <div data-slot="chat" className={cn('relative flex h-full flex-col', className)} {...dropHandlers}>
594
+ {dragging && (
595
+ <div
596
+ data-slot="chat-drop-hint"
597
+ aria-hidden
598
+ className="pointer-events-none absolute inset-2 z-10 grid place-items-center rounded-xl border-2 border-dashed border-primary bg-background/85 text-sm text-foreground"
599
+ >
600
+ Solte imagens para anexá-las à conversa.
601
+ </div>
602
+ )}
449
603
  <ChatTranscript
450
604
  items={items}
451
605
  indicator={indicator}
@@ -459,16 +613,19 @@ export function Chat({
459
613
  app…); o <Composer> sozinho segue sendo o caminho de quem não tem chat. */}
460
614
  <div data-slot="chat-composer" className={cn('shrink-0 p-3', composerClassName)}>
461
615
  {notice}
462
- <Composer
463
- value={input}
464
- onChange={changeInput}
465
- onSubmit={() => void submit()}
466
- onHistoryPrevious={showPreviousInput}
467
- onHistoryNext={showNextInput}
468
- busy={busy}
469
- placeholder={placeholder}
470
- actions={composerActions}
471
- />
616
+ <ComposerDropBridge.Provider value={dropIntake}>
617
+ <Composer
618
+ value={input}
619
+ onChange={changeInput}
620
+ onSubmit={() => void submit()}
621
+ onHistoryPrevious={showPreviousInput}
622
+ onHistoryNext={showNextInput}
623
+ busy={busy}
624
+ placeholder={placeholder}
625
+ actions={composerActions}
626
+ attachments={attachments}
627
+ />
628
+ </ComposerDropBridge.Provider>
472
629
  </div>
473
630
  </div>
474
631
  )