@softize/opus 17.0.0 → 17.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 CHANGED
@@ -7,6 +7,17 @@ Depois de qualquer bump, rode os gates (`typecheck` · `test` · `opus check` ·
7
7
  `opus copy --check` · `base copy check` · `manifest:check`) — eles apontam o que a
8
8
  mudança cobra do seu código.
9
9
 
10
+ ## 17.1.0 — 2026-09-12
11
+
12
+ Formulários de `Presentation` em Dialog e Drawer passam a oferecer `Cancelar` ao lado da ação
13
+ principal. O retorno no header modal aparece somente quando a surface anterior também é um Dialog
14
+ ou Drawer; a Page mantida ao fundo não cria uma etapa de navegação.
15
+
16
+ Em Page hospedada por `PageShell`, a `Presentation` mantém navegação e ações na barra e apresenta o
17
+ título no `PageIntro`. Ações que abrem outra Presentation usam o botão default para assumir a
18
+ hierarquia primária do fluxo. Ações textuais de header e footer usam o tamanho `default`; `sm` fica
19
+ reservado às operações densas de toolbar, seção e coleção.
20
+
10
21
  ## 17.0.0 — 2026-09-11
11
22
 
12
23
  A inspeção de `Presentation` passa a pertencer exclusivamente à Lens, que lê as definições estáticas
@@ -19,11 +19,12 @@ capacidades em uma apresentação verificável.
19
19
  `Presentation` é o artefato declarativo que descreve um recurso independentemente da superfície em
20
20
  que aparece. `Surface` escolhe `page`, `dialog` ou `drawer` no momento da renderização.
21
21
 
22
- A anatomia portátil é `Header + Body + Footer?`. O header organiza horizontalmente
23
- `Navigation? + Title + Actions?`; o body contém o recurso; o footer contém ações de conclusão.
24
- Dialog e Drawer acrescentam o fechamento ao final das ações do header. Em Page, `PageShell` hospeda
25
- o mesmo header na barra persistente; uma introdução opcional pode existir no body sem mover nem
26
- duplicar o título estrutural.
22
+ A anatomia portátil é `Header + Body + Footer?`, mas cada surface materializa seu header de acordo
23
+ com o espaço disponível. Dialog e Drawer organizam horizontalmente
24
+ `Navigation? + Title + Actions? + Close` e mantêm as ações de conclusão no footer. Em Page
25
+ hospedada por `PageShell`, a barra persistente reúne navegação e ações; o título inicia o conteúdo
26
+ em `PageIntro`, sem repetir a página atual ao lado do breadcrumb. Fora do shell, a Page materializa
27
+ seu header completo no próprio container.
27
28
 
28
29
  Uma action assume um de dois papéis:
29
30
 
@@ -48,12 +49,15 @@ estática, publicável no manifest e inspecionável pela Lens. A invocação é
48
49
  uma execução: identifica a Presentation, escolhe a surface, carrega somente input JSON e mantém a
49
50
  pilha de frames necessária para voltar. Ela não é publicada no manifest.
50
51
 
51
- `back` restaura o último frame da pilha. `close` encerra apenas dialog ou drawer e é inválido para
52
- page. `navigate` abre outra invocação por push ou substitui a atual; sincronizar esse estado com URL
53
- é responsabilidade do adaptador da aplicação quando refresh, deep link ou histórico forem
54
- necessários. O inspetor de desenvolvimento reúne definição, invocação, bindings resolvidos e
55
- diagnósticos, mascarando chaves sensíveis antes de expor o JSON. O inventário estático e seu JSON
56
- ficam disponíveis na Lens; a aplicação não adiciona um launcher flutuante ao shell.
52
+ `back` restaura o último frame da pilha. Em Dialog ou Drawer, ele é apresentado quando o frame
53
+ anterior também é modal; a Page mantida ao fundo não cria uma etapa intermediária. `close` encerra
54
+ apenas dialog ou drawer e é inválido para page. Formulários modais oferecem `Cancelar` como ação de
55
+ abandono no footer, ao lado da conclusão. `navigate` abre outra invocação por push ou substitui a
56
+ atual; sincronizar esse estado com URL é responsabilidade do adaptador da aplicação quando refresh,
57
+ deep link ou histórico forem necessários. O inspetor de desenvolvimento reúne definição,
58
+ invocação, bindings resolvidos e diagnósticos, mascarando chaves sensíveis antes de expor o JSON. O
59
+ inventário estático e seu JSON ficam disponíveis na Lens; a aplicação não adiciona um launcher
60
+ flutuante ao shell.
57
61
 
