@softize/opus 17.2.0 → 18.0.1

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 (46) hide show
  1. package/CHANGELOG.md +62 -1
  2. package/bin/lib/check.mjs +212 -45
  3. package/docs/adr/0010-page-header-owns-page-chrome.md +2 -2
  4. package/docs/adr/0011-page-shell-coordinates-persistent-page-chrome.md +4 -0
  5. package/docs/adr/0013-presentation-is-a-portable-action-oriented-artifact.md +14 -2
  6. package/docs/adr/0015-action-size-follows-interaction-density.md +1 -1
  7. package/docs/adr/0016-list-collection-header-belongs-to-content.md +80 -0
  8. package/package.json +1 -1
  9. package/registry/skills/build-opus-ui/SKILL.md +30 -20
  10. package/registry/skills/build-opus-ui/references/evaluations.md +9 -3
  11. package/registry/skills/build-opus-ui/references/ui-patterns.md +30 -18
  12. package/src/core/presentation.ts +223 -24
  13. package/src/core/runtime.ts +3 -0
  14. package/src/core/types.ts +2 -0
  15. package/src/mcp/index.ts +1 -0
  16. package/src/ui/components/patterns/action-form-card.tsx +8 -1
  17. package/src/ui/components/patterns/confirm.tsx +194 -157
  18. package/src/ui/components/patterns/content-header.tsx +17 -2
  19. package/src/ui/components/patterns/form-dialog.tsx +28 -14
  20. package/src/ui/components/patterns/form.tsx +340 -222
  21. package/src/ui/components/patterns/list.tsx +43 -44
  22. package/src/ui/components/patterns/page-heading-context.tsx +34 -0
  23. package/src/ui/components/patterns/page-state.tsx +2 -0
  24. package/src/ui/components/patterns/page.tsx +165 -51
  25. package/src/ui/components/patterns/presentation.tsx +140 -84
  26. package/src/ui/components/patterns/surface-header.tsx +5 -6
  27. package/src/ui/components/patterns/trigger.tsx +113 -83
  28. package/src/ui/components/primitives/button.tsx +2 -2
  29. package/src/ui/components/primitives/chat.tsx +19 -5
  30. package/src/ui/components/primitives/control.ts +9 -3
  31. package/src/ui/components/primitives/dialog.tsx +16 -9
  32. package/src/ui/components/primitives/drawer.tsx +9 -6
  33. package/src/ui/docs/content/action-form-card.md +9 -8
  34. package/src/ui/docs/content/action-form-dialog.md +11 -12
  35. package/src/ui/docs/content/action-form.md +25 -25
  36. package/src/ui/docs/content/action-list.md +101 -70
  37. package/src/ui/docs/content/action-trigger.md +2 -2
  38. package/src/ui/docs/content/button.md +1 -1
  39. package/src/ui/docs/content/chat.md +4 -4
  40. package/src/ui/docs/content/content.md +29 -13
  41. package/src/ui/docs/content/dialog.md +27 -21
  42. package/src/ui/docs/content/drawer.md +8 -6
  43. package/src/ui/docs/content/page.md +43 -50
  44. package/src/ui/docs/content/presentation.md +39 -28
  45. package/src/ui/docs/doc-client.tsx +1 -1
  46. package/src/ui/meta.ts +4 -4
@@ -2,6 +2,7 @@
2
2
  name: build-opus-ui
3
3
  description: Constrói interface contract-driven com hooks, forms, listas, views e componentes de @softize/opus/ui. Use ao implementar ou alterar telas que consomem actions Opus.
4
4
  ---
5
+
5
6
  <!-- softize-skill-route: $model-opus-dictionary -->
6
7
 
7
8
  # Construir UI Opus
@@ -39,10 +40,15 @@ existentes, mantendo navegação observável e componentes reutilizáveis sem la
39
40
  paginam pela primitiva `Pagination`; selects inline de filtro têm largura fixa. Não reescrever
40
41
  esses defaults na tela.
41
42
  7. Tratar página, filtro, seleção e modal importante como estado navegável por URL quando
