@softize/opus 17.0.0 → 17.2.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,26 @@ 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.2.0 — 2026-09-12
11
+
12
+ Páginas hospedadas por `PageShell` passam a apresentar o título no início do conteúdo, em
13
+ `PageIntro`, e reservam a barra persistente para navegação e ações. O shorthand de `Page` aplica
14
+ essa anatomia automaticamente, evitando repetir a página atual ao lado do breadcrumb.
15
+
16
+ Na composição explícita dentro de `PageShell`, substitua `PageHeader` por `PageIntro`. Fora do
17
+ shell, `PageHeader` continua sendo o cabeçalho convencional da página.
18
+
19
+ ## 17.1.0 — 2026-09-12
20
+
21
+ Formulários de `Presentation` em Dialog e Drawer passam a oferecer `Cancelar` ao lado da ação
22
+ principal. O retorno no header modal aparece somente quando a surface anterior também é um Dialog
23
+ ou Drawer; a Page mantida ao fundo não cria uma etapa de navegação.
24
+
25
+ Em Page hospedada por `PageShell`, a `Presentation` mantém navegação e ações na barra e apresenta o
26
+ título no `PageIntro`. Ações que abrem outra Presentation usam o botão default para assumir a
27
+ hierarquia primária do fluxo. Ações textuais de header e footer usam o tamanho `default`; `sm` fica
28
+ reservado às operações densas de toolbar, seção e coleção.
29
+
10
30
  ## 17.0.0 — 2026-09-11
11
31
 
12
32
  A inspeção de `Presentation` passa a pertencer exclusivamente à Lens, que lê as definições estáticas
@@ -21,14 +21,13 @@ renderiza a barra, recebe a navegação conhecida pelo shell e oferece o alvo ca
21
21
  declaradas pela `Page` descendente. Essa barra existe somente por `PageShell`; `PageHeader` não
22
22
  possui variante visual para reproduzi-la dentro da página.
23
23
 
24
- Quando uma `Page` está dentro de `PageShell`, `PageHeader` continua sendo seu header estrutural.
25
- `PageTitle`, `PageNavigation`, `PageBack` e `PageActions` são projetados nos alvos da barra sem
26
- duplicar a anatomia no body. Na forma explícita, a página usa `PageHeader` e `PageBody`, com
27
- `PageIntro` opcional para contexto adicional no conteúdo. Fora de `PageShell`, `PageHeader` aparece
28
- dentro do container da página.
29
-
30
- `PageState` oculta somente `PageIntro`. A barra do `PageShell` permanece visível com navegação,
31
- título e ações mesmo quando o conteúdo carrega, falha ou está vazio. Uma ação que depende do
24
+ Quando uma `Page` está dentro de `PageShell`, `PageIntro` é seu header estrutural no conteúdo.
25
+ `PageTitle` permanece nessa região, enquanto `PageNavigation`, `PageBack` e `PageActions` são
26
+ projetados nos alvos da barra. Na forma explícita, a página usa `PageIntro` e `PageBody`, sem
27
+ `PageHeader`. Fora de `PageShell`, `PageHeader` continua dentro do container da página.
28
+
29
+ `PageState` oculta o título de `PageIntro`. A barra do `PageShell` permanece visível com navegação
30
+ e ações mesmo quando o conteúdo carrega, falha ou está vazio. Uma ação que depende do
32
31
  conteúdo deve se omitir por estado na própria rota.
33
32
 
34
33
  `PageShell` não substitui o shell completo da aplicação, não inclui sidebar e não cria navegação.
@@ -38,10 +37,9 @@ Ele apenas coordena a barra e a área em que uma única `Page` é renderizada.
38
37
 
39
38
  - shell e rota podem continuar conhecendo partes diferentes da página sem criar componentes locais
40
39
  de chrome;
41
- - título e ações têm destinos oficiais na barra;
40
+ - o título inicia o conteúdo e as ações têm destino oficial na barra;
42
41
  - estados integrais preservam a barra e escondem somente a introdução do conteúdo;
43
- - a composição de `Page` com `PageHeader` é a mesma dentro e fora de `PageShell`; apenas seu destino
44
- visual muda;
42
+ - a composição explícita usa `PageIntro` dentro do shell e `PageHeader` fora dele;
45
43
  - `PageActionsTarget` continua disponível apenas para workspaces imersivos que não usam