58
62
  ## Consequências
59
63
 
@@ -90,6 +94,8 @@ Foi descartada porque compatibilidade exige delegar cada kind ao pattern canôni
90
94
  - o manifest projeta Presentations sem callbacks nem dados server-only;
91
95
  - a lens lista e expõe o JSON integral de cada Presentation;
92
96
  - testes de invocação cobrem push, replace, back, close e rejeição de input não JSON;
97
+ - testes do renderer cobrem a ausência de retorno entre Page e modal, o retorno entre modais e as
98
+ ações Cancelar/concluir do formulário;
93
99
  - o inspetor mascara segredo, token, cookie, autorização, senha e chave de API;
94
100
  - a Lens lista as definições registradas e abre o JSON integral sem depender da árvore React;
95
101
  - uma aplicação consumidora usa o pacote local por symlink durante a migração página por página.
@@ -0,0 +1,64 @@
1
+ # ADR 0015 — O tamanho da ação acompanha a densidade da interação
2
+
3
+ - **Status:** aceita.
4
+ - **Data:** 2026-09-12.
5
+
6
+ ## Contexto
7
+
8
+ O uso de `default` e `sm` passou a variar entre headers de Page, footers de Dialog e Drawer,
9
+ toolbars e ações de listagem. Como tamanho também era usado para sugerir importância, duas decisões
10
+ equivalentes podiam ganhar alturas diferentes, enquanto ações repetitivas ocupavam o mesmo espaço
11
+ de uma conclusão da superfície.
12
+
13
+ O Opus já separa contexto semântico, variante visual e tamanho. Misturar esses eixos impede que a
14
+ mesma regra funcione quando uma ação muda de secundária para primária ou quando um recurso troca de
15
+ Page para Dialog ou Drawer.
16
+
17
+ ## Decisão
18
+
19
+ O tamanho comunica densidade e alcance da interação; `context` e `variant` comunicam hierarquia e
20
+ risco.
21
+
22
+ - ações textuais que decidem a superfície usam `default`: header de Page e footer de Page, Dialog
23
+ ou Drawer;
24
+ - ações operacionais em toolbar, header de seção ou coleção densa usam `sm`;
25
+ - ações internas de linha, célula ou campo usam `xs` ou `icon-xs`;
26
+ - ações somente com ícone que pertencem ao chrome da superfície, como voltar e fechar, usam
27
+ `icon-sm`;
28
+ - `lg` fica reservado a chamadas que deliberadamente precisam de uma área de toque maior, não a
29
+ uma ação primária comum.
30
+
31
+ Patterns que conhecem a região aplicam o tamanho. Componentes estruturais genéricos não clonam nem
32
+ reescrevem filhos arbitrários: em composição manual, o consumidor segue a mesma matriz. Alterar a
33
+ ênfase de uma ação não altera seu tamanho dentro da região.
34
+
35
+ ## Consequências
36
+
37
+ - Page, Dialog e Drawer mantêm proporção equivalente nas decisões principais;
38
+ - toolbars e grids preservam densidade sem reduzir ações de conclusão;
39
+ - `primary`, `neutral` e `danger` podem mudar sem provocar salto de altura;
40
+ - exemplos e skills deixam de recomendar `sm` por todo cabeçalho compacto.
41
+
42
+ ## Alternativas consideradas
43
+
44
+ ### Usar `sm` em toda barra ou header
45
+
46
+ Preservaria a menor altura possível, mas trataria uma decisão da página como operação repetitiva e
47
+ criaria diferença em relação ao footer modal do mesmo recurso.
48
+
49
+ ### Fazer a ação primária sempre maior
50
+
51
+ Reforçaria hierarquia, mas misturaria importância com densidade. A variante `solid` e o contexto
52
+ `primary` já comunicam essa relação sem deslocar o layout.
53
+
54
+ ### Fazer `ButtonGroup` redimensionar qualquer filho
55
+
56
+ Centralizaria parte da aparência, mas introduziria comportamento implícito e frágil para filhos que
57
+ não são `Button`. A região ou o pattern conhece melhor o tamanho adequado.
58
+
59
+ ## Verificação
60
+
61
+ - testes da `Presentation` conferem `default` em ações textuais do header e do footer;
62
+ - testes de `ActionList` continuam cobrindo `sm` em toolbar e `icon-xs` nas ações de linha;
63
+ - a documentação de Button e Page apresenta a mesma matriz;
64
+ - revisão de consumidor trata divergência como exceção explícita, não como novo default.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softize/opus",
3
- "version": "17.0.0",
3
+ "version": "17.1.0",
4
4
  "description": "End-to-end action protocol for TypeScript. Single package with subpath exports (core + adapters).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -21,8 +21,10 @@ PageActions?) + PageBody`; `title` e `actions` no próprio `Page` são a abrevia