42
- o produto precisa de deep link, back/forward ou refresh.
43
- 8. Compor superfícies pela gramática estrutural do catálogo: fora de `PageShell`, `Page` contém
44
- `PageHeader` e `PageBody`; dentro dele, contém `PageIntro` e `PageBody`; `Content` contém
45
- `ContentHeader` e `ContentBody`; Card, Drawer e Pane usam seus
43
+ o produto precisa de deep link, back/forward ou refresh. Em `Presentation`, declarar `route`
44
+ no artefato e deixar o adaptador apenas conectar a spec ao router da aplicação.
45
+ 8. Compor superfícies pela gramática estrutural do catálogo: `PageHeader` declara o chrome,
46
+ `PageIntro` a introdução opcional e `PageBody` o conteúdo. Na composição explícita, declare
47
+ exatamente um heading principal com `PageTitle` no header/intro, `Content level={1}` ou um
48
+ `PageState` ativo no body;
49
+ dentro de `PageShell`, os slots de
50
+ `PageHeader` são projetados na barra. `Content` contém `ContentHeader` e `ContentBody`; Card,
51
+ Drawer e Pane usam seus
46
52
  respectivos `*Body`. Para o caso direto, usar a sintaxe abreviada de `Page` (`title`,
47
53
  `actions`) ou de `Content` (`title`, `description`, `actions` e `count`); não misturá-la com o
48
54
  header explícito. O título da página não carrega contador. Ajustar o
@@ -51,24 +57,28 @@ existentes, mantendo navegação observável e componentes reutilizáveis sem la
51
57
  9. Manter margem e posicionamento no consumidor; componente reutilizável controla apenas
52
58
  seu interior.
53
59
  10. Não definir a fonte raiz em uma biblioteca ou componente. O navegador e a aplicação são
54
- responsáveis por `font-size` em `html`; medidas escaláveis da UI usam `rem` ou a escala
55
- relativa do Tailwind. Reservar `px` a hairlines e compensações presas à geometria da borda,
56
- com justificativa e cobertura explícitas.
57
- 11. Evoluir um pattern compartilhado apenas quando a recorrência e o contrato estiverem claros.
58
- 12. Tratar tokens de superfície como pares indivisíveis no mesmo fragmento de classes:
59
- `bg-card text-card-foreground` e `bg-popover text-popover-foreground`. Não depender da
60
- igualdade atual com `--foreground`, porque o app pode sobrescrever cada par.
61
- 13. Usar diretamente a escala `rounded-*`; não criar radius por nome de componente quando
62
- `rounded-xs` a `rounded-2xl` expressam a forma. Escolher o degrau pela escala visual:
63
- detalhes e itens internos tendem a `xs`/`sm`, controles e flutuantes compactos a `md`,
64
- molduras estruturais a `lg`, e superfícies principais ou modais a `xl`. Essa associação
65
- orienta o default, não cria uma restrição semântica. Em aninhamento, evitar moldura dupla e
66
- reduzir o raio interno; em grupos conectados, remover os raios das arestas internas. Tamanho
67
- e forma permanecem eixos separados; usar `shape="pill"` quando a pílula for intencional.
68
- 14. Distinguir uma região disponível para criação ou vínculo, representada por `Empty` com
60
+ responsáveis por `font-size` em `html`; medidas escaláveis da UI usam `rem` ou a escala
61
+ relativa do Tailwind. Reservar `px` a hairlines e compensações presas à geometria da borda,
62
+ com justificativa e cobertura explícitas.
63
+ 11. Em uma página centrada em coleção, compor `Content + ActionList`: título e criação ficam em
64
+ extremos opostos do `ContentHeader`; a criação usa botão textual `default`, sem ícone, com
65
+ `Criar recurso`. Busca, filtros, atualização, estados e paginação permanecem no `ActionList`.
66
+ Reservar `toolbarActions` a operações ligadas ao recorte atual.
67
+ 12. Evoluir um pattern compartilhado apenas quando a recorrência e o contrato estiverem claros.
68
+ 13. Tratar tokens de superfície como pares indivisíveis no mesmo fragmento de classes:
69
+ `bg-card text-card-foreground` e `bg-popover text-popover-foreground`. Não depender da
70
+ igualdade atual com `--foreground`, porque o app pode sobrescrever cada par.
71
+ 14. Usar diretamente a escala `rounded-*`; não criar radius por nome de componente quando
72
+ `rounded-xs` a `rounded-2xl` expressam a forma. Escolher o degrau pela escala visual:
73
+ detalhes e itens internos tendem a `xs`/`sm`, controles e flutuantes compactos a `md`,
74
+ molduras estruturais a `lg`, e superfícies principais ou modais a `xl`. Essa associação
75
+ orienta o default, não cria uma restrição semântica. Em aninhamento, evitar moldura dupla e
76
+ reduzir o raio interno; em grupos conectados, remover os raios das arestas internas. Tamanho
77
+ e forma permanecem eixos separados; usar `shape="pill"` quando a pílula for intencional.
78
+ 15. Distinguir uma região disponível para criação ou vínculo, representada por `Empty` com
69
79
  moldura tracejada, de um resultado vazio dentro de uma estrutura existente, que preserva