46
44
  `PageShell`.
47
45
 
@@ -64,7 +62,7 @@ estado do conteúdo. A moldura do shell não deve oscilar com a consulta da rota
64
62
 
65
63
  ## Verificação
66
64
 
67
- - testes cobrem a projeção de navegação, título e ações, a permanência da barra e a ocultação de `PageIntro` em estados
65
+ - testes cobrem o título no início do conteúdo, a projeção de navegação e ações, a permanência da barra e a ocultação de `PageIntro` em estados
68
66
  integrais;
69
67
  - a documentação demonstra a composição de `PageShell`, `PageIntro` e `Page` sem CSS externo;
70
68
  - consumidores removem barras paralelas e `PageActionsTarget` ao adotar o novo contrato.
@@ -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.2.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",
@@ -40,10 +40,11 @@ existentes, mantendo navegação observável e componentes reutilizáveis sem la
40
40
  esses defaults na tela.
41
41
  7. Tratar página, filtro, seleção e modal importante como estado navegável por URL quando
42
42
  o produto precisa de deep link, back/forward ou refresh.
43
- 8. Compor superfícies pela gramática estrutural do catálogo: `Page` contém `PageHeader` e
44
- `PageBody`; `Content` contém `ContentHeader` e `ContentBody`; Card, Drawer e Pane usam seus
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
45
46
  respectivos `*Body`. Para o caso direto, usar a sintaxe abreviada de `Page` (`title`,
46
- `description`, `actions`) ou de `Content` (as mesmas mais `count`); não misturá-la com o
47
+ `actions`) ou de `Content` (`title`, `description`, `actions` e `count`); não misturá-la com o
47
48
  header explícito. O título da página não carrega contador. Ajustar o
48
49
  nível do heading pela hierarquia semântica, não pelo destaque visual. O `Page` mantém seu teto
49
50
  centralizado padrão de `80rem`.
@@ -3,8 +3,9 @@
3
3
  - Dispara: “Monte a tela de edição usando a form action do Opus.”
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
- - Execução estrutural: montar uma página de relatório com `Page > PageHeader + PageBody`, uma seção
7
- `Content > ContentHeader + ContentBody`, `ActionFilterBar` separado do renderer, `ItemGroup` para
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
8
9
  uma coleção secundária e um `ActionFormDialog`; provar teto padrão de `80rem`, hierarquia por
9
10
  `level`, vazio estrutural sólido, `Empty` apenas para criação ou vínculo, números sem alinhamento
10
11
  inferido e cancelamento `ghost` no modal.
