@softize/opus 17.2.0 → 18.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 +55 -1
- package/bin/lib/check.mjs +212 -45
- package/docs/adr/0011-page-shell-coordinates-persistent-page-chrome.md +4 -0
- package/docs/adr/0013-presentation-is-a-portable-action-oriented-artifact.md +14 -2
- package/docs/adr/0016-list-collection-header-belongs-to-content.md +80 -0
- package/package.json +1 -1
- package/registry/skills/build-opus-ui/SKILL.md +30 -20
- package/registry/skills/build-opus-ui/references/evaluations.md +9 -3
- package/registry/skills/build-opus-ui/references/ui-patterns.md +29 -17
- package/src/core/presentation.ts +223 -24
- package/src/core/runtime.ts +3 -0
- package/src/core/types.ts +2 -0
- package/src/mcp/index.ts +1 -0
- package/src/ui/components/patterns/action-form-card.tsx +8 -1
- package/src/ui/components/patterns/confirm.tsx +194 -157
- package/src/ui/components/patterns/content-header.tsx +17 -2
- package/src/ui/components/patterns/form-dialog.tsx +28 -14
- package/src/ui/components/patterns/form.tsx +340 -222
- package/src/ui/components/patterns/list.tsx +43 -44
- package/src/ui/components/patterns/page-heading-context.tsx +34 -0
- package/src/ui/components/patterns/page-state.tsx +2 -0
- package/src/ui/components/patterns/page.tsx +164 -50
- package/src/ui/components/patterns/presentation.tsx +140 -84
- package/src/ui/components/patterns/surface-header.tsx +5 -6
- package/src/ui/components/patterns/trigger.tsx +112 -82
- package/src/ui/components/primitives/button.tsx +2 -2
- package/src/ui/components/primitives/chat.tsx +19 -5
- package/src/ui/components/primitives/control.ts +9 -3
- package/src/ui/components/primitives/dialog.tsx +16 -9
- package/src/ui/components/primitives/drawer.tsx +9 -6
- package/src/ui/docs/content/action-form-card.md +9 -8
- package/src/ui/docs/content/action-form-dialog.md +11 -12
- package/src/ui/docs/content/action-form.md +25 -25
- package/src/ui/docs/content/action-list.md +101 -70
- package/src/ui/docs/content/chat.md +4 -4
- package/src/ui/docs/content/content.md +29 -13
- package/src/ui/docs/content/dialog.md +27 -21
- package/src/ui/docs/content/drawer.md +8 -6
- package/src/ui/docs/content/page.md +43 -50
- package/src/ui/docs/content/presentation.md +39 -28
- package/src/ui/docs/doc-client.tsx +1 -1
- package/src/ui/meta.ts +4 -4
|
@@ -4,11 +4,17 @@
|
|
|
4
4
|
- Não dispara: “Ajuste o CSS de um e-mail estático.”
|
|
5
5
|
- Execução: implementar uma lista com modal roteável e provar loading, erro, vazio e back.
|
|
6
6
|
- Execução estrutural: montar uma página de relatório isolada com `Page > PageHeader + PageBody`,
|
|
7
|
-
outra em `PageShell` com `Page > PageIntro + PageBody`,
|
|
8
|
-
|
|
7
|
+
outra em `PageShell` com `Page > PageHeader? + PageIntro? + PageBody`, garantindo exatamente um
|
|
8
|
+
`PageTitle` entre header/intro, e uma coleção nomeada com
|
|
9
|
+
`Content > ContentHeader + ContentBody > ActionList`. Provar que criação fica em
|
|
10
|
+
`ContentActions`, no extremo oposto ao título, como botão textual `default` sem ícone e com
|
|
11
|
+
`Criar recurso`, enquanto uma operação dependente do recorte usa `toolbarActions`; incluir
|
|
12
|
+
`ActionFilterBar` separado do renderer, `ItemGroup` para
|
|
9
13
|
uma coleção secundária e um `ActionFormDialog`; provar teto padrão de `80rem`, hierarquia por
|
|
10
14
|
`level`, vazio estrutural sólido, `Empty` apenas para criação ou vínculo, números sem alinhamento
|
|
11
|
-
inferido e cancelamento `
|
|
15
|
+
inferido e cancelamento `outline` no footer 50/50 do modal.
|
|
16
|
+
- Execução roteável: declarar Page, criação e edição com `Presentation.route`; provar deep link,
|
|
17
|
+
hidratação do registro, fechamento para a lista e projeção integral no manifest.
|
|
12
18
|
- Execução abreviada: montar outra página com `<Page title actions>` e uma seção com
|
|
13
19
|
`<Content title description actions>`, provando que ambas produzem a mesma anatomia e que o lint
|
|
14
20
|
rejeita a mistura entre props abreviadas e headers explícitos.
|
|
@@ -4,29 +4,33 @@
|
|
|
4
4
|
- Campos, labels, mensagens e invalidações pertencem ao contrato quando são parte da
|
|
5
5
|
operação, não a uma tela isolada.
|
|
6
6
|
- URL representa estado que precisa sobreviver a refresh, deep link ou histórico.
|
|
7
|
-
- `
|
|
8
|
-
`
|
|
9
|
-
|
|
10
|
-
|
|
7
|
+
- Uma `Presentation` que representa esse estado declara `route.path` e `route.surface`. Rotas com
|
|
8
|
+
parâmetro podem declarar `route.record` para hidratar o item a partir de uma Presentation de
|
|
9
|
+
lista; não reconstruir essa decisão com condicionais no componente da página.
|
|
10
|
+
- `Page` fornece o `<main>` e o container centralizado com teto padrão de `80rem`. Sua forma
|
|
11
|
+
explícita exige `PageBody`, exatamente um heading principal e aceita `PageHeader`, `PageIntro` e
|
|
12
|
+
`PageFooter`. Declare esse heading com `PageTitle` em header/intro, `Content level={1}` ou um
|
|
13
|
+
`PageState` ativo no body. `PageHeader`
|
|
14
|
+
declara chrome; `PageIntro` declara uma introdução opcional no conteúdo. `title` e `actions` no
|
|
15
|
+
próprio `Page` são a abreviação para o caso direto.
|
|
11
16
|
Não misturar as duas formas. Alterar `className` apenas quando a superfície tiver uma necessidade
|
|
12
17
|
real de largura; não reconstruir esse container em cada rota.
|
|
13
|
-
- `PageIntro` é a região estrutural
|
|
14
|
-
|
|
15
|
-
- `PageHeader`
|
|
16
|
-
|
|
18
|
+
- `PageIntro` é a região estrutural de uma introdução no conteúdo. Pode coexistir com
|
|
19
|
+
`PageHeader`, porque as duas regiões têm papéis diferentes.
|
|
20
|
+
- `PageHeader` declara navegação e ações globais; fora do shell também pode carregar o título. Em
|
|
21
|
+
uma subpágina simples, `PageBack` recebe o destino pai explícito e aparece
|
|
17
22
|
antes do título como controle somente com ícone. Para mais de um ancestral relevante, use
|
|
18
23
|
`Breadcrumb` dentro de `PageNavigation`. Não combine retorno e breadcrumb nem crie um chrome
|
|
19
24
|
paralelo para uma `Page`. `PageActionsTarget` fica reservado a workspaces imersivos que já
|
|
20
25
|
possuam chrome próprio.
|
|
21
26
|
- Quando shell e rota conhecem partes diferentes da mesma página, use `PageShell` ao redor da rota.
|
|
22
|
-
O shell é o único responsável pela barra
|
|
23
|
-
|
|
24
|
-
conteúdo com o título em `PageIntro`.
|
|
27
|
+
O shell é o único responsável pela barra e projeta os slots de `PageHeader`; `PageIntro` permanece
|
|
28
|
+
no conteúdo.
|
|
25
29
|
Ações com texto na barra usam o tamanho `default`, como as ações de footer de Dialog e Drawer;
|
|
26
30
|
ações somente com ícone de chrome usam `icon-sm`. O tamanho `sm` fica para ações operacionais em
|
|
27
31
|
toolbar, seção ou coleção densa, e `xs`/`icon-xs` para ações internas de linha ou célula. Contexto
|
|
28
|
-
e variante resolvem a hierarquia visual sem alterar essa medida.
|
|
29
|
-
|
|
32
|
+
e variante resolvem a hierarquia visual sem alterar essa medida. Em uma coleção, a ação de
|
|
33
|
+
criar usa botão textual `default`, sem ícone, no extremo oposto do `ContentHeader`. Não monte
|
|
30
34
|
`PaneHeader`, portal ou seletor global para reconstruir essa composição.
|
|
31
35
|
- `PageState` substitui todo o conteúdo principal quando a página carrega, falha ou está vazia. Na
|
|
32
36
|
forma curta ele pode ser escrito como filho direto de `Page`, que cria o `PageBody`; na forma
|
|
@@ -41,8 +45,13 @@ PageActions?) + PageBody`; `title` e `actions` no próprio `Page` são a abrevia
|
|
|
41
45
|
- `Content` delimita uma seção e segue a mesma anatomia: `Content > ContentHeader (ContentTitle,
|
|
42
46
|
ContentDescription, ContentMeta, ContentActions) + ContentBody`. `ContentHeader` nunca fica
|
|
43
47
|
solto. `title`, `description`, `count` e `actions` no `Content` são a abreviação para o caso
|
|
44
|
-
direto e não podem ser misturados ao header explícito. `
|
|
45
|
-
|
|
48
|
+
direto e não podem ser misturados ao header explícito. Na variante `page`, ações da coleção ficam
|
|
49
|
+
no extremo oposto ao título; a criação usa botão textual `default`, sem ícone, com `Criar recurso`.
|
|
50
|
+
O diálogo de criação usa `Criar recurso` e o de edição, `Editar recurso`. `level` preserva a
|
|
51
|
+
hierarquia semântica do heading.
|
|
52
|
+
- Uma página centrada em coleção compõe `Content + ActionList`. O `ContentHeader` recebe título e
|
|
53
|
+
criação; o `ActionList` mantém busca, filtros, atualização, resultados, estados e paginação.
|
|
54
|
+
`toolbarActions` fica reservado a operações que dependem do recorte atual.
|
|
46
55
|
- Card, Drawer e Pane nomeiam a região principal como `CardBody`, `DrawerBody` e `PaneBody`.
|
|
47
56
|
`*Content` permanece reservado a raízes técnicas ou painéis cujo papel não é o corpo de uma
|
|
48
57
|
estrutura, como `DialogContent`, `PopoverContent` e `TabsContent`.
|
|
@@ -64,8 +73,11 @@ ContentDescription, ContentMeta, ContentActions) + ContentBody`. `ContentHeader`
|
|
|
64
73
|
- `ActionForm` mantém validação, execução e estados do contrato nos dois modos: sem `children`,
|
|
65
74
|
renderiza os campos declarados; com `children`, o consumidor diagrama `ActionFormField` e
|
|
66
75
|
controles customizados pelo contexto. `ActionFormCard` e `ActionFormDialog` acrescentam a
|
|
67
|
-
casca; não duplicar o form para obter card ou modal. Cancelamento
|
|
68
|
-
|
|
76
|
+
casca; não duplicar o form para obter card ou modal. Cancelamento usa `ghost` por padrão. Quando
|
|
77
|
+
um formulário modal divide o footer igualmente entre duas ações, o cancelamento usa `outline` e
|
|
78
|
+
a ação principal mantém `solid`. Criação e edição comuns preservam o rótulo padrão `Salvar`;
|
|
79
|
+
`submitLabel` fica para efeitos específicos, como `Renomear` ou `Criar nova versão`. Em
|
|
80
|
+
`Dialog mode="alert"`, a saída segura também usa `outline`.
|
|
69
81
|
- Cabeçalhos de `Dialog` e `Drawer` nomeiam a superfície com o título e organizam suas ações. O close
|
|
70
82
|
padrão é uma action ghost somente com ícone no final do header; não crie uma posição flutuante
|
|
71
83
|
alternativa. Não preencher uma segunda linha por hábito. Consequência, restrição ou instrução que
|
package/src/core/presentation.ts
CHANGED
|
@@ -108,11 +108,33 @@ const presentationBodySchema = z
|
|
|
108
108
|
})
|
|
109
109
|
.strict();
|
|
110
110
|
|
|
111
|
+
const presentationRouteRecordSchema = z
|
|
112
|
+
.object({
|
|
113
|
+
presentation: identifierSchema,
|
|
114
|
+
param: identifierSchema,
|
|
115
|
+
field: z.string().min(1),
|
|
116
|
+
})
|
|
117
|
+
.strict();
|
|
118
|
+
|
|
119
|
+
export const presentationRouteSchema = z
|
|
120
|
+
.object({
|
|
121
|
+
path: z
|
|
122
|
+
.string()
|
|
123
|
+
.regex(
|
|
124
|
+
/^\/(?:[a-z0-9-]+|:[a-z][a-z0-9]*)(?:\/(?:[a-z0-9-]+|:[a-z][a-z0-9]*))*$/,
|
|
125
|
+
),
|
|
126
|
+
surface: presentationSurfaceSchema,
|
|
127
|
+
record: presentationRouteRecordSchema.optional(),
|
|
128
|
+
})
|
|
129
|
+
.strict();
|
|
130
|
+
export type PresentationRoute = z.infer<typeof presentationRouteSchema>;
|
|
131
|
+
|
|
111
132
|
export const presentationSchema = z
|
|
112
133
|
.object({
|
|
113
134
|
schemaVersion: z.literal(1),
|
|
114
135
|
id: identifierSchema,
|
|
115
136
|
title: z.string().min(1),
|
|
137
|
+
route: presentationRouteSchema.optional(),
|
|
116
138
|
body: presentationBodySchema,
|
|
117
139
|
actions: z.array(presentationCommandSchema).default([]),
|
|
118
140
|
})
|
|
@@ -121,12 +143,15 @@ export const presentationSchema = z
|
|
|
121
143
|
export type PresentationDefinition = z.infer<typeof presentationSchema>;
|
|
122
144
|
export type PresentationDefinitionInput = z.input<typeof presentationSchema>;
|
|
123
145
|
export type PresentationCommand = PresentationDefinition["actions"][number];
|
|
124
|
-
type DefaultedProperty<
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
146
|
+
type DefaultedProperty<
|
|
147
|
+
Input,
|
|
148
|
+
Key extends PropertyKey,
|
|
149
|
+
Output,
|
|
150
|
+
> = Key extends keyof Input
|
|
151
|
+
? undefined extends Input[Key]
|
|
152
|
+
? Exclude<Input[Key], undefined> | Output
|
|
153
|
+
: Input[Key] & Output
|
|
154
|
+
: Output;
|
|
130
155
|
type NavigateEffect = Extract<PresentationEffect, { effect: "navigate" }>;
|
|
131
156
|
type DefinedPresentationEffect<Effect> = Effect extends { effect: "navigate" }
|
|
132
157
|
? Omit<Effect, "input" | "mode"> &
|
|
@@ -135,18 +160,20 @@ type DefinedPresentationEffect<Effect> = Effect extends { effect: "navigate" }
|
|
|
135
160
|
mode: DefaultedProperty<Effect, "mode", NavigateEffect["mode"]>;
|
|
136
161
|
}
|
|
137
162
|
: Effect & PresentationEffect;
|
|
138
|
-
type DefinedPresentationEffects<
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
163
|
+
type DefinedPresentationEffects<
|
|
164
|
+
Input,
|
|
165
|
+
Key extends PropertyKey,
|
|
166
|
+
> = Key extends keyof Input
|
|
167
|
+
? undefined extends Input[Key]
|
|
168
|
+
? PresentationCommand["onSuccess"]
|
|
169
|
+
: Input[Key] extends readonly unknown[]
|
|
170
|
+
? {
|
|
171
|
+
-readonly [Index in keyof Input[Key]]: DefinedPresentationEffect<
|
|
172
|
+
Input[Key][Index]
|
|
173
|
+
>;
|
|
174
|
+
}
|
|
175
|
+
: PresentationCommand["onSuccess"]
|
|
176
|
+
: PresentationCommand["onSuccess"];
|
|
150
177
|
type PresentationOpen = NonNullable<PresentationDefinition["body"]["open"]>;
|
|
151
178
|
type DefinedPresentationOpen<Open> = Open extends object
|
|
152
179
|
? Omit<Open, "input"> &
|
|
@@ -171,7 +198,11 @@ type DefinedPresentationBody<Body extends PresentationDefinitionInput["body"]> =
|
|
|
171
198
|
type DefinedPresentationCommand<Command> = Command extends object
|
|
172
199
|
? Omit<Command, "input" | "blocking" | "onSuccess"> &
|
|
173
200
|
Omit<PresentationCommand, "input" | "blocking" | "onSuccess"> & {
|
|
174
|
-
input: DefaultedProperty<
|
|
201
|
+
input: DefaultedProperty<
|
|
202
|
+
Command,
|
|
203
|
+
"input",
|
|
204
|
+
PresentationCommand["input"]
|
|
205
|
+
>;
|
|
175
206
|
blocking: DefaultedProperty<
|
|
176
207
|
Command,
|
|
177
208
|
"blocking",
|
|
@@ -241,6 +272,84 @@ export interface PresentationActionRegistry {
|
|
|
241
272
|
readonly [name: string]: ActionContract;
|
|
242
273
|
}
|
|
243
274
|
|
|
275
|
+
export interface PresentationRouteMatch {
|
|
276
|
+
definition: PresentationDefinition;
|
|
277
|
+
input: Record<string, string>;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function routeSegments(path: string): string[] {
|
|
281
|
+
return path.split("/").filter(Boolean);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function routePattern(path: string): string {
|
|
285
|
+
return routeSegments(path)
|
|
286
|
+
.map((segment) => (segment.startsWith(":") ? ":" : segment))
|
|
287
|
+
.join("/");
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function decodeRouteSegment(segment: string): string | null {
|
|
291
|
+
try {
|
|
292
|
+
return decodeURIComponent(segment);
|
|
293
|
+
} catch {
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export function matchPresentationRoute(
|
|
299
|
+
definitions: readonly PresentationDefinition[],
|
|
300
|
+
pathname: string,
|
|
301
|
+
): PresentationRouteMatch | null {
|
|
302
|
+
const actual = routeSegments(pathname);
|
|
303
|
+
const matches = definitions.flatMap((definition) => {
|
|
304
|
+
if (definition.route === undefined) return [];
|
|
305
|
+
const pattern = routeSegments(definition.route.path);
|
|
306
|
+
if (pattern.length !== actual.length) return [];
|
|
307
|
+
const input: Record<string, string> = {};
|
|
308
|
+
for (let index = 0; index < pattern.length; index += 1) {
|
|
309
|
+
const expected = pattern[index]!;
|
|
310
|
+
const received = actual[index]!;
|
|
311
|
+
if (expected.startsWith(":")) {
|
|
312
|
+
const decoded = decodeRouteSegment(received);
|
|
313
|
+
if (decoded === null) return [];
|
|
314
|
+
input[expected.slice(1)] = decoded;
|
|
315
|
+
}
|
|
316
|
+
else if (expected !== received) return [];
|
|
317
|
+
}
|
|
318
|
+
return [{ definition, input }];
|
|
319
|
+
});
|
|
320
|
+
matches.sort((left, right) => {
|
|
321
|
+
const staticCount = (entry: PresentationRouteMatch) =>
|
|
322
|
+
routeSegments(entry.definition.route!.path).filter(
|
|
323
|
+
(segment) => !segment.startsWith(":"),
|
|
324
|
+
).length;
|
|
325
|
+
return staticCount(right) - staticCount(left);
|
|
326
|
+
});
|
|
327
|
+
return matches[0] ?? null;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export function pathForPresentationInvocation(
|
|
331
|
+
definitions: readonly PresentationDefinition[],
|
|
332
|
+
invocation: PresentationInvocation,
|
|
333
|
+
): string {
|
|
334
|
+
const definition = definitions.find(
|
|
335
|
+
(candidate) => candidate.id === invocation.presentationId,
|
|
336
|
+
);
|
|
337
|
+
if (definition?.route === undefined) {
|
|
338
|
+
throw new Error(
|
|
339
|
+
`Presentation “${invocation.presentationId}” não declara route.`,
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
return definition.route.path.replace(/:([a-z][a-z0-9]*)/g, (_, param) => {
|
|
343
|
+
const value = invocation.input[param];
|
|
344
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
345
|
+
throw new Error(
|
|
346
|
+
`A rota de “${invocation.presentationId}” exige o parâmetro “${param}”.`,
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
return encodeURIComponent(value);
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
|
|
244
353
|
export interface PresentationBindingContext {
|
|
245
354
|
route?: Readonly<Record<string, unknown>>;
|
|
246
355
|
record?: Readonly<Record<string, unknown>>;
|
|
@@ -254,7 +363,9 @@ export interface PresentationBindingContext {
|
|
|
254
363
|
export function definePresentation<const T extends PresentationDefinitionInput>(
|
|
255
364
|
definition: T,
|
|
256
365
|
): DefinedPresentation<T> {
|
|
257
|
-
return presentationSchema.parse(
|
|
366
|
+
return presentationSchema.parse(
|
|
367
|
+
definition,
|
|
368
|
+
) as unknown as DefinedPresentation<T>;
|
|
258
369
|
}
|
|
259
370
|
|
|
260
371
|
export function definePresentationInvocation(
|
|
@@ -314,6 +425,7 @@ export function applyPresentationEffects(
|
|
|
314
425
|
invocation: PresentationInvocation,
|
|
315
426
|
effects: readonly PresentationEffectInput[],
|
|
316
427
|
context: PresentationBindingContext = {},
|
|
428
|
+
definitions: readonly PresentationDefinition[] = [],
|
|
317
429
|
): PresentationTransition {
|
|
318
430
|
let current: PresentationInvocation | null = invocation;
|
|
319
431
|
const refresh: Array<string | null> = [];
|
|
@@ -340,11 +452,15 @@ export function applyPresentationEffects(
|
|
|
340
452
|
if (current === null) exit = "back";
|
|
341
453
|
continue;
|
|
342
454
|
}
|
|
455
|
+
const targetDefinition = definitions.find(
|
|
456
|
+
(definition) => definition.id === effect.presentation,
|
|
457
|
+
);
|
|
343
458
|
current = openPresentation(
|
|
344
459
|
current,
|
|
345
460
|
{
|
|
346
461
|
presentationId: effect.presentation,
|
|
347
|
-
surface:
|
|
462
|
+
surface:
|
|
463
|
+
effect.surface ?? targetDefinition?.route?.surface ?? current.surface,
|
|
348
464
|
input: resolvePresentationBindings(effect.input, context) as Record<
|
|
349
465
|
string,
|
|
350
466
|
PresentationJsonValue
|
|
@@ -404,6 +520,7 @@ export function validatePresentations(
|
|
|
404
520
|
const parsed = z.array(presentationSchema).parse(input);
|
|
405
521
|
const byId = new Map<string, PresentationDefinition>();
|
|
406
522
|
const problems: string[] = [];
|
|
523
|
+
const routePatterns = new Set<string>();
|
|
407
524
|
|
|
408
525
|
for (const presentation of parsed) {
|
|
409
526
|
if (byId.has(presentation.id))
|
|
@@ -447,7 +564,9 @@ export function validatePresentations(
|
|
|
447
564
|
bodyAction.kind !== "form" &&
|
|
448
565
|
presentation.body.submitLabel !== undefined
|
|
449
566
|
) {
|
|
450
|
-
problems.push(
|
|
567
|
+
problems.push(
|
|
568
|
+
`SubmitLabel só pode ser declarado para uma action form.`,
|
|
569
|
+
);
|
|
451
570
|
}
|
|
452
571
|
}
|
|
453
572
|
|
|
@@ -476,6 +595,44 @@ export function validatePresentations(
|
|
|
476
595
|
}
|
|
477
596
|
|
|
478
597
|
for (const presentation of parsed) {
|
|
598
|
+
if (presentation.route !== undefined) {
|
|
599
|
+
const pattern = routePattern(presentation.route.path);
|
|
600
|
+
if (routePatterns.has(pattern)) {
|
|
601
|
+
problems.push(
|
|
602
|
+
`A route “${presentation.route.path}” conflita com outra route declarada.`,
|
|
603
|
+
);
|
|
604
|
+
}
|
|
605
|
+
routePatterns.add(pattern);
|
|
606
|
+
const record = presentation.route.record;
|
|
607
|
+
if (record !== undefined) {
|
|
608
|
+
if (!presentation.route.path.includes(`:${record.param}`)) {
|
|
609
|
+
problems.push(
|
|
610
|
+
`A route de “${presentation.id}” não declara o parâmetro “${record.param}” usado por record.`,
|
|
611
|
+
);
|
|
612
|
+
}
|
|
613
|
+
const source = byId.get(record.presentation);
|
|
614
|
+
if (source === undefined) {
|
|
615
|
+
problems.push(
|
|
616
|
+
`A route de “${presentation.id}” consulta a Presentation inexistente “${record.presentation}”.`,
|
|
617
|
+
);
|
|
618
|
+
} else if (actions[source.body.action]?.kind !== "list") {
|
|
619
|
+
problems.push(
|
|
620
|
+
`A route de “${presentation.id}” exige que “${record.presentation}” tenha uma list action no body.`,
|
|
621
|
+
);
|
|
622
|
+
} else {
|
|
623
|
+
const sourceAction = actions[source.body.action];
|
|
624
|
+
const output = unwrapObjectSchema(sourceAction.output);
|
|
625
|
+
if (
|
|
626
|
+
output !== null &&
|
|
627
|
+
!(record.field in (output.shape as Record<string, z.ZodTypeAny>))
|
|
628
|
+
) {
|
|
629
|
+
problems.push(
|
|
630
|
+
`A route de “${presentation.id}” consulta o campo inexistente “${record.field}” na saída de “${source.body.action}”.`,
|
|
631
|
+
);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
}
|
|
479
636
|
const open = presentation.body.open;
|
|
480
637
|
if (open !== undefined && !byId.has(open.presentation)) {
|
|
481
638
|
problems.push(
|
|
@@ -483,6 +640,15 @@ export function validatePresentations(
|
|
|
483
640
|
);
|
|
484
641
|
} else if (open !== undefined) {
|
|
485
642
|
const target = byId.get(open.presentation);
|
|
643
|
+
if (
|
|
644
|
+
open.surface !== undefined &&
|
|
645
|
+
target?.route !== undefined &&
|
|
646
|
+
open.surface !== target.route.surface
|
|
647
|
+
) {
|
|
648
|
+
problems.push(
|
|
649
|
+
`A lista “${presentation.id}” abre “${target.id}” como ${open.surface}, mas sua route declara ${target.route.surface}.`,
|
|
650
|
+
);
|
|
651
|
+
}
|
|
486
652
|
const action =
|
|
487
653
|
target === undefined ? undefined : actions[target.body.action];
|
|
488
654
|
if (action !== undefined) {
|
|
@@ -503,6 +669,15 @@ export function validatePresentations(
|
|
|
503
669
|
);
|
|
504
670
|
continue;
|
|
505
671
|
}
|
|
672
|
+
if (
|
|
673
|
+
effect.surface !== undefined &&
|
|
674
|
+
target.route !== undefined &&
|
|
675
|
+
effect.surface !== target.route.surface
|
|
676
|
+
) {
|
|
677
|
+
problems.push(
|
|
678
|
+
`O body de “${presentation.id}” navega para “${target.id}” como ${effect.surface}, mas sua route declara ${target.route.surface}.`,
|
|
679
|
+
);
|
|
680
|
+
}
|
|
506
681
|
const action = actions[target.body.action];
|
|
507
682
|
if (action !== undefined) {
|
|
508
683
|
validateActionBindings(
|
|
@@ -522,6 +697,15 @@ export function validatePresentations(
|
|
|
522
697
|
`A action “${command.action}” navega para a Presentation inexistente “${effect.presentation}”.`,
|
|
523
698
|
);
|
|
524
699
|
} else {
|
|
700
|
+
if (
|
|
701
|
+
effect.surface !== undefined &&
|
|
702
|
+
target.route !== undefined &&
|
|
703
|
+
effect.surface !== target.route.surface
|
|
704
|
+
) {
|
|
705
|
+
problems.push(
|
|
706
|
+
`A action “${command.action}” navega para “${target.id}” como ${effect.surface}, mas sua route declara ${target.route.surface}.`,
|
|
707
|
+
);
|
|
708
|
+
}
|
|
525
709
|
const action = actions[target.body.action];
|
|
526
710
|
if (action !== undefined) {
|
|
527
711
|
validateActionBindings(
|
|
@@ -544,6 +728,14 @@ export function validatePresentations(
|
|
|
544
728
|
problems.push(
|
|
545
729
|
`A Presentation “${target.id}” usa “${target.body.action}”, mas foi aberta por “${command.action}”.`,
|
|
546
730
|
);
|
|
731
|
+
} else if (
|
|
732
|
+
command.target.surface !== undefined &&
|
|
733
|
+
target.route !== undefined &&
|
|
734
|
+
command.target.surface !== target.route.surface
|
|
735
|
+
) {
|
|
736
|
+
problems.push(
|
|
737
|
+
`A action “${command.action}” abre “${target.id}” como ${command.target.surface}, mas sua route declara ${target.route.surface}.`,
|
|
738
|
+
);
|
|
547
739
|
}
|
|
548
740
|
}
|
|
549
741
|
}
|
|
@@ -555,7 +747,11 @@ export function validatePresentations(
|
|
|
555
747
|
function unwrapObjectSchema(schema: unknown): z.ZodObject<any> | null {
|
|
556
748
|
let current = schema;
|
|
557
749
|
const visited = new Set<unknown>();
|
|
558
|
-
while (
|
|
750
|
+
while (
|
|
751
|
+
current !== null &&
|
|
752
|
+
typeof current === "object" &&
|
|
753
|
+
!visited.has(current)
|
|
754
|
+
) {
|
|
559
755
|
visited.add(current);
|
|
560
756
|
if (current instanceof z.ZodObject) return current;
|
|
561
757
|
const definition = (current as { _def?: Record<string, unknown> })._def;
|
|
@@ -583,7 +779,10 @@ function validateActionBindings(
|
|
|
583
779
|
continue;
|
|
584
780
|
}
|
|
585
781
|
const binding = bindings[key];
|
|
586
|
-
if (
|
|
782
|
+
if (
|
|
783
|
+
binding?.source === "fixed" &&
|
|
784
|
+
!field.safeParse(binding.value).success
|
|
785
|
+
) {
|
|
587
786
|
problems.push(
|
|
588
787
|
`O valor fixo de “${path}.${key}” é incompatível com o input da action “${action.name}”.`,
|
|
589
788
|
);
|
package/src/core/runtime.ts
CHANGED
|
@@ -817,6 +817,9 @@ export class Runtime {
|
|
|
817
817
|
if (cfg === null) continue
|
|
818
818
|
tools.push({
|
|
819
819
|
name: action.name,
|
|
820
|
+
...(action.label !== undefined
|
|
821
|
+
? { title: typeof action.label === 'string' ? action.label : action.label.default }
|
|
822
|
+
: {}),
|
|
820
823
|
description: cfg.description ?? action.description ?? action.name,
|
|
821
824
|
inputSchema: action.input,
|
|
822
825
|
metadata: { dataProducts: [...(this.actionDataProducts.get(action.name) ?? [])].sort() },
|
package/src/core/types.ts
CHANGED
|
@@ -1259,6 +1259,8 @@ export interface AiAdapter extends Adapter {
|
|
|
1259
1259
|
/** Uma tool exposta ao modelo: nome da action + descrição + JSON Schema do input. */
|
|
1260
1260
|
export interface AiTool {
|
|
1261
1261
|
name: string
|
|
1262
|
+
/** Nome legível da tool; preserva `name` como identificador técnico estável. */
|
|
1263
|
+
title?: string
|
|
1262
1264
|
description: string
|
|
1263
1265
|
inputSchema: unknown
|
|
1264
1266
|
/** Metadados de governança para o host; não fazem parte do prompt nem concedem acesso. */
|
package/src/mcp/index.ts
CHANGED
|
@@ -44,6 +44,7 @@ export function createOpusMcpServer(runtime: Runtime, opts: OpusMcpOptions = {})
|
|
|
44
44
|
server.setRequestHandler(ListToolsRequestSchema, () => ({
|
|
45
45
|
tools: runtime.aiTools().map((t) => ({
|
|
46
46
|
name: t.name,
|
|
47
|
+
...(t.title !== undefined ? { title: t.title } : {}),
|
|
47
48
|
description: t.description,
|
|
48
49
|
inputSchema: toJsonSchema(t.inputSchema) as { type: 'object' },
|
|
49
50
|
_meta: { 'com.softize.opus/data-products': t.metadata?.dataProducts ?? [] },
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
CardTitle,
|
|
14
14
|
} from "../primitives/card.tsx";
|
|
15
15
|
import { ActionForm, type ActionFormProps } from "./form.tsx";
|
|
16
|
+
import { ButtonGroup } from "../primitives/button-group.tsx";
|
|
16
17
|
|
|
17
18
|
export interface ActionFormCardProps<
|
|
18
19
|
TInput extends Record<string, unknown>,
|
|
@@ -46,7 +47,13 @@ export function ActionFormCard<TInput extends Record<string, unknown>, TData>({
|
|
|
46
47
|
{...rest}
|
|
47
48
|
body={(fields) => <CardBody>{fields}</CardBody>}
|
|
48
49
|
footer={(actions) => (
|
|
49
|
-
<CardFooter className="justify-end">
|
|
50
|
+
<CardFooter className="justify-end">
|
|
51
|
+
{rest.onCancel === undefined ? (
|
|
52
|
+
actions
|
|
53
|
+
) : (
|
|
54
|
+
<ButtonGroup mode="spaced">{actions}</ButtonGroup>
|
|
55
|
+
)}
|
|
56
|
+
</CardFooter>
|
|
50
57
|
)}
|
|
51
58
|
/>
|
|
52
59
|
</Card>
|