70
80
  a moldura sólida dessa estrutura.
71
- 15. Testar estados de loading, vazio, erro, sucesso, permissão e interação relevante.
81
+ 16. Testar estados de loading, vazio, erro, sucesso, permissão e interação relevante.
72
82
 
73
83
  ## Verificação
74
84
 
@@ -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`, uma seção `Content > ContentHeader +
8
- ContentBody`, `ActionFilterBar` separado do renderer, `ItemGroup` para
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 `ghost` no modal.
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
- - `Page` fornece o `<main>` e o container centralizado com teto padrão de `80rem`. Fora de
8
- `PageShell`, sua forma explícita é `Page > PageHeader (PageBack? | PageNavigation?, PageTitle,
9
- PageActions?) + PageBody`; `title` e `actions` no próprio `Page` são a abreviação
10
- para o caso direto.
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 do título quando a página está em `PageShell`; fora dele, pode
14
- substituir `PageHeader` quando o conteúdo precisar começar por uma introdução.
15
- - `PageHeader` é a região de cabeçalho dentro de uma `Page` isolada e organiza navegação, título e
16
- ações na mesma linha. Em uma subpágina simples, `PageBack` recebe o destino pai explícito e aparece
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: fornece `navigation`; a `Page` descendente continua
23
- declarando `title` e `actions`. O Opus mantém a barra de `3rem`, projeta ações nela e inicia o
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
- ações somente com ícone de chrome usam `icon-sm`. O tamanho `sm` fica para ações operacionais em
30
+ ações somente com ícone de chrome usam `icon`. 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. Na forma explícita dentro do
29
- shell, use `Page > PageIntro (PageNavigation?, PageTitle, PageActions?) + PageBody`. Não monte
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. `level` preserva a hierarquia semântica
45
- do heading.
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 em forms, confirmações e
68
- modais usa `ghost`, deixando o destaque visual para a ação principal.
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
@@ -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<Input, Key extends PropertyKey, Output> =
125
- Key extends keyof Input
126
- ? undefined extends Input[Key]
127
- ? Exclude<Input[Key], undefined> | Output
128
- : Input[Key] & Output
129
- : Output;
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<Input, Key extends PropertyKey> =
139
- Key extends keyof Input
140
- ? undefined extends Input[Key]
141
- ? PresentationCommand["onSuccess"]
142
- : Input[Key] extends readonly unknown[]
143
- ? {
144
- -readonly [Index in keyof Input[Key]]: DefinedPresentationEffect<
145
- Input[Key][Index]
146
- >;
147
- }
148
- : PresentationCommand["onSuccess"]
149
- : PresentationCommand["onSuccess"];
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<Command, "input", PresentationCommand["input"]>;
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(definition) as unknown as DefinedPresentation<T>;
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: effect.surface ?? current.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(`SubmitLabel só pode ser declarado para uma action form.`);
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 (current !== null && typeof current === "object" && !visited.has(current)) {
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 (binding?.source === "fixed" && !field.safeParse(binding.value).success) {
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
  );
@@ -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">{actions}</CardFooter>
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>