@@ -4,14 +4,14 @@
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`. Sua forma
8
- explícita é `Page > PageHeader (PageBack? | PageNavigation?, PageTitle,
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
9
  PageActions?) + PageBody`; `title` e `actions` no próprio `Page` são a abreviação
10
10
  para o caso direto.
11
11
  Não misturar as duas formas. Alterar `className` apenas quando a superfície tiver uma necessidade
12
12
  real de largura; não reconstruir esse container em cada rota.
13
- - `PageIntro` é uma região opcional do body para contexto realmente útil; não substitui nem duplica
14
- o título estrutural do `PageHeader`.
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
15
  - `PageHeader` é a região de cabeçalho dentro de uma `Page` isolada e organiza navegação, título e
16
16
  ações na mesma linha. Em uma subpágina simples, `PageBack` recebe o destino pai explícito e aparece
17
17
  antes do título como controle somente com ícone. Para mais de um ancestral relevante, use
@@ -20,11 +20,13 @@ PageActions?) + PageBody`; `title` e `actions` no próprio `Page` são a abrevia
20
20
  possuam chrome próprio.
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
- 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
26
- shell, use `Page > PageHeader (PageNavigation?, PageTitle, PageActions?) + PageBody`, com
27
- `PageIntro` opcional antes do body. Não monte
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`.
25
+ 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
27
+ 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
28
30
  `PaneHeader`, portal ou seletor global para reconstruir essa composição.
29
31
  - `PageState` substitui todo o conteúdo principal quando a página carrega, falha ou está vazia. Na
30
32
  forma curta ele pode ser escrito como filho direto de `Page`, que cria o `PageBody`; na forma
@@ -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
  )}
@@ -34,7 +34,6 @@ const PageHeaderContext = createContext<PageHeadingRegion | null>(null);
34
34
  const PageIntegralStateContext = createContext(false);
35
35
  const PageActionsTargetContext = createContext<HTMLElement | null>(null);
36
36
  const PageNavigationTargetContext = createContext<HTMLElement | null>(null);
37
- const PageTitleTargetContext = createContext<HTMLElement | null>(null);
38
37
  const PageShellContext = createContext(false);
39
38
  const pageBarClassName =
40
39
  "flex h-12 shrink-0 items-center border-b border-border bg-background text-foreground";
@@ -124,7 +123,6 @@ export function PageShell({
124
123
  );
125
124
  const [navigationTarget, setNavigationTarget] =
126
125
  useState<HTMLDivElement | null>(null);
127
- const [titleTarget, setTitleTarget] = useState<HTMLDivElement | null>(null);
128
126
 
129
127
  return (
130
128
  <div
@@ -152,20 +150,18 @@ export function PageShell({
152
150
  <PageShellNavigationSlot targetRef={setNavigationTarget}>
153
151
  {navigation}
154
152
  </PageShellNavigationSlot>
155
- <PageShellTitleSlot targetRef={setTitleTarget} />
153
+ <PageShellTitleSlot />
156
154
  <PageShellActionsSlot targetRef={setActionsTarget}>
157
155
  {actions}
158
156
  </PageShellActionsSlot>
159
157
  </SurfaceHeader>
160
158
  <PageShellContext.Provider value>
161
159
  <PageNavigationTargetContext.Provider value={navigationTarget}>
162
- <PageTitleTargetContext.Provider value={titleTarget}>
163
- <PageActionsTarget target={actionsTarget}>
164
- <div data-slot="page-shell-content" className="min-h-0 flex-1">
165
- {children}
166
- </div>
167
- </PageActionsTarget>
168
- </PageTitleTargetContext.Provider>
160
+ <PageActionsTarget target={actionsTarget}>
161
+ <div data-slot="page-shell-content" className="min-h-0 flex-1">
162
+ {children}
163
+ </div>
164
+ </PageActionsTarget>
169
165
  </PageNavigationTargetContext.Provider>
170
166
  </PageShellContext.Provider>
171
167
  </div>
@@ -254,10 +250,16 @@ export function Page({
254
250
  }, []);
255
251
  if (
256
252
  shorthand &&
257
- nodes.some((node) => isValidElement(node) && node.type === PageHeader)
253
+ nodes.some(
254
+ (node) =>
255
+ isValidElement(node) &&
256
+ [PageHeader, PageIntro, PageBody, PageFooter].includes(
257
+ node.type as typeof PageHeader,
258
+ ),
259
+ )
258
260
  ) {
259
261
  throw new Error(
260
- "Page não permite misturar propriedades de shorthand com PageHeader explícito.",
262
+ "Page não permite misturar propriedades de shorthand com composição explícita.",
261
263
  );
262
264
  }
263
265
  if (!shorthand) {
@@ -277,9 +279,9 @@ export function Page({
277
279
  headers.length + bodies.length + footers.length === nodes.length;
278
280
  const validShellComposition =
279
281
  insideShell &&
280
- headers.length === 1 &&
282
+ headers.length === 0 &&
281
283
  bodies.length === 1 &&
282
- intros.length <= 1 &&
284
+ intros.length === 1 &&
283
285
  footers.length <= 1 &&
284
286
  headers.length + intros.length + bodies.length + footers.length ===
285
287
  nodes.length;
@@ -290,7 +292,7 @@ export function Page({
290
292
  ) {
291
293
  throw new Error(
292
294
  insideShell
293
- ? "Page explícito dentro de PageShell exige um PageHeader, um PageBody e aceita no máximo um PageIntro e um PageFooter como filhos diretos."
295
+ ? "Page explícito dentro de PageShell exige um PageIntro, um PageBody e aceita no máximo um PageFooter como filhos diretos."
294
296
  : "Page explícito exige um PageHeader ou PageIntro, um PageBody e no máximo um PageFooter como filhos diretos.",
295
297
  );
296
298
  }
@@ -298,10 +300,10 @@ export function Page({
298
300
  const content =
299
301
  shorthand && insideShell ? (
300
302
  <>
301
- <PageHeader>
303
+ <PageIntro>
302
304
  <PageTitle>{title}</PageTitle>
303
305
  {actions !== undefined && <PageActions>{actions}</PageActions>}
304
- </PageHeader>
306
+ </PageIntro>
305
307
  <PageBody>{children}</PageBody>
306
308
  </>
307
309
  ) : shorthand ? (
@@ -346,7 +348,7 @@ export function Page({
346
348
  );
347
349
  }
348
350
 
349
- /** Introdução opcional do conteúdo, separada da navegação persistente do shell. */
351
+ /** Título estrutural no conteúdo de PageShell ou introdução opcional de uma Page isolada. */
350
352
  export function PageIntro({
351
353
  className,
352
354
  children,
@@ -358,12 +360,16 @@ export function PageIntro({
358
360
  requireParent(page !== null, "PageIntro", "Page");
359
361
  if (integralState) {
360
362
  if (!insideShell) return <></>;
361
- const actions = Children.toArray(children).filter(
362
- (node) => isValidElement(node) && node.type === PageActions,
363
+ const persistentSlots = Children.toArray(children).filter(
364
+ (node) =>
365
+ isValidElement(node) &&
366
+ [PageBack, PageNavigation, PageActions].includes(
367
+ node.type as typeof PageBack,
368
+ ),
363
369
  );
364
370
  return (
365
371
  <PageHeaderContext.Provider value="intro">
366
- {actions}
372
+ {persistentSlots}
367
373
  </PageHeaderContext.Provider>
368
374
  );
369
375
  }
@@ -374,9 +380,11 @@ export function PageIntro({
374
380
  name="PageIntro"
375
381
  slot="page-intro"
376
382
  slots={{
383
+ leading: [PageBack, PageNavigation],
377
384
  title: PageTitle,
378
385
  actions: PageActions,
379
386
  }}
387
+ leadingPlacement="inline"
380
388
  className={className}
381
389
  {...props}
382
390
  >
@@ -431,10 +439,8 @@ export function PageTitle({
431
439
  ...props
432
440
  }: HTMLAttributes<HTMLHeadingElement>): ReactElement {
433
441
  const variant = useContext(PageHeaderContext);
434
- const insideShell = useContext(PageShellContext);
435
- const target = useContext(PageTitleTargetContext);
436
442
  requireParent(variant !== null, "PageTitle", "PageHeader ou PageIntro");
437
- const title = (
443
+ return (
438
444
  <h1
439
445
  data-slot="page-title"
440
446
  className={cn(
@@ -446,10 +452,6 @@ export function PageTitle({
446
452
  {...props}
447
453
  />
448
454
  );
449
- if (insideShell && variant === "header") {
450
- return target === null ? <></> : createPortal(title, target);
451
- }
452
- return title;
453
455
  }
454
456
 
455
457
  export function PageActions({
@@ -495,11 +497,7 @@ export function PageNavigation({
495
497
  const variant = useContext(PageHeaderContext);
496
498
  const insideShell = useContext(PageShellContext);
497
499
  const target = useContext(PageNavigationTargetContext);
498
- requireParent(
499
- variant !== null,
500
- "PageNavigation",
501
- "PageHeader",
502
- );
500
+ requireParent(variant !== null, "PageNavigation", "PageHeader ou PageIntro");
503
501
  const navigation = (
504
502
  <div
505
503
  data-slot="page-navigation-content"
@@ -507,7 +505,7 @@ export function PageNavigation({
507
505
  {...props}
508
506
  />
509
507
  );
510
- if (insideShell && variant === "header") {
508
+ if (insideShell) {
511
509
  return target === null ? <></> : createPortal(navigation, target);
512
510
  }
513
511
  return navigation;
@@ -522,7 +520,7 @@ export function PageBack({
522
520
  const variant = useContext(PageHeaderContext);
523
521
  const insideShell = useContext(PageShellContext);
524
522
  const target = useContext(PageNavigationTargetContext);
525
- requireParent(variant !== null, "PageBack", "PageHeader");
523
+ requireParent(variant !== null, "PageBack", "PageHeader ou PageIntro");
526
524
  const destination =
527
525
  typeof children === "string" ? children : "a página anterior";
528
526
  const accessibleLabel = ariaLabel ?? `Voltar para ${destination}`;
@@ -540,7 +538,7 @@ export function PageBack({
540
538
  </a>
541
539
  </Button>
542
540
  );
543
- if (insideShell && variant === "header") {
541
+ if (insideShell) {
544
542
  return target === null ? <></> : createPortal(back, target);
545
543
  }
546
544
  return back;
@@ -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,
@@ -143,7 +148,7 @@ function PresentationFrame({
143
148
  if (insidePageShell) {
144
149
  return (
145
150
  <Page className={cn("flex min-h-full flex-col", className)}>
146
- <PageHeader>
151
+ <PageIntro>
147
152
  {navigation === undefined ? null : (
148
153
  <PageNavigation>{navigation}</PageNavigation>
149
154
  )}
@@ -153,7 +158,7 @@ function PresentationFrame({
153
158
  <PresentationActions>{headerActions}</PresentationActions>
154
159
  </PageActions>
155
160
  )}
156
- </PageHeader>
161
+ </PageIntro>
157
162
  <PageBody className="min-h-0 flex-1 overflow-y-auto">{body}</PageBody>
158
163
  {hasFooter ? <PageFooter>{renderFooterActions()}</PageFooter> : null}
159
164
  </Page>
@@ -298,6 +303,7 @@ function PresentationCommandTrigger({
298
303
  command,
299
304
  action,
300
305
  input,
306
+ size,
301
307
  disabled,
302
308
  onLoadingChange,
303
309
  onSuccess,
@@ -305,6 +311,7 @@ function PresentationCommandTrigger({
305
311
  command: PresentationCommand;
306
312
  action: SimpleContract<Record<string, unknown>, unknown>;
307
313
  input: Record<string, unknown>;
314
+ size: ButtonSize;
308
315
  disabled: boolean;
309
316
  onLoadingChange: (command: PresentationCommand, loading: boolean) => void;
310
317
  onSuccess: (data: unknown) => void;
@@ -318,6 +325,7 @@ function PresentationCommandTrigger({
318
325
  action={action}
319
326
  input={input}
320
327
  variant="ghost"
328
+ size={size}
321
329
  disabled={disabled}
322
330
  onLoadingChange={reportLoading}
323
331
  onSuccess={onSuccess}
@@ -374,6 +382,11 @@ export function Presentation({
374
382
  [context, invocation, onInvocationChange, onRefresh],
375
383
  );
376
384
 
385
+ const closeSurface = useCallback((): void => {
386
+ onOpenChange?.(false);
387
+ applyEffects([{ effect: "close" }]);
388
+ }, [applyEffects, onOpenChange]);
389
+
377
390
  const reportLoading = useCallback(
378
391
  (command: PresentationCommand, loading: boolean): void => {
379
392
  setLoadingCommands((current) => {
@@ -429,6 +442,10 @@ export function Presentation({
429
442
  string,
430
443
  unknown
431
444
  >;
445
+ // Header e footer são regiões de decisão da superfície, não toolbars operacionais.
446
+ // Seus comandos textuais usam a linha padrão; listas e grids continuam donos da
447
+ // densidade compacta de suas próprias ações.
448
+ const size: ButtonSize = "default";
432
449
  if (action.kind === "simple") {
433
450
  return (
434
451
  <PresentationCommandTrigger
@@ -436,6 +453,7 @@ export function Presentation({
436
453
  command={command}
437
454
  action={action as SimpleContract<Record<string, unknown>, unknown>}
438
455
  input={input}
456
+ size={size}
439
457
  disabled={isBlocked(command)}
440
458
  onLoadingChange={reportLoading}
441
459
  onSuccess={(data) => applyEffects(command.onSuccess, data)}
@@ -450,7 +468,7 @@ export function Presentation({
450
468
  return (
451
469
  <Button
452
470
  key={`${command.placement}:${command.action}`}
453
- variant="ghost"
471
+ size={size}
454
472
  disabled={isBlocked(command)}
455
473
  onClick={() =>
456
474
  openTarget(
@@ -480,6 +498,11 @@ export function Presentation({
480
498
  action={bodyAction as FormContract<Record<string, unknown>, unknown>}
481
499
  defaultValues={bodyInput}
482
500
  submitLabel={definition.body.submitLabel}
501
+ onCancel={
502
+ invocation.surface === "page"
503
+ ? undefined
504
+ : closeSurface
505
+ }
483
506
  onSuccess={(data) => applyEffects(definition.body.onSuccess, data)}
484
507
  disabled={hasSurfaceBlock}
485
508
  onLoadingChange={setBodyLoading}
@@ -544,8 +567,12 @@ export function Presentation({
544
567
  const footerActions = definition.actions
545
568
  .filter((command) => command.placement === "footer")
546
569
  .map(renderCommand);
570
+ const parentSurface = invocation.stack.at(-1)?.surface;
571
+ const canGoBack =
572
+ parentSurface !== undefined &&
573
+ (invocation.surface === "page" || parentSurface !== "page");
547
574
  const navigation =
548
- invocation.stack.length === 0 ? undefined : (
575
+ !canGoBack ? undefined : (
549
576
  <Button
550
577
  size="icon-sm"
551
578
  variant="ghost"
@@ -567,10 +594,11 @@ export function Presentation({
567
594
  footerActions={footerActions.length === 0 ? undefined : footerActions}
568
595
  open={open}
569
596
  onOpenChange={(nextOpen) => {
570
- onOpenChange?.(nextOpen);
571
597
  if (!nextOpen && invocation.surface !== "page") {
572
- applyEffects([{ effect: "close" }]);
598
+ closeSurface();
599
+ return;
573
600
  }
601
+ onOpenChange?.(nextOpen);
574
602
  }}
575
603
  className={className}
576
604
  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>
@@ -2,15 +2,17 @@
2
2
 
3
3
  Use `Page` para manter título, ações, estado e conteúdo na mesma anatomia. Sozinha, ela apresenta
4
4
  `PageHeader` dentro do container. Quando a aplicação possui uma barra persistente, envolva a rota
5
- com `PageShell`: o shell fornece a navegação e a página projeta título e ações na barra.
5
+ com `PageShell`: o shell fornece a navegação, a página projeta as ações na barra e o título inicia
6
+ o conteúdo.
6
7
 
7
8
  `PageHeader` mantém navegação, título e ações na mesma linha. O container é centralizado e ocupa a
8
9
  largura disponível até `80rem` (`max-w-7xl`). Use `className` somente quando a composição pedir
9
10
  outro teto ou largura total.
10
11
 
11
12
  A forma curta é o padrão para páginas comuns. Fora de `PageShell`, ela cria `PageHeader` e
12
- `PageBody`. Dentro dele, cria `PageHeader` e `PageBody`, projetando o header na barra. A forma
13
- explícita permite acrescentar um `PageIntro` opcional no conteúdo sem duplicar o título estrutural.
13
+ `PageBody`. Dentro dele, cria `PageIntro` e `PageBody`: o título fica na introdução e as ações são
14
+ projetadas na barra. A forma explícita segue a mesma anatomia, sem duplicar o título ao lado do
15
+ breadcrumb.
14
16
  Contadores e outros indicadores pertencem ao
15
17
  conteúdo que os explica.
16
18
 
@@ -64,8 +66,8 @@ posição introdutória sem transformar a trilha em ação.
64
66
  ## Barra persistente do shell
65
67
 
66
68
  Use `PageShell` quando o shell conhece o breadcrumb e a rota conhece título e ações. Não crie um
67
- `PaneHeader` paralelo nem esconda slots de `Page` com CSS. O Opus projeta `PageTitle` e
68
- `PageActions` na barra.
69
+ `PaneHeader` paralelo nem esconda slots de `Page` com CSS. O Opus mantém o `PageTitle` no início do
70
+ conteúdo e projeta somente navegação contextual e `PageActions` na barra.
69
71
 
70
72
  ```tsx preview col
