@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.
- package/CHANGELOG.md +95 -0
- package/bin/lib/check.mjs +9 -32
- package/bin/lib/copy.mjs +7 -4
- package/docs/adr/0014-structural-headers-do-not-carry-description.md +1 -1
- package/docs/adr/0018-modal-header-only-names-the-surface.md +1 -1
- package/docs/adr/0023-surfaces-share-one-anatomy-and-two-layouts.md +4 -0
- package/docs/adr/0058-mensagem-da-ia-aceita-imagem-e-o-chat-recebe-anexos.md +201 -0
- package/docs/adr/0059-subheadings-orient-without-carrying-business-rules.md +51 -0
- package/docs/adr/README.md +2 -0
- package/package.json +1 -1
- package/registry/skills/build-opus-ui/references/evaluations.md +8 -0
- package/registry/skills/build-opus-ui/references/ui-patterns.md +14 -4
- package/src/ai/drivers/anthropic.ts +49 -9
- package/src/ai/index.ts +5 -2
- package/src/core/ai-message.ts +20 -0
- package/src/core/index.ts +5 -0
- package/src/core/types.ts +28 -4
- package/src/ui/components/patterns/action-list-dialog.tsx +14 -7
- package/src/ui/components/patterns/form-dialog.tsx +18 -7
- package/src/ui/components/patterns/form.tsx +36 -8
- package/src/ui/components/patterns/page.tsx +22 -0
- package/src/ui/components/patterns/surface-anatomy.ts +1 -1
- package/src/ui/components/primitives/chat.tsx +179 -22
- package/src/ui/components/primitives/composer.tsx +397 -7
- package/src/ui/components/primitives/dialog.tsx +28 -1
- package/src/ui/components/primitives/drawer.tsx +30 -2
- package/src/ui/docs/content/action-form-card.md +1 -1
- package/src/ui/docs/content/action-form-dialog.md +8 -0
- package/src/ui/docs/content/action-form.md +18 -5
- package/src/ui/docs/content/action-list-dialog.md +8 -0
- package/src/ui/docs/content/ai.md +46 -0
- package/src/ui/docs/content/chat.md +97 -2
- package/src/ui/docs/content/composer.md +144 -1
- package/src/ui/docs/content/dialog.md +24 -2
- package/src/ui/docs/content/drawer.md +24 -2
- package/src/ui/docs/content/page.md +23 -1
- package/src/ui/docs/content/surface.md +8 -2
- package/src/ui/lib/modal-layers.ts +27 -3
- package/src/ui/lib/prepare-image.ts +386 -0
- package/src/ui/meta.ts +2 -2
- package/src/ui/react.tsx +21 -1
|
@@ -17,6 +17,7 @@ import { declaredJsonSchema } from '../../core/index.ts'
|
|
|
17
17
|
import type {
|
|
18
18
|
AiAdapter,
|
|
19
19
|
AiCompleteOptions,
|
|
20
|
+
AiContentPart,
|
|
20
21
|
AiMessage,
|
|
21
22
|
AiRunOptions,
|
|
22
23
|
AiRunResult,
|
|
@@ -101,6 +102,50 @@ function defaultToJsonSchema(schema: unknown): unknown {
|
|
|
101
102
|
return zodToJsonSchema(schema as Parameters<typeof zodToJsonSchema>[0], { $refStrategy: 'none' })
|
|
102
103
|
}
|
|
103
104
|
|
|
105
|
+
const IMAGE_MEDIA_TYPES: ReadonlySet<string> = new Set(['image/png', 'image/jpeg', 'image/webp'])
|
|
106
|
+
|
|
107
|
+
/** Converte o conteúdo neutro da mensagem (ADR 0058) para os blocos da API da Anthropic. Uma
|
|
108
|
+
* parte desconhecida, uma parte de texto vazia, uma lista vazia, uma imagem fora dos formatos
|
|
109
|
+
* aceitos ou uma imagem na fala do assistente lançam erro: nada é descartado em silêncio, e nada
|
|
110
|
+
* chega a ser enviado. */
|
|
111
|
+
function toAnthropicContent(message: AiMessage, index: number): unknown {
|
|
112
|
+
const { content } = message
|
|
113
|
+
if (typeof content === 'string') return content
|
|
114
|
+
// A API recusa lista vazia e bloco de texto sem texto; o erro aqui diz qual mensagem é.
|
|
115
|
+
if (content.length === 0) {
|
|
116
|
+
throw new Error(`ai/anthropic: mensagem ${index + 1} sem conteúdo (lista de partes vazia).`)
|
|
117
|
+
}
|
|
118
|
+
return content.map((part: AiContentPart, partIndex) => {
|
|
119
|
+
const where = `mensagem ${index + 1}, parte ${partIndex + 1}`
|
|
120
|
+
if (part.type === 'text') {
|
|
121
|
+
if (part.text.trim() === '') {
|
|
122
|
+
throw new Error(`ai/anthropic: parte de texto vazia (${where}).`)
|
|
123
|
+
}
|
|
124
|
+
return { type: 'text', text: part.text }
|
|
125
|
+
}
|
|
126
|
+
if (part.type === 'image') {
|
|
127
|
+
if (message.role !== 'user') {
|
|
128
|
+
throw new Error(`ai/anthropic: imagem só pode ir numa mensagem 'user' (${where}).`)
|
|
129
|
+
}
|
|
130
|
+
if (!IMAGE_MEDIA_TYPES.has(part.mediaType)) {
|
|
131
|
+
throw new Error(
|
|
132
|
+
`ai/anthropic: formato de imagem não aceito: "${String(part.mediaType)}" (${where}). ` +
|
|
133
|
+
'Use image/png, image/jpeg ou image/webp.',
|
|
134
|
+
)
|
|
135
|
+
}
|
|
136
|
+
return { type: 'image', source: { type: 'base64', media_type: part.mediaType, data: part.data } }
|
|
137
|
+
}
|
|
138
|
+
const unknownType = (part as { type?: unknown }).type
|
|
139
|
+
throw new Error(`ai/anthropic: parte de conteúdo desconhecida: "${String(unknownType)}" (${where}).`)
|
|
140
|
+
})
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** O histórico de entrada no formato da API, convertido antes da primeira chamada. */
|
|
144
|
+
function toAnthropicMessages(input: string | AiMessage[]): Array<{ role: string; content: unknown }> {
|
|
145
|
+
if (typeof input === 'string') return [{ role: 'user', content: input }]
|
|
146
|
+
return input.map((m, i) => ({ role: m.role, content: toAnthropicContent(m, i) }))
|
|
147
|
+
}
|
|
148
|
+
|
|
104
149
|
interface ContentBlock {
|
|
105
150
|
type: string
|
|
106
151
|
text?: string
|
|
@@ -183,6 +228,9 @@ export function anthropicAi(options: AnthropicAiOptions = {}): AiAdapter {
|
|
|
183
228
|
},
|
|
184
229
|
|
|
185
230
|
async run(input: string | AiMessage[], opts: AiRunOptions): Promise<AiRunResult> {
|
|
231
|
+
// Mensagens no formato Anthropic — convertidas antes de tudo (uma parte inválida falha sem
|
|
232
|
+
// chamar a API) e crescem com tool_use/tool_result a cada passo.
|
|
233
|
+
const messages = toAnthropicMessages(input)
|
|
186
234
|
const c = await client()
|
|
187
235
|
const tools = withAskTool(
|
|
188
236
|
opts.tools.map((t) => ({
|
|
@@ -192,11 +240,6 @@ export function anthropicAi(options: AnthropicAiOptions = {}): AiAdapter {
|
|
|
192
240
|
})),
|
|
193
241
|
opts,
|
|
194
242
|
)
|
|
195
|
-
// Mensagens no formato Anthropic — crescem com tool_use/tool_result a cada passo.
|
|
196
|
-
const messages: Array<{ role: string; content: unknown }> =
|
|
197
|
-
typeof input === 'string'
|
|
198
|
-
? [{ role: 'user', content: input }]
|
|
199
|
-
: input.map((m) => ({ role: m.role, content: m.content }))
|
|
200
243
|
|
|
201
244
|
const maxSteps = opts.maxSteps ?? 8
|
|
202
245
|
const calls: Array<{ name: string; input: unknown }> = []
|
|
@@ -242,6 +285,7 @@ export function anthropicAi(options: AnthropicAiOptions = {}): AiAdapter {
|
|
|
242
285
|
},
|
|
243
286
|
|
|
244
287
|
async *runStream(input: string | AiMessage[], opts: AiRunOptions): AsyncIterable<ChatEvent> {
|
|
288
|
+
const messages = toAnthropicMessages(input)
|
|
245
289
|
const c = await client()
|
|
246
290
|
const tools = withAskTool(
|
|
247
291
|
opts.tools.map((t) => ({
|
|
@@ -251,10 +295,6 @@ export function anthropicAi(options: AnthropicAiOptions = {}): AiAdapter {
|
|
|
251
295
|
})),
|
|
252
296
|
opts,
|
|
253
297
|
)
|
|
254
|
-
const messages: Array<{ role: string; content: unknown }> =
|
|
255
|
-
typeof input === 'string'
|
|
256
|
-
? [{ role: 'user', content: input }]
|
|
257
|
-
: input.map((m) => ({ role: m.role, content: m.content }))
|
|
258
298
|
|
|
259
299
|
const maxSteps = opts.maxSteps ?? 8
|
|
260
300
|
|
package/src/ai/index.ts
CHANGED
|
@@ -5,11 +5,14 @@
|
|
|
5
5
|
* (texto → objeto VALIDADO por um `Schema` do protocolo — o mesmo tipo de schema
|
|
6
6
|
* das actions vira o contrato da resposta do modelo). Driver em `ai/anthropic`.
|
|
7
7
|
* A superfície é a mínima de backoffice e cresce por reincidência — não por
|
|
8
|
-
* especulação
|
|
9
|
-
* real
|
|
8
|
+
* especulação. Streaming, chat multi-turn e mensagem com imagem (ADR 0058) já
|
|
9
|
+
* entraram assim; o resto espera um caso real.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
export type { AiAdapter, AiCompleteOptions } from '../core/index.ts'
|
|
13
|
+
// Mensagem com texto e imagem (ADR 0058): o tipo neutro de conteúdo e a leitura só do texto.
|
|
14
|
+
export type { AiContentPart, AiImageMediaType, AiMessage } from '../core/index.ts'
|
|
15
|
+
export { aiMessageText } from '../core/index.ts'
|
|
13
16
|
// Elicitação — perguntar à pessoa no meio do turno (round-trip via `AiRunOptions.onAsk`).
|
|
14
17
|
// O contrato (tipos) mora no core; aqui saem o nome reservado, o schema/descrição da tool e
|
|
15
18
|
// o formatador. É o ponto de consistência pra qualquer consumidor (chat, copilot, MCP externo).
|
|
@@ -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
|
@@ -618,8 +618,9 @@ export type FieldWidget = BuiltInFieldWidget | (string & { readonly __opusCustom
|
|
|
618
618
|
export interface FieldSpec {
|
|
619
619
|
label: I18nRef
|
|
620
620
|
placeholder?: I18nRef
|
|
621
|
-
/**
|
|
622
|
-
|
|
621
|
+
/** Orientação necessária para preencher ou prever o efeito da escolha, sempre visível. */
|
|
622
|
+
hint?: I18nRef
|
|
623
|
+
/** Explicação complementar sob demanda junto à label; não recebe regras indispensáveis. */
|
|
623
624
|
help?: I18nRef
|
|
624
625
|
|
|
625
626
|
default?: unknown
|
|
@@ -1383,10 +1384,33 @@ export interface AiTool {
|
|
|
1383
1384
|
}
|
|
1384
1385
|
}
|
|
1385
1386
|
|
|
1386
|
-
/**
|
|
1387
|
+
/** Formatos de imagem que a mensagem aceita: os três que os provedores conhecidos leem (ADR 0058). */
|
|
1388
|
+
export type AiImageMediaType = 'image/png' | 'image/jpeg' | 'image/webp'
|
|
1389
|
+
|
|
1390
|
+
/**
|
|
1391
|
+
* Parte de conteúdo de uma mensagem, sem a forma de nenhum provedor (ADR 0058). Cada driver
|
|
1392
|
+
* converte as partes para o formato da sua API e recusa com erro o que não conhece.
|
|
1393
|
+
*
|
|
1394
|
+
* A imagem não leva nome: o rótulo que a pessoa e o modelo usam ("Imagem 1: print.png") é uma
|
|
1395
|
+
* parte de texto que o consumidor monta antes dela.
|
|
1396
|
+
*/
|
|
1397
|
+
export type AiContentPart =
|
|
1398
|
+
| { type: 'text'; text: string }
|
|
1399
|
+
| {
|
|
1400
|
+
type: 'image'
|
|
1401
|
+
mediaType: AiImageMediaType
|
|
1402
|
+
/** Bytes da imagem em base64, sem o prefixo `data:`. */
|
|
1403
|
+
data: string
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
/**
|
|
1407
|
+
* Turno de conversa (histórico multi-turn do chat). `content` é texto ou uma lista de partes
|
|
1408
|
+
* com texto e imagem (ADR 0058). Para ler só o texto, use `aiMessageText(message)`: interpolar
|
|
1409
|
+
* `content` direto num template produz "[object Object]" quando a mensagem tem partes.
|
|
1410
|
+
*/
|
|
1387
1411
|
export interface AiMessage {
|
|
1388
1412
|
role: 'user' | 'assistant'
|
|
1389
|
-
content: string
|
|
1413
|
+
content: string | AiContentPart[]
|
|
1390
1414
|
}
|
|
1391
1415
|
|
|
1392
1416
|
/** Uma opção de resposta de uma pergunta de esclarecimento (`ask_user`). */
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
DialogContent,
|
|
20
20
|
DialogHeader,
|
|
21
21
|
DialogTitle,
|
|
22
|
+
DialogDescription,
|
|
22
23
|
} from '../primitives/dialog.tsx'
|
|
23
24
|
import { ActionList } from './list.tsx'
|
|
24
25
|
import type { ListAction } from '../../../core/index.ts'
|
|
@@ -34,6 +35,8 @@ export interface ActionListDialogProps<
|
|
|
34
35
|
open: boolean
|
|
35
36
|
onOpenChange: (open: boolean) => void
|
|
36
37
|
title: string
|
|
38
|
+
/** Orientação opcional sobre a finalidade; regras e consequências pertencem a intro ou aos campos. */
|
|
39
|
+
description?: ReactNode
|
|
37
40
|
/** Conteúdo relevante apresentado antes da lista, como texto ou Alert. */
|
|
38
41
|
intro?: ReactNode
|
|
39
42
|
/** Nota à esquerda da linha de ações (contexto curto). O total já mora no rodapé. */
|
|
@@ -63,6 +66,7 @@ export function ActionListDialog<
|
|
|
63
66
|
open,
|
|
64
67
|
onOpenChange,
|
|
65
68
|
title,
|
|
69
|
+
description,
|
|
66
70
|
intro,
|
|
67
71
|
note,
|
|
68
72
|
actions,
|
|
@@ -75,19 +79,22 @@ export function ActionListDialog<
|
|
|
75
79
|
children,
|
|
76
80
|
}: ActionListDialogProps<TItem, TInput>): React.ReactElement {
|
|
77
81
|
const introId = useId()
|
|
82
|
+
const descriptionId = useId()
|
|
83
|
+
const describedBy =
|
|
84
|
+
[description == null ? null : descriptionId, intro == null ? null : introId]
|
|
85
|
+
.filter(Boolean)
|
|
86
|
+
.join(' ') || undefined
|
|
78
87
|
return (
|
|
79
88
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
80
|
-
<DialogContent
|
|
81
|
-
className={cn('max-w-3xl', className)}
|
|
82
|
-
aria-describedby={intro === undefined ? undefined : introId}
|
|
83
|
-
>
|
|
89
|
+
<DialogContent className={cn('max-w-3xl', className)} aria-describedby={describedBy}>
|
|
84
90
|
<DialogHeader>
|
|
85
91
|
<DialogTitle>{title}</DialogTitle>
|
|
92
|
+
{description != null && (
|
|
93
|
+
<DialogDescription id={descriptionId}>{description}</DialogDescription>
|
|
94
|
+
)}
|
|
86
95
|
</DialogHeader>
|
|
87
96
|
<DialogBody>
|
|
88
|
-
{intro
|
|
89
|
-
<div id={introId}>{intro}</div>
|
|
90
|
-
)}
|
|
97
|
+
{intro != null && <div id={introId}>{intro}</div>}
|
|
91
98
|
{(note !== undefined || actions !== undefined) && (
|
|
92
99
|
<div
|
|
93
100
|
data-slot="action-list-dialog-toolbar"
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
DialogFooter,
|
|
17
17
|
DialogHeader,
|
|
18
18
|
DialogTitle,
|
|
19
|
+
DialogDescription,
|
|
19
20
|
} from "../primitives/dialog.tsx";
|
|
20
21
|
import { ActionForm, type ActionFormProps } from "./form.tsx";
|
|
21
22
|
import { ButtonGroup } from "../primitives/button-group.tsx";
|
|
@@ -27,6 +28,8 @@ export interface ActionFormDialogProps<
|
|
|
27
28
|
open: boolean;
|
|
28
29
|
onOpenChange: (open: boolean) => void;
|
|
29
30
|
title: string;
|
|
31
|
+
/** Orientação opcional sobre a finalidade; regras e consequências pertencem a intro ou aos campos. */
|
|
32
|
+
description?: ReactNode;
|
|
30
33
|
/** Conteúdo relevante apresentado antes dos campos, como texto ou Alert. */
|
|
31
34
|
intro?: ReactNode;
|
|
32
35
|
/** Distribui as ações pela largura disponível somente quando as decisões pedem peso equivalente. */
|
|
@@ -40,6 +43,7 @@ export function ActionFormDialog<
|
|
|
40
43
|
open,
|
|
41
44
|
onOpenChange,
|
|
42
45
|
title,
|
|
46
|
+
description,
|
|
43
47
|
intro,
|
|
44
48
|
onSuccess,
|
|
45
49
|
onCancel,
|
|
@@ -48,15 +52,23 @@ export function ActionFormDialog<
|
|
|
48
52
|
...rest
|
|
49
53
|
}: ActionFormDialogProps<TInput, TData>) {
|
|
50
54
|
const introId = useId();
|
|
55
|
+
const descriptionId = useId();
|
|
56
|
+
const describedBy =
|
|
57
|
+
[description == null ? null : descriptionId, intro == null ? null : introId]
|
|
58
|
+
.filter(Boolean)
|
|
59
|
+
.join(" ") || undefined;
|
|
51
60
|
return (
|
|
52
61
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
53
62
|
{/* Header e rodapé FIXOS, só o CORPO (campos) scrolla — o "X" não rola junto e
|
|
54
63
|
forms longos não estouram a viewport. O content já é coluna flex com teto. */}
|
|
55
|
-
<DialogContent
|
|
56
|
-
aria-describedby={intro === undefined ? undefined : introId}
|
|
57
|
-
>
|
|
64
|
+
<DialogContent aria-describedby={describedBy}>
|
|
58
65
|
<DialogHeader>
|
|
59
66
|
<DialogTitle>{title}</DialogTitle>
|
|
67
|
+
{description != null && (
|
|
68
|
+
<DialogDescription id={descriptionId}>
|
|
69
|
+
{description}
|
|
70
|
+
</DialogDescription>
|
|
71
|
+
)}
|
|
60
72
|
</DialogHeader>
|
|
61
73
|
<ActionForm
|
|
62
74
|
{...rest}
|
|
@@ -65,9 +77,7 @@ export function ActionFormDialog<
|
|
|
65
77
|
// faixa da casa — fonte única). Os botões seguem dentro do <form> do ActionForm.
|
|
66
78
|
body={(fields) => (
|
|
67
79
|
<DialogBody>
|
|
68
|
-
{intro
|
|
69
|
-
<div id={introId}>{intro}</div>
|
|
70
|
-
)}
|
|
80
|
+
{intro != null && <div id={introId}>{intro}</div>}
|
|
71
81
|
{fields}
|
|
72
82
|
</DialogBody>
|
|
73
83
|
)}
|
|
@@ -84,7 +94,8 @@ export function ActionFormDialog<
|
|
|
84
94
|
}}
|
|
85
95
|
onCancel={onCancel ?? (() => onOpenChange(false))}
|
|
86
96
|
cancelVariant={
|
|
87
|
-
cancelVariant ??
|
|
97
|
+
cancelVariant ??
|
|
98
|
+
(footerDistribution === "equal" ? "outline" : "ghost")
|
|
88
99
|
}
|
|
89
100
|
/>
|
|
90
101
|
</DialogContent>
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Dois modos, um motor:
|
|
5
5
|
* - AUTO (sem children): monta TODOS os campos na ordem do contrato — zero JSX de campo.
|
|
6
6
|
* - COMPOSIÇÃO (com children): você diagrama; <ActionFormField name /> coloca cada campo
|
|
7
|
-
* (label/help/widget/erro/asterisco derivados do contrato) onde quiser. Condicional é
|
|
7
|
+
* (label/hint/help/widget/erro/asterisco derivados do contrato) onde quiser. Condicional é
|
|
8
8
|
* JSX ({cond && <ActionFormField/>}); opções de runtime entram por prop no campo. O
|
|
9
9
|
* FieldGroup canônico permanece nos dois modos; grids e seções entram como filhos explícitos.
|
|
10
10
|
*
|
|
@@ -54,6 +54,7 @@ import { ToggleGroup } from "../primitives/toggle-group.tsx";
|
|
|
54
54
|
import {
|
|
55
55
|
Field,
|
|
56
56
|
FieldError,
|
|
57
|
+
FieldDescription,
|
|
57
58
|
FieldGroup,
|
|
58
59
|
FieldLabel,
|
|
59
60
|
} from "../primitives/field.tsx";
|
|
@@ -137,7 +138,9 @@ function inferFieldKind(schema: ZodTypeAny): FieldKind {
|
|
|
137
138
|
interface FieldSpec {
|
|
138
139
|
label?: string;
|
|
139
140
|
placeholder?: string;
|
|
140
|
-
/**
|
|
141
|
+
/** Orientação necessária sempre visível abaixo do controle. */
|
|
142
|
+
hint?: string;
|
|
143
|
+
/** Detalhe complementar no ícone junto à label, disponível por hover ou foco. */
|
|
141
144
|
help?: string;
|
|
142
145
|
/** Override explícito do tipo de campo, quando o auto-detect do Zod não basta.
|
|
143
146
|
* Honra 'textarea' (string sem max), 'code' (textarea monoespaçada, ex.: SKILL.md),
|
|
@@ -206,9 +209,8 @@ function dictMetaOptions(schema: ZodTypeAny): SelectOption[] | undefined {
|
|
|
206
209
|
* lugar (`<FieldLabel>Nome<LabelHelp help="…" /></FieldLabel>`) e o `opus copy` inventarie
|
|
207
210
|
* o texto de `help` como helper-text.
|
|
208
211
|
*
|
|
209
|
-
*
|
|
210
|
-
*
|
|
211
|
-
* (não foca o input / não toggla).
|
|
212
|
+
* O botão participa do Tab e abre por foco ou hover. Dialog e Drawer preservam o foco
|
|
213
|
+
* inicial no controle, sem remover a ajuda da navegação por teclado. Clicar não ativa a label.
|
|
212
214
|
*/
|
|
213
215
|
export function LabelHelp({
|
|
214
216
|
help,
|
|
@@ -222,7 +224,8 @@ export function LabelHelp({
|
|
|
222
224
|
<TooltipTrigger asChild>
|
|
223
225
|
<button
|
|
224
226
|
type="button"
|
|
225
|
-
|
|
227
|
+
data-slot="label-help"
|
|
228
|
+
tabIndex={0}
|
|
226
229
|
aria-label="Ajuda"
|
|
227
230
|
onClick={(e) => {
|
|
228
231
|
// Clicar o ícone não ativa o label (não foca/toggla o campo).
|
|
@@ -344,7 +347,8 @@ export function ActionFormField({
|
|
|
344
347
|
const errorMessage =
|
|
345
348
|
typeof fieldError?.message === "string" ? fieldError.message : undefined;
|
|
346
349
|
const errorId = errorMessage !== undefined ? `${name}-error` : undefined;
|
|
347
|
-
const
|
|
350
|
+
const hintId = spec.hint?.trim() ? `${name}-hint` : undefined;
|
|
351
|
+
const describedBy = [hintId, errorId].filter(Boolean).join(" ") || undefined;
|
|
348
352
|
useEffect(() => {
|
|
349
353
|
if (errorMessage === undefined) return;
|
|
350
354
|
const control = document.getElementById(name);
|
|
@@ -430,6 +434,9 @@ export function ActionFormField({
|
|
|
430
434
|
</span>
|
|
431
435
|
<LabelHelp help={spec.help} />
|
|
432
436
|
</FieldLabel>
|
|
437
|
+
{hintId !== undefined && (
|
|
438
|
+
<FieldDescription id={hintId}>{spec.hint}</FieldDescription>
|
|
439
|
+
)}
|
|
433
440
|
{errorMessage !== undefined && (
|
|
434
441
|
<FieldError id={errorId}>{errorMessage}</FieldError>
|
|
435
442
|
)}
|
|
@@ -683,6 +690,9 @@ export function ActionFormField({
|
|
|
683
690
|
/>
|
|
684
691
|
)}
|
|
685
692
|
|
|
693
|
+
{hintId !== undefined && (
|
|
694
|
+
<FieldDescription id={hintId}>{spec.hint}</FieldDescription>
|
|
695
|
+
)}
|
|
686
696
|
{errorMessage !== undefined && (
|
|
687
697
|
<FieldError id={errorId}>{errorMessage}</FieldError>
|
|
688
698
|
)}
|
|
@@ -774,7 +784,25 @@ export function ActionForm<TInput extends Record<string, unknown>, TData>({
|
|
|
774
784
|
string,
|
|
775
785
|
ZodTypeAny | undefined
|
|
776
786
|
>;
|
|
777
|
-
|
|
787
|
+
// O contexto entrega texto resolvido também aos renderizadores de campo do consumidor.
|
|
788
|
+
const fields: Record<string, FieldSpec> = Object.fromEntries(
|
|
789
|
+
Object.entries(action.fields ?? {}).map(([name, declared]) => {
|
|
790
|
+
const spec = declared as FieldSpec;
|
|
791
|
+
return [
|
|
792
|
+
name,
|
|
793
|
+
{
|
|
794
|
+
...spec,
|
|
795
|
+
label: msgText(spec.label, name),
|
|
796
|
+
placeholder:
|
|
797
|
+
spec.placeholder === undefined
|
|
798
|
+
? undefined
|
|
799
|
+
: msgText(spec.placeholder, ""),
|
|
800
|
+
hint: spec.hint === undefined ? undefined : msgText(spec.hint, ""),
|
|
801
|
+
help: spec.help === undefined ? undefined : msgText(spec.help, ""),
|
|
802
|
+
},
|
|
803
|
+
];
|
|
804
|
+
}),
|
|
805
|
+
);
|
|
778
806
|
|
|
779
807
|
// Corpo e rodapé como SLOTS: por padrão renderizam inline; o ActionFormDialog injeta
|
|
780
808
|
// DialogBody/DialogFooter (fonte única do scroll e da faixa). Os botões ficam DENTRO do
|
|
@@ -147,6 +147,8 @@ interface PageBaseProps extends Omit<
|
|
|
147
147
|
|
|
148
148
|
interface PageShorthandProps extends PageBaseProps {
|
|
149
149
|
title: ReactNode;
|
|
150
|
+
/** Orientação opcional sobre a finalidade da página; regras pertencem ao corpo. */
|
|
151
|
+
description?: ReactNode;
|
|
150
152
|
actions?: ReactNode;
|
|
151
153
|
children: ReactNode;
|
|
152
154
|
}
|
|
@@ -224,6 +226,7 @@ function hasOpaquePageBodyContent(node: ReactNode): boolean {
|
|
|
224
226
|
|
|
225
227
|
export function Page({
|
|
226
228
|
title,
|
|
229
|
+
description,
|
|
227
230
|
actions,
|
|
228
231
|
layout = "flow",
|
|
229
232
|
className,
|
|
@@ -343,6 +346,9 @@ export function Page({
|
|
|
343
346
|
<>
|
|
344
347
|
<PageHeader>
|
|
345
348
|
<PageTitle>{title}</PageTitle>
|
|
349
|
+
{description !== undefined && (
|
|
350
|
+
<PageDescription>{description}</PageDescription>
|
|
351
|
+
)}
|
|
346
352
|
{actions !== undefined && <PageActions>{actions}</PageActions>}
|
|
347
353
|
</PageHeader>
|
|
348
354
|
<PageBody>{children}</PageBody>
|
|
@@ -461,6 +467,7 @@ export function PageHeader({
|
|
|
461
467
|
slots={{
|
|
462
468
|
leading: [PageBack, PageNavigation],
|
|
463
469
|
title: PageTitle,
|
|
470
|
+
description: PageDescription,
|
|
464
471
|
actions: PageActions,
|
|
465
472
|
}}
|
|
466
473
|
leadingPlacement="above"
|
|
@@ -496,6 +503,21 @@ export function PageTitle({
|
|
|
496
503
|
);
|
|
497
504
|
}
|
|
498
505
|
|
|
506
|
+
/** Orientação breve e dispensável para a decisão; não recebe regras de negócio. */
|
|
507
|
+
export function PageDescription({
|
|
508
|
+
className,
|
|
509
|
+
...props
|
|
510
|
+
}: HTMLAttributes<HTMLParagraphElement>): ReactElement {
|
|
511
|
+
requireParent(useContext(PageHeaderContext), "PageDescription", "PageHeader");
|
|
512
|
+
return (
|
|
513
|
+
<p
|
|
514
|
+
data-slot="page-description"
|
|
515
|
+
className={cn(structuralHeaderClasses.page.description, className)}
|
|
516
|
+
{...props}
|
|
517
|
+
/>
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
|
|
499
521
|
export function PageActions({
|
|
500
522
|
className,
|
|
501
523
|
...props
|
|
@@ -60,7 +60,7 @@ interface FamilyAnatomy {
|
|
|
60
60
|
regions: Record<SurfaceLayout, RegionAnatomy>;
|
|
61
61
|
/** Escala visual do título. O nível semântico do heading é escolhido à parte. */
|
|
62
62
|
title: Record<SurfaceLayout, string>;
|
|
63
|
-
/** Descrição no header, quando a família a oferece (
|
|
63
|
+
/** Descrição no header, quando a família a oferece (ADR 0059 inclui subheadings opcionais em modais). */
|
|
64
64
|
description: string;
|
|
65
65
|
/** Espaço entre mídia, título e ações na linha do header. */
|
|
66
66
|
headerGap: string;
|