21
21
  - Quando shell e rota conhecem partes diferentes da mesma página, use `PageShell` ao redor da rota.
22
22
  O shell é o único responsável pela barra: fornece `navigation`; a `Page` descendente continua
23
23
  declarando `title` e `actions`. O Opus mantém a barra de `3rem` e projeta título e ações nela.
24
- Ações com texto na barra
25
- usam `Button size="sm"`; ações somente com ícone usam `icon-sm`. Na forma explícita dentro do
24
+ Ações com texto na barra usam o tamanho `default`, como as ações de footer de Dialog e Drawer;
25
+ ações somente com ícone de chrome usam `icon-sm`. O tamanho `sm` fica para ações operacionais em
26
+ toolbar, seção ou coleção densa, e `xs`/`icon-xs` para ações internas de linha ou célula. Contexto
27
+ e variante resolvem a hierarquia visual sem alterar essa medida. Na forma explícita dentro do
26
28
  shell, use `Page > PageHeader (PageNavigation?, PageTitle, PageActions?) + PageBody`, com
27
29
  `PageIntro` opcional antes do body. Não monte
28
30
  `PaneHeader`, portal ou seletor global para reconstruir essa composição.
@@ -719,7 +719,13 @@ export function ActionForm<TInput extends Record<string, unknown>, TData>({
719
719
  {wrapFooter(
720
720
  <>
721
721
  {onCancel !== undefined && (
722
- <Button type="button" form={formId} variant="ghost" onClick={onCancel}>
722
+ <Button
723
+ type="button"
724
+ form={formId}
725
+ variant="ghost"
726
+ disabled={disabled || isLoading}
727
+ onClick={onCancel}
728
+ >
723
729
  {cancelLabel}
724
730
  </Button>
725
731
  )}
@@ -29,7 +29,11 @@ import type {
29
29
  ViewContract,
30
30
  } from "../../../core/contracts.ts";
31
31
  import { cn } from "../../lib/cn.ts";
32
- import { Button, buttonVariants } from "../primitives/button.tsx";
32
+ import {
33
+ Button,
34
+ buttonVariants,
35
+ type ButtonSize,
36
+ } from "../primitives/button.tsx";
33
37
  import { ButtonGroup } from "../primitives/button-group.tsx";
34
38
  import { Copyable } from "../primitives/copyable.tsx";
35
39
  import { DetailField, DetailGroup } from "../primitives/detail.tsx";
@@ -55,6 +59,7 @@ import {
55
59
  PageBody,
56
60
  PageFooter,
57
61
  PageHeader,
62
+ PageIntro,
58
63
  PageNavigation,
59
64
  PageTitle,
60
65
  useInsidePageShell,
@@ -147,13 +152,15 @@ function PresentationFrame({
147
152
  {navigation === undefined ? null : (
148
153
  <PageNavigation>{navigation}</PageNavigation>
149
154
  )}
150
- <PageTitle>{title}</PageTitle>
151
155
  {headerActions === undefined ? null : (
152
156
  <PageActions>
153
157
  <PresentationActions>{headerActions}</PresentationActions>
154
158
  </PageActions>
155
159
  )}
156
160
  </PageHeader>
161
+ <PageIntro>
162
+ <PageTitle>{title}</PageTitle>
163
+ </PageIntro>
157
164
  <PageBody className="min-h-0 flex-1 overflow-y-auto">{body}</PageBody>
158
165
  {hasFooter ? <PageFooter>{renderFooterActions()}</PageFooter> : null}
159
166
  </Page>
@@ -298,6 +305,7 @@ function PresentationCommandTrigger({
298
305
  command,
299
306
  action,
300
307
  input,
308
+ size,
301
309
  disabled,
302
310
  onLoadingChange,
303
311
  onSuccess,
@@ -305,6 +313,7 @@ function PresentationCommandTrigger({
305
313
  command: PresentationCommand;
306
314
  action: SimpleContract<Record<string, unknown>, unknown>;
307
315
  input: Record<string, unknown>;
316
+ size: ButtonSize;
308
317
  disabled: boolean;
309
318
  onLoadingChange: (command: PresentationCommand, loading: boolean) => void;
310
319
  onSuccess: (data: unknown) => void;
@@ -318,6 +327,7 @@ function PresentationCommandTrigger({
318
327
  action={action}
319
328
  input={input}
320
329
  variant="ghost"
330
+ size={size}
321
331
  disabled={disabled}
322
332
  onLoadingChange={reportLoading}
323
333
  onSuccess={onSuccess}
@@ -374,6 +384,11 @@ export function Presentation({
374
384
  [context, invocation, onInvocationChange, onRefresh],
375
385
  );
376
386
 
387
+ const closeSurface = useCallback((): void => {
388
+ onOpenChange?.(false);
389
+ applyEffects([{ effect: "close" }]);
390
+ }, [applyEffects, onOpenChange]);
391
+
377
392
  const reportLoading = useCallback(
378
393
  (command: PresentationCommand, loading: boolean): void => {
379
394
  setLoadingCommands((current) => {
@@ -429,6 +444,10 @@ export function Presentation({
429
444
  string,
430
445
  unknown
431
446
  >;
447
+ // Header e footer são regiões de decisão da superfície, não toolbars operacionais.
448
+ // Seus comandos textuais usam a linha padrão; listas e grids continuam donos da
449
+ // densidade compacta de suas próprias ações.
450
+ const size: ButtonSize = "default";
432
451
  if (action.kind === "simple") {
433
452
  return (
434
453
  <PresentationCommandTrigger
@@ -436,6 +455,7 @@ export function Presentation({
436
455
  command={command}
437
456
  action={action as SimpleContract<Record<string, unknown>, unknown>}
438
457
  input={input}
458
+ size={size}
439
459
  disabled={isBlocked(command)}
440
460
  onLoadingChange={reportLoading}
441
461
  onSuccess={(data) => applyEffects(command.onSuccess, data)}
@@ -450,7 +470,7 @@ export function Presentation({
450
470
  return (
451
471
  <Button
452
472
  key={`${command.placement}:${command.action}`}
453
- variant="ghost"
473
+ size={size}
454
474
  disabled={isBlocked(command)}
455
475
  onClick={() =>
456
476
  openTarget(
@@ -480,6 +500,11 @@ export function Presentation({
480
500
  action={bodyAction as FormContract<Record<string, unknown>, unknown>}
481
501
  defaultValues={bodyInput}
482
502
  submitLabel={definition.body.submitLabel}
503
+ onCancel={
504
+ invocation.surface === "page"
505
+ ? undefined
506
+ : closeSurface
507
+ }
483
508
  onSuccess={(data) => applyEffects(definition.body.onSuccess, data)}
484
509
  disabled={hasSurfaceBlock}
485
510
  onLoadingChange={setBodyLoading}
@@ -544,8 +569,12 @@ export function Presentation({
544
569
  const footerActions = definition.actions
545
570
  .filter((command) => command.placement === "footer")
546
571
  .map(renderCommand);
572
+ const parentSurface = invocation.stack.at(-1)?.surface;
573
+ const canGoBack =
574
+ parentSurface !== undefined &&
575
+ (invocation.surface === "page" || parentSurface !== "page");
547
576
  const navigation =
548
- invocation.stack.length === 0 ? undefined : (
577
+ !canGoBack ? undefined : (
549
578
  <Button
550
579
  size="icon-sm"
551
580
  variant="ghost"
@@ -567,10 +596,11 @@ export function Presentation({
567
596
  footerActions={footerActions.length === 0 ? undefined : footerActions}
568
597
  open={open}
569
598
  onOpenChange={(nextOpen) => {
570
- onOpenChange?.(nextOpen);
571
599
  if (!nextOpen && invocation.surface !== "page") {
572
- applyEffects([{ effect: "close" }]);
600
+ closeSurface();
601
+ return;
573
602
  }
603
+ onOpenChange?.(nextOpen);
574
604
  }}
575
605
  className={className}
576
606
  bodyClassName={bodyClassNameProp}
@@ -24,14 +24,20 @@ há texto visível. O glifo dentro do controle acompanha o tamanho (0.875rem em
24
24
  | Nome | Medida | Uso |
25
25
  | --------- | ------- | -------------------------------------------------------------------------------------------------------------------------------- |
26
26
  | `xs` | 1.5rem | Ação dentro de um campo (`InputGroupButton`). |
27
- | `sm` | 2rem | Toolbar e cabeçalho densos, ao lado de `Select` e `Tabs` `sm`. |
28
- | `default` | 2.25rem | A linha padrão, a mesma de `Input`. |
27
+ | `sm` | 2rem | Ação operacional em toolbar, seção ou coleção densa, ao lado de `Select` e `Tabs` `sm`. |
28
+ | `default` | 2.25rem | Decisão da superfície em header de Page ou footer de Dialog/Drawer; é também a linha padrão de `Input`. |
29
29
  | `lg` | 2.5rem | Chamada principal com mais área de toque. |
30
30
  | `icon-xs` | 1.5rem | Ação só de ícone dentro de um campo ou de uma linha densa; é o quadrado de `ActionTrigger` com `icon`. |
31
31
  | `icon-sm` | 1.75rem | Ação só de ícone em composições compactas que precisam de mais presença; também é o quadrado das setas do pager de `ActionList`. |
32
32
  | `icon` | 2.25rem | Ação só de ícone na linha padrão. |
33
33
  | `icon-lg` | 2.5rem | Ação só de ícone ao lado de um `lg`. |
34
34
 
35
+ Escolha o tamanho pela região, não pela importância visual: `variant` e `context` resolvem a
36
+ hierarquia da ação. Headers de Page e footers de Dialog/Drawer usam `default`; ações operacionais
37
+ de seção, toolbar e coleção usam `sm`; ações dentro de linha ou célula usam `xs` ou `icon-xs`.
38
+ Controles de chrome, como voltar e fechar, usam `icon-sm`. Assim a mesma decisão mantém a mesma
39
+ altura mesmo quando uma superfície troca uma ação secundária por uma primária.
40
+
35
41
  ```tsx preview
36
42
  <Button size="xs">Mínimo</Button>
37
43
  <Button size="sm">Pequeno</Button>
@@ -82,7 +82,7 @@ Use `PageShell` quando o shell conhece o breadcrumb e a rota conhece título e a
82
82
  <Page
83
83
  title="Clientes"
84
84
  actions={
85
- <Button size="sm">
85
+ <Button>
86
86
  <Plus /> Novo cliente
87
87
  </Button>
88
88
  }
@@ -103,7 +103,7 @@ Na composição explícita dentro do shell, use `PageHeader` e `PageBody`:
103
103
  <PageHeader>
104
104
  <PageTitle>Clientes</PageTitle>
105
105
  <PageActions>
106
- <Button size="sm">Novo cliente</Button>
106
+ <Button>Novo cliente</Button>
107
107
  </PageActions>
108
108
  </PageHeader>
109
109
  <PageBody>Conteúdo da listagem.</PageBody>
@@ -2,17 +2,23 @@
2
2
  um drawer. A superfície decide quanto espaço ocupar; o recurso continua com a mesma navegação,
3
3
  cabeçalho, body e rodapé.
4
4
 
5
- Nas três superfícies, o cabeçalho usa a mesma ordem horizontal: navegação ou retorno, título e ações.
6
- Dialog e Drawer acrescentam o fechamento ao final. Uma Page pode apresentar um `PageIntro` separado
7
- no body quando o conteúdo realmente precisar de uma introdução; ele não substitui nem duplica o
8
- título estrutural da Presentation.
5
+ Dialog e Drawer mantêm no cabeçalho a ordem horizontal de navegação ou retorno, título e ações.
6
+ O retorno aparece em uma surface modal somente
7
+ quando ela foi aberta sobre outro Dialog ou Drawer; a Page ao fundo sustenta o modal, mas não cria
8
+ uma etapa de navegação. Formulários modais encerram o footer com `Cancelar` e a ação principal.
9
+ Em Page hospedada por `PageShell`, a barra concentra navegação e ações, enquanto o título abre o
10
+ conteúdo em um `PageIntro`. Assim o breadcrumb não disputa espaço nem repete o título na mesma linha.
11
+ Ações textuais de header e footer usam o tamanho `default`, pois pertencem à decisão da superfície;
12
+ o tamanho `sm` permanece reservado às operações densas de toolbar, seção e coleção. Contexto e
13
+ variante continuam definindo hierarquia visual sem alterar essa medida.
9
14
 
10
15
  Use esse padrão quando uma lista puder abrir um detalhe lateral, quando a mesma edição precisar
11
16
  funcionar em modal e em rota própria ou quando um fluxo começar compacto e crescer sem ganhar uma
12
17
  segunda implementação.
13
18
 
14
19
  Dentro de `PageShell`, a superfície `page` usa a anatomia real da aplicação: a Presentation projeta
15
- navigation, title e actions no header da barra e compõe `PageBody` e, quando necessário, `PageFooter`.
20
+ navigation e actions no header da barra, apresenta o título em `PageIntro` e compõe `PageBody` e,
21
+ quando necessário, `PageFooter`.
16
22
  Fora do shell, ela materializa o `PageHeader` completo para exemplos e superfícies independentes.
17
23
  Não envolva a Presentation em um card para simular a página; valide proporção, rolagem e ações no
18
24
  shell que efetivamente hospeda a rota.
@@ -35,6 +41,7 @@ const workspacePresentation = definePresentation({
35
41
  });
36
42
 
37
43
  function Example() {
44
+ const [open, setOpen] = React.useState(true);
38
45
  const [invocation, setInvocation] = React.useState(() =>
39
46
  definePresentationInvocation({
40
47
  schemaVersion: 1,
@@ -52,7 +59,10 @@ function Example() {
52
59
  key={value}
53
60
  size="sm"
54
61
  variant={invocation.surface === value ? "solid" : "outline"}
55
- onClick={() => setInvocation({ ...invocation, surface: value })}
62
+ onClick={() => {
63
+ setInvocation({ ...invocation, surface: value });
64
+ setOpen(true);
65
+ }}
56
66
  >
57
67
  {value}
58
68
  </Button>
@@ -64,7 +74,12 @@ function Example() {
64
74
  definitions={[workspacePresentation]}
65
75
  actions={{ [workspaceUpdate.name]: workspaceUpdate }}
66
76
  invocation={invocation}
67
- onInvocationChange={(next) => next && setInvocation(next)}
77
+ open={invocation.surface === "page" || open}
78
+ onOpenChange={setOpen}
79
+ onInvocationChange={(next) => {
80
+ if (next) setInvocation(next);
81
+ else setOpen(false);
82
+ }}
68
83
  />
69
84
  </div>
70
85
  );