71
73
  <PageShell
@@ -82,7 +84,7 @@ Use `PageShell` quando o shell conhece o breadcrumb e a rota conhece título e a
82
84
  <Page
83
85
  title="Clientes"
84
86
  actions={
85
- <Button size="sm">
87
+ <Button>
86
88
  <Plus /> Novo cliente
87
89
  </Button>
88
90
  }
@@ -95,17 +97,17 @@ Use `PageShell` quando o shell conhece o breadcrumb e a rota conhece título e a
95
97
  A barra permanece visível durante `PageState`; uma introdução opcional desaparece. Se uma ação não
96
98
  puder ser executada sem o conteúdo, a própria rota deve omiti-la naquele estado.
97
99
 
98
- Na composição explícita dentro do shell, use `PageHeader` e `PageBody`:
100
+ Na composição explícita dentro do shell, use `PageIntro` e `PageBody`:
99
101
 
100
102
  ```tsx preview col
101
103
  <PageShell navigation={<Breadcrumb>...</Breadcrumb>}>
102
104
  <Page>
103
- <PageHeader>
105
+ <PageIntro>
104
106
  <PageTitle>Clientes</PageTitle>
105
107
  <PageActions>
106
- <Button size="sm">Novo cliente</Button>
108
+ <Button>Novo cliente</Button>
107
109
  </PageActions>
108
- </PageHeader>
110
+ </PageIntro>
109
111
  <PageBody>Conteúdo da listagem.</PageBody>
110
112
  </Page>
111
113
  </PageShell>
@@ -136,8 +138,9 @@ render(
136
138
  );
137
139
  ```
138
140
 
139
- Use `PageIntro` somente quando o conteúdo precisar de uma introdução adicional. Ele não substitui
140
- o `PageHeader`, não repete o título da página e pode ser omitido.
141
+ Fora de `PageShell`, use `PageIntro` somente quando o conteúdo precisar de uma introdução em vez do
142
+ header convencional. Dentro do shell, ele é a região estrutural do título e não deve ser combinado
143
+ com `PageHeader`.
141
144
 
142
145
  ## Estados integrais
143
146
 
@@ -208,7 +211,7 @@ estado na própria seção com `DataState`, `ActionView`, `ActionList` ou `Alert
208
211
 
209
212
  | Propriedade | Tipo | Descrição |
210
213
  | ----------- | ----------------------------- | ------------------------------------------------------------- |
211
- | `children` | `ReactNode` | Conteúdo introdutório opcional do body. |
214
+ | `children` | `ReactNode` | Título e ações da página dentro de `PageShell`, ou introdução de uma página isolada. |
212
215
  | `className` | `string` | Classes adicionais da região introdutória. |
213
216
  | demais | Atributos de `HTMLDivElement` | Atributos nativos repassados à região introdutória. |
214
217
 
@@ -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
  );
package/src/ui/meta.ts CHANGED
@@ -400,7 +400,7 @@ export const componentMeta = {
400
400
  name: "page",
401
401
  ancestry: "opus",
402
402
  whenToUse:
403
- "O esqueleto de página do back-office: <main> + container centralizado com teto padrão de 80rem (`max-w-7xl`). O shorthand cobre título e ações. Quando shell e rota conhecem partes diferentes da página, PageShell mantém a barra de 3rem e recebe navegação, título e ações da Page. PageIntro é um bloco opcional de introdução no conteúdo. Fora do shell, a forma explícita usa PageHeader para o cabeçalho completo. PageActionsTarget fica restrito a workspaces imersivos sem PageShell. PageState oculta somente PageIntro; informações importantes entram no corpo como Alert ou texto. Para uma região disponível à criação ou vínculo, use Empty.",
403
+ "O esqueleto de página do back-office: <main> + container centralizado com teto padrão de 80rem (`max-w-7xl`). O shorthand cobre título e ações. Quando shell e rota conhecem partes diferentes da página, PageShell mantém a barra de 3rem, recebe a navegação e projeta as ações da Page; o título inicia o conteúdo em PageIntro. Fora do shell, a forma explícita usa PageHeader para o cabeçalho completo. PageActionsTarget fica restrito a workspaces imersivos sem PageShell. PageState oculta somente PageIntro; informações importantes entram no corpo como Alert ou texto. Para uma região disponível à criação ou vínculo, use Empty.",
404
404
  },
405
405
  presentation: {
406
406
  name: "presentation",