@softize/opus 15.0.0 → 15.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,53 @@ 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
+ ## 15.1.0 — 2026-09-10
11
+
12
+ `PageShell` passa a coordenar o chrome persistente quando shell e rota conhecem partes diferentes da
13
+ página. O shell fornece a navegação e mantém uma barra de `3rem`; a `Page` descendente continua
14
+ declarando título, descrição, ações e estados. As ações aparecem na barra sem portal montado pelo
15
+ consumidor, enquanto título e descrição formam o novo `PageIntro` dentro do conteúdo.
16
+
17
+ Em um estado integral, `PageShell` permanece visível e somente `PageIntro` é ocultado. A composição
18
+ anterior de `Page`, `PageHeader` e `PageBody` não muda fora do shell. `PageActionsTarget` continua
19
+ disponível para workspaces imersivos que já possuem uma moldura própria.
20
+
21
+ **Migração opcional:** substitua barras paralelas e seletores que escondem slots por
22
+ `PageShell navigation={...}` ao redor da rota. A página interna pode continuar na forma curta; use
23
+ `PageIntro` e `PageBody` somente quando precisar da composição explícita.
24
+
25
+ ## 15.0.1 — 2026-09-09
26
+
27
+ Correções sobre a 15.0.0, publicada horas antes, todas vindas da revisão dela. Nenhuma novidade de
28
+ API: nada entrou, saiu ou mudou de nome.
29
+
30
+ As ações de uma linha do `ActionList` só entram no `ButtonGroup` a partir de duas. Com uma só, o
31
+ grupo não é materializado: um `role="group"` sem nome por linha enchia a árvore de acessibilidade
32
+ sem informar nada, e o espaçamento não tinha o que espaçar. Fragmento devolvido pelo consumidor
33
+ conta como as ações que carrega, não como um filho só.
34
+
35
+ **Migração:** quem escreveu seletor contra o `[data-slot="button-group"]` da linha na 15.0.0
36
+ precisa mirar a célula. Com uma ação só, não há mais grupo.
37
+
38
+ A região introdutória do `PageHeader` — `PageBack` ou `PageNavigation` — passa a carregar
39
+ `data-slot="page-navigation"` também na apresentação em barra. Antes o marcador existia só no
40
+ header padrão, então um seletor de teste ou de CSS que funcionasse num não funcionava no outro.
41
+
42
+ `Page` deixa de emitir `min-h-0` e `min-h-full` ao mesmo tempo quando a barra encontra um estado
43
+ integral. O resultado dependia da ordem do CSS gerado; agora o estado integral declara a altura
44
+ que precisa e a barra só contém a rolagem quando não há estado.
45
+
46
+ `opus check` para de acrescentar um segundo diagnóstico sobre a forma de composição quando o
47
+ problema é uma propriedade removida. A prop já diz o que corrigir; a mensagem seguinte era
48
+ verdadeira e enganosa ao mesmo tempo.
49
+
50
+ A ADR 0004 ganha, no adendo, o motivo que sustenta a alternativa descartada — antes ela se apoiava
51
+ numa citação que não sustentava a conclusão — e a segunda consequência da decisão: `PageHeader`
52
+ desiste antes de validar os filhos, então um cabeçalho inválido só lança quando a página chega em
53
+ `ready` — o `opus check` continua pegando isso estaticamente. A guidance de UI e a doc de `Page`
54
+ passam a avisar que o retorno some junto com o cabeçalho e que a saída, nesse caso, é o `action` do
55
+ próprio `PageState`.
56
+
10
57
  ## 15.0.0 — 2026-09-09
11
58
 
12
59
  `PageHeader` passa a oferecer `variant="bar"`, uma apresentação compacta da mesma região de título,
package/bin/lib/check.mjs CHANGED
@@ -105,12 +105,13 @@ const UI_SURFACE_PAIRS = new Map([
105
105
  // locais homônimos.
106
106
  const UI_STRUCTURAL_PARENTS = new Map([
107
107
  ["PageHeader", new Set(["Page"])],
108
+ ["PageIntro", new Set(["Page"])],
108
109
  ["PageBody", new Set(["Page"])],
109
- ["PageTitle", new Set(["PageHeader"])],
110
+ ["PageTitle", new Set(["PageHeader", "PageIntro"])],
110
111
  ["PageBack", new Set(["PageHeader"])],
111
112
  ["PageNavigation", new Set(["PageHeader"])],
112
- ["PageDescription", new Set(["PageHeader"])],
113
- ["PageActions", new Set(["PageHeader"])],
113
+ ["PageDescription", new Set(["PageHeader", "PageIntro"])],
114
+ ["PageActions", new Set(["PageHeader", "PageIntro"])],
114
115
  ["ContentHeader", new Set(["Content"])],
115
116
  ["ContentBody", new Set(["Content"])],
116
117
  ["ContentTitle", new Set(["ContentHeader"])],
@@ -166,6 +167,7 @@ const UI_STRUCTURAL_ROOTS = new Map([
166
167
  "Page",
167
168
  {
168
169
  header: "PageHeader",
170
+ alternateHeader: "PageIntro",
169
171
  body: "PageBody",
170
172
  shorthand: new Set(["title", "description", "actions"]),
171
173
  // O contador saiu do título da página (ADR 0009). Tirar `count` do shorthand não proíbe
@@ -187,6 +189,7 @@ const UI_STRUCTURAL_ROOTS = new Map([
187
189
 
188
190
  const UI_STRICT_DIRECT_COMPONENTS = new Set([
189
191
  "PageHeader",
192
+ "PageIntro",
190
193
  "PageBody",
191
194
  "PageTitle",
192
195
  "PageBack",
@@ -204,6 +207,8 @@ const UI_STRICT_DIRECT_COMPONENTS = new Set([
204
207
  "AlertActions",
205
208
  ]);
206
209
 
210
+ const UI_STRUCTURAL_CONTAINERS = new Set(["PageShell"]);
211
+
207
212
  const UI_STRUCTURAL_HEADERS = new Map([
208
213
  [
209
214
  "PageHeader",
@@ -219,6 +224,14 @@ const UI_STRUCTURAL_HEADERS = new Map([
219
224
  shorthand: new Set(),
220
225
  },
221
226
  ],
227
+ [
228
+ "PageIntro",
229
+ {
230
+ title: "PageTitle",
231
+ optional: new Set(["PageDescription", "PageActions"]),
232
+ shorthand: new Set(),
233
+ },
234
+ ],
222
235
  [
223
236
  "ContentHeader",
224
237
  {
@@ -793,6 +806,7 @@ function structuralJsxAncestorName(node, imports) {
793
806
  if (
794
807
  imported !== undefined &&
795
808
  (UI_STRUCTURAL_ROOTS.has(imported) ||
809
+ UI_STRUCTURAL_CONTAINERS.has(imported) ||
796
810
  UI_STRUCTURAL_PARENTS.has(imported) ||
797
811
  [...UI_STRUCTURAL_PARENTS.values()].some((parents) =>
798
812
  parents.has(imported),
@@ -805,6 +819,18 @@ function structuralJsxAncestorName(node, imports) {
805
819
  return null;
806
820
  }
807
821
 
822
+ function hasJsxAncestor(node, imports, component) {
823
+ let current = node.parent;
824
+ while (current !== undefined) {
825
+ if (ts.isJsxElement(current) || ts.isJsxSelfClosingElement(current)) {
826
+ const local = jsxLocalName(current);
827
+ if (local !== null && imports.get(local) === component) return true;
828
+ }
829
+ current = current.parent;
830
+ }
831
+ return false;
832
+ }
833
+
808
834
  function directStructuralChildren(node, imports) {
809
835
  if (!ts.isJsxElement(node)) return { names: [], hasOpaque: false };
810
836
  const names = [];
@@ -950,8 +976,10 @@ export function checkUiStructure(file, text) {
950
976
  : [],
951
977
  ),
952
978
  );
979
+ let removedProp = false;
953
980
  for (const [attribute, hint] of root.removed ?? []) {
954
981
  if (attributes.has(attribute)) {
982
+ removedProp = true;
955
983
  add(
956
984
  node,
957
985
  component,
@@ -964,25 +992,40 @@ export function checkUiStructure(file, text) {
964
992
  attributes.has(attribute),
965
993
  );
966
994
  const children = directStructuralChildren(node, imports);
995
+ const insidePageShell =
996
+ component === "Page" && hasJsxAncestor(node, imports, "PageShell");
997
+ const acceptedHeaders = (
998
+ insidePageShell
999
+ ? [root.alternateHeader]
1000
+ : [root.header, root.alternateHeader]
1001
+ ).filter(Boolean);
967
1002
  const structural =
968
- children.names.includes(root.header) ||
1003
+ acceptedHeaders.some((header) => children.names.includes(header)) ||
969
1004
  children.names.includes(root.body);
970
- if (shorthand && structural) {
1005
+ // A prop removida saiu do `shorthand`, então sozinha ela não marca o nó como forma curta:
1006
+ // ele cai no ramo explícito, que cobra header e body — uma segunda mensagem inteiramente
1007
+ // induzida pela prop. Sem filhos estruturais essa cobrança dispara de qualquer jeito, e
1008
+ // some aqui.
1009
+ // Com filhos estruturais, o que as regras de forma acham é defeito independente: misturar
1010
+ // shorthand com slots, ou um filho inesperado, não some quando a prop sai.
1011
+ if (removedProp && !structural) {
1012
+ // Nada a acrescentar; os filhos seguem sendo visitados no fim de `visit`.
1013
+ } else if (shorthand && structural) {
971
1014
  add(
972
1015
  node,
973
1016
  component,
974
- `${component} não permite misturar propriedades de shorthand com ${root.header}/${root.body}.`,
1017
+ `${component} não permite misturar propriedades de shorthand com ${acceptedHeaders.join("/")}/${root.body}.`,
975
1018
  "ui-structure-mode",
976
1019
  );
977
1020
  } else if (!shorthand) {
978
- const headers = children.names.filter(
979
- (name) => name === root.header,
1021
+ const headers = children.names.filter((name) =>
1022
+ acceptedHeaders.includes(name),
980
1023
  ).length;
981
1024
  const bodies = children.names.filter(
982
1025
  (name) => name === root.body,
983
1026
  ).length;
984
1027
  const unexpected = children.names.some(
985
- (name) => name !== root.header && name !== root.body,
1028
+ (name) => !acceptedHeaders.includes(name) && name !== root.body,
986
1029
  );
987
1030
  if (
988
1031
  headers !== 1 ||
@@ -993,7 +1036,7 @@ export function checkUiStructure(file, text) {
993
1036
  add(
994
1037
  node,
995
1038
  component,
996
- `${component} explícito exige exatamente um ${root.header} e um ${root.body} como filhos diretos.`,
1039
+ `${component} explícito exige exatamente um ${acceptedHeaders.join(" ou ")} e um ${root.body} como filhos diretos.`,
997
1040
  );
998
1041
  }
999
1042
  }
@@ -4,6 +4,9 @@
4
4
  > estados. Ela foi invertida: `PageState` passa a ocultá-lo em `loading`, `error` e `empty`, e a
5
5
  > restaurá-lo em `ready`. A lista de responsabilidades abaixo já descreve o comportamento novo; o
6
6
  > adendo no fim deste arquivo registra o motivo, a alternativa descartada e o que se perde.
7
+ >
8
+ > **Atualização (2026-09-10).** Dentro de `PageShell`, o estado integral oculta somente
9
+ > `PageIntro`. A barra pertence à moldura persistente e continua visível (ADR 0011).
7
10
 
8
11
  ## Contexto
9
12
 
@@ -90,10 +93,17 @@ superfície de estado carrega `role="heading"` com `aria-level={1}`.
90
93
 
91
94
  **Alternativa descartada.** Ocultar apenas título, descrição e ações, preservando a região
92
95
  introdutória. Foi descartada para esta versão porque partiria o cabeçalho em duas regras de
93
- visibilidade e deixaria a barra materializada para um botão, contrariando "um header sem
94
- conteúdo útil não é materializado" da ADR 0010.
96
+ visibilidade uma para o retorno, outra para o resto —, e um cabeçalho que aparece pela metade
97
+ é mais difícil de prever do que um que some inteiro. Não é uma decisão confortável: ver
98
+ "O que se perde".
95
99
 
96
100
  **O que se perde, e é conhecido.** Some junto o `PageBack`, então uma subpágina em erro fica sem o
97
101
  retorno in-page para o pai — justamente quando a pessoa mais precisa sair. Enquanto esta ADR não
98
102
  for revista, uma subpágina que dependa desse retorno oferece a saída pelo `action` do próprio
99
103
  `PageState`. Reavaliar se o custo aparecer em uso real.
104
+
105
+ Uma segunda consequência é da mesma decisão: como `PageHeader` desiste antes de validar seus
106
+ filhos, um cabeçalho estruturalmente inválido — dois `PageBack`, ou sem `PageTitle` — deixa de
107
+ lançar enquanto a página está em estado integral, e só lança quando ela chega em `ready`. O
108
+ `opus check` continua pegando isso estaticamente, então o erro não passa despercebido até
109
+ produção; o que muda é o momento em que aparece no desenvolvimento.
@@ -4,6 +4,10 @@
4
4
  - **Data:** 2026-09-09.
5
5
  - **Complementa:** ADR 0005.
6
6
 
7
+ > **Atualização (2026-09-10).** A ADR 0011 acrescenta `PageShell` para o caso em que shell e rota
8
+ > conhecem partes diferentes da mesma página. A barra continua sendo a apresentação compacta do
9
+ > cabeçalho, mas o título do conteúdo passa a `PageIntro` e as ações são coordenadas pelo Opus.
10
+
7
11
  ## Contexto
8
12
 
9
13
  Aplicações com sidebar passaram a montar uma barra superior separada de `PageHeader` para reunir
@@ -0,0 +1,70 @@
1
+ # ADR 0011 — PageShell coordena o chrome persistente da página
2
+
3
+ - **Status:** aceita.
4
+ - **Data:** 2026-09-10.
5
+ - **Revisa:** ADR 0010.
6
+
7
+ ## Contexto
8
+
9
+ A ADR 0010 reuniu título, navegação e ações em `PageHeader`, mas partiu do pressuposto de que uma
10
+ única camada conhece todos esses elementos. Em aplicações com navegação persistente, o shell conhece
11
+ o breadcrumb e a moldura enquanto a rota conhece o título, as ações e o estado do conteúdo.
12
+
13
+ Sem um contrato para essa divisão, consumidores criam uma barra com `PaneHeader`, projetam ações
14
+ manualmente e escondem partes do `PageHeader` com CSS. O resultado parece correto, mas mantém duas
15
+ anatomias concorrentes e faz um estado integral remover também a navegação persistente do shell.
16
+
17
+ ## Decisão
18
+
19
+ `PageShell` representa a moldura persistente de uma página dentro de um shell de aplicação. Ele
20
+ renderiza a apresentação em barra de `PageHeader`, recebe a navegação conhecida pelo shell e oferece
21
+ o alvo canônico para ações declaradas pela `Page` descendente.
22
+
23
+ Quando uma `Page` está dentro de `PageShell`, sua forma curta transforma título e descrição em
24
+ `PageIntro`, dentro do conteúdo. `PageActions` continua declarado pela página, mas aparece na barra.
25
+ Na forma explícita, a página usa `PageIntro` e `PageBody`. Fora de `PageShell`, a composição anterior
26
+ com `PageHeader` continua válida; `PageIntro` também pode ser escolhido quando o conteúdo precisa de
27
+ uma introdução sem navegação própria. As duas regiões não são equivalentes: `PageHeader` reúne o
28
+ cabeçalho completo, enquanto `PageIntro` dá mais presença ao título dentro do conteúdo.
29
+
30
+ `PageState` oculta somente `PageIntro`. A barra do `PageShell` permanece visível porque preserva
31
+ navegação e ações globais mesmo quando o conteúdo carrega, falha ou está vazio. Uma ação que depende
32
+ do conteúdo deve se omitir por estado na própria rota.
33
+
34
+ `PageShell` não substitui o shell completo da aplicação, não inclui sidebar e não cria navegação.
35
+ Ele apenas coordena a barra e a área em que uma única `Page` é renderizada.
36
+
37
+ ## Consequências
38
+
39
+ - shell e rota podem continuar conhecendo partes diferentes da página sem criar componentes locais
40
+ de chrome;
41
+ - título e descrição deixam de ser mascarados por seletores globais;
42
+ - ações de página têm um único destino oficial na barra;
43
+ - estados integrais preservam a barra e escondem somente a introdução do conteúdo;
44
+ - a composição anterior de `Page` permanece compatível fora de `PageShell`;
45
+ - `PageActionsTarget` continua disponível apenas para workspaces imersivos que não usam
46
+ `PageShell`.
47
+
48
+ ## Alternativas consideradas
49
+
50
+ ### Passar breadcrumb a cada rota
51
+
52
+ Manteria toda a anatomia dentro de `Page`, mas faria cada página receber e repassar contexto que já
53
+ pertence ao shell. Também duplicaria essa infraestrutura em seções com navegação própria.
54
+
55
+ ### Tornar o portal local um padrão da aplicação
56
+
57
+ Resolveria somente o GrandBrasil e deixaria o design system sem contrato para a mesma divisão de
58
+ responsabilidade em outros consumidores.
59
+
60
+ ### Fazer a barra desaparecer em estados integrais
61
+
62
+ Repetiria o comportamento do header embutido, mas removeria navegação persistente por causa do
63
+ estado do conteúdo. A moldura do shell não deve oscilar com a consulta da rota.
64
+
65
+ ## Verificação
66
+
67
+ - testes cobrem a projeção das ações, a permanência da barra e a ocultação de `PageIntro` em estados
68
+ integrais;
69
+ - a documentação demonstra a composição de `PageShell`, `PageIntro` e `Page` sem CSS externo;
70
+ - consumidores removem barras paralelas e `PageActionsTarget` ao adotar o novo contrato.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softize/opus",
3
- "version": "15.0.0",
3
+ "version": "15.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",
@@ -10,6 +10,9 @@
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
+ - Fora de `PageShell`, a composição explícita também pode trocar `PageHeader` por `PageIntro` quando
14
+ o conteúdo precisar somente de título, descrição e ações, sem retorno ou breadcrumb. `PageIntro`
15
+ dá mais presença ao título e não é uma abreviação visual de `PageHeader`.
13
16
  - `PageHeader` é a única região de cabeçalho da página. O default acompanha o container;
14
17
  `variant="bar"` apresenta a mesma anatomia como faixa compacta no topo. Em uma subpágina simples,
15
18
  `PageBack` recebe o destino pai explícito: aparece acima do título no default e como icon-only com
@@ -17,14 +20,22 @@
17
20
  `PageNavigation`. Não combine retorno e breadcrumb nem crie um chrome paralelo para uma `Page`.
18
21
  Ações com texto na barra usam `Button size="sm"`; ações somente com ícone usam `icon-sm`.
19
22
  `PageActionsTarget` fica reservado a workspaces imersivos que já possuam chrome próprio.
23
+ - Quando shell e rota conhecem partes diferentes da mesma página, use `PageShell` ao redor da rota.
24
+ O shell fornece `navigation`; a `Page` descendente continua declarando `title`, `description` e
25
+ `actions`. O Opus mantém a barra de `3rem`, projeta as ações nela e apresenta título e descrição
26
+ como `PageIntro` no conteúdo. Na forma explícita dentro do shell, use
27
+ `Page > PageIntro (PageTitle, PageDescription?, PageActions?) + PageBody`. Não monte `PaneHeader`,
28
+ portal ou seletor global para reconstruir essa composição.
20
29
  - `PageState` substitui todo o conteúdo principal quando a página carrega, falha ou está vazia. Na
21
30
  forma curta ele pode ser escrito como filho direto de `Page`, que cria o `PageBody`; na forma
22
- explícita, fica sozinho dentro de `PageBody`. Nos três estados ativos, o cabeçalho some, o estado
23
- ocupa a área disponível e seu título assume o heading principal, inclusive quando um componente
24
- intermediário renderiza o estado. O erro mantém `role="alert"`, usa a mesma composição central e
25
- sem moldura dos demais estados e apresenta a recuperação como botão `outline` textual. Estados de
26
- seção ou coleção continuam em `DataState`, `ActionView`, `ActionList` ou `Alert`; não elevar uma
27
- falha parcial a estado da página.
31
+ explícita, fica sozinho dentro de `PageBody`. Nos três estados ativos, o cabeçalho da Page isolada
32
+ some inteiro incluindo o `PageBack`; dentro de `PageShell`, somente `PageIntro` some e a barra
33
+ permanece. O estado ocupa a área disponível e seu título assume o heading
34
+ principal, inclusive quando um componente intermediário renderiza o estado. Uma subpágina que
35
+ dependa do retorno ao pai oferece essa saída pelo `action` do próprio `PageState`. O erro mantém
36
+ `role="alert"`, usa a mesma composição central e sem moldura dos demais estados e apresenta a
37
+ recuperação como botão `outline` textual. Estados de seção ou coleção continuam em `DataState`,
38
+ `ActionView`, `ActionList` ou `Alert`; não elevar uma falha parcial a estado da página.
28
39
  - `Content` delimita uma seção e segue a mesma anatomia: `Content > ContentHeader (ContentTitle,
29
40
  ContentDescription, ContentMeta, ContentActions) + ContentBody`. `ContentHeader` nunca fica
30
41
  solto. `title`, `description`, `count` e `actions` no `Content` são a abreviação para o caso
@@ -20,6 +20,9 @@
20
20
  */
21
21
 
22
22
  import {
23
+ Children,
24
+ Fragment,
25
+ isValidElement,
23
26
  useEffect,
24
27
  useLayoutEffect,
25
28
  useMemo,
@@ -1142,6 +1145,31 @@ export function ActionFilterBar({
1142
1145
  // ActionList
1143
1146
  // =============================================================================
1144
1147
 
1148
+ /**
1149
+ * Ações de uma linha da tabela. Com duas ou mais, agrupa e espaça; com uma só, não
1150
+ * materializa o `ButtonGroup` — um `role="group"` sem nome por linha enche a árvore de
1151
+ * acessibilidade sem informar nada, e `mode="spaced"` não teria o que espaçar.
1152
+ */
1153
+ function RowActions({ children }: { children: ReactNode }) {
1154
+ // O consumidor costuma devolver as ações dentro de um fragment, que conta como UM filho —
1155
+ // e às vezes dentro de fragments aninhados. Expandir recursivamente, e contar só elementos,
1156
+ // evita tanto tratar duas ações como uma quanto deixar um espaço em branco virar ação.
1157
+ const expand = (node: ReactNode): ReactNode[] =>
1158
+ Children.toArray(node).flatMap((child) =>
1159
+ isValidElement(child) && child.type === Fragment
1160
+ ? expand((child.props as { children?: ReactNode }).children)
1161
+ : [child],
1162
+ );
1163
+ const alone = expand(children).filter(isValidElement).length < 2;
1164
+ return alone ? (
1165
+ <div className="flex justify-end">{children}</div>
1166
+ ) : (
1167
+ <ButtonGroup mode="spaced" className="ml-auto">
1168
+ {children}
1169
+ </ButtonGroup>
1170
+ );
1171
+ }
1172
+
1145
1173
  export function ActionList<TInput, TItem>({
1146
1174
  action,
1147
1175
  input,
@@ -1634,9 +1662,7 @@ export function ActionList<TInput, TItem>({
1634
1662
  className="w-px whitespace-nowrap text-right"
1635
1663
  onClick={(e) => e.stopPropagation()}
1636
1664
  >
1637
- <ButtonGroup mode="spaced" className="ml-auto">
1638
- {rowActions(item)}
1639
- </ButtonGroup>
1665
+ <RowActions>{rowActions(item)}</RowActions>
1640
1666
  </TableCell>
1641
1667
  )}
1642
1668
  </TableRow>
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * <Page /> — esqueleto composto de página do back-office.
3
3
  *
4
- * A forma curta cobre o caso comum; a forma explícita expõe a mesma anatomia para composições
5
- * especiais. Ambas preservam o container centralizado com teto de 80rem.
4
+ * A forma curta cobre o caso comum; PageShell coordena a barra persistente quando shell e rota
5
+ * conhecem partes diferentes. Todas preservam o container centralizado com teto de 80rem.
6
6
  */
7
7
 
8
8
  import {
@@ -39,9 +39,114 @@ interface PageContextValue {
39
39
  }
40
40
 
41
41
  const PageContext = createContext<PageContextValue | null>(null);
42
- const PageHeaderContext = createContext<PageHeaderVariant | null>(null);
42
+ type PageHeadingRegion = PageHeaderVariant | "intro";
43
+
44
+ const PageHeaderContext = createContext<PageHeadingRegion | null>(null);
43
45
  const PageIntegralStateContext = createContext(false);
44
46
  const PageActionsTargetContext = createContext<HTMLElement | null>(null);
47
+ const PageShellContext = createContext(false);
48
+ const pageBarClassName =
49
+ "flex h-12 shrink-0 items-center border-b border-border bg-background text-foreground";
50
+ const pageBarContentClassName = "w-full py-2";
51
+
52
+ function PageShellNavigationSlot({
53
+ className,
54
+ ...props
55
+ }: HTMLAttributes<HTMLDivElement>): ReactElement {
56
+ return (
57
+ <div
58
+ data-slot="page-shell-navigation"
59
+ className={cn("min-w-0 flex-1 overflow-hidden", className)}
60
+ {...props}
61
+ />
62
+ );
63
+ }
64
+
65
+ function PageShellActionsSlot({
66
+ targetRef,
67
+ className,
68
+ ...props
69
+ }: HTMLAttributes<HTMLDivElement> & {
70
+ targetRef: (target: HTMLDivElement | null) => void;
71
+ }): ReactElement {
72
+ return (
73
+ <div
74
+ ref={targetRef}
75
+ data-slot="page-shell-actions"
76
+ className={cn("flex shrink-0 items-center gap-2", className)}
77
+ {...props}
78
+ />
79
+ );
80
+ }
81
+
82
+ export interface PageShellProps extends Omit<
83
+ HTMLAttributes<HTMLDivElement>,
84
+ "children"
85
+ > {
86
+ /** Navegação contextual conhecida pelo shell, normalmente um Breadcrumb. */
87
+ navigation?: ReactNode;
88
+ /** Ações do shell ou do recurso que antecedem as ações declaradas pela Page. */
89
+ actions?: ReactNode;
90
+ /** Uma Page descendente, diretamente ou através da rota ativa. */
91
+ children: ReactNode;
92
+ /** Classes do container interno da barra. */
93
+ headerClassName?: string;
94
+ }
95
+
96
+ /** Moldura persistente que coordena navegação do shell e ações da Page ativa. */
97
+ export function PageShell({
98
+ navigation,
99
+ actions,
100
+ children,
101
+ className,
102
+ headerClassName,
103
+ ...props
104
+ }: PageShellProps): ReactElement {
105
+ const [actionsTarget, setActionsTarget] = useState<HTMLDivElement | null>(null);
106
+
107
+ return (
108
+ <div
109
+ data-slot="page-shell"
110
+ className={cn(
111
+ "relative flex h-full min-h-0 min-w-0 flex-1 flex-col",
112
+ className,
113
+ )}
114
+ {...props}
115
+ >
116
+ <SurfaceHeader
117
+ name="PageShell"
118
+ slot="page"
119
+ slots={{
120
+ leading: PageShellNavigationSlot,
121
+ title: PageTitle,
122
+ description: PageDescription,
123
+ actions: PageShellActionsSlot,
124
+ }}
125
+ leadingPlacement="inline"
126
+ titleRequired={false}
127
+ contentClassName={cn(
128
+ pageBarContentClassName,
129
+ "px-3",
130
+ headerClassName,
131
+ )}
132
+ className={pageBarClassName}
133
+ data-variant="bar"
134
+ >
135
+ <PageShellNavigationSlot>{navigation}</PageShellNavigationSlot>
136
+ <PageShellActionsSlot targetRef={setActionsTarget}>
137
+ {actions}
138
+ </PageShellActionsSlot>
139
+ </SurfaceHeader>
140
+ <PageShellContext.Provider value>
141
+ <PageActionsTarget target={actionsTarget}>
142
+ <div data-slot="page-shell-content" className="min-h-0 flex-1">
143
+ {children}
144
+ </div>
145
+ </PageActionsTarget>
146
+ </PageShellContext.Provider>
147
+ </div>
148
+ );
149
+ }
45
150
 
46
151
  export function PageActionsTarget({
47
152
  target,
@@ -97,6 +202,7 @@ export function Page({
97
202
  children,
98
203
  ...props
99
204
  }: PageProps): ReactElement {
205
+ const insideShell = useContext(PageShellContext);
100
206
  const shorthand = title !== undefined;
101
207
  const nodes = Children.toArray(children);
102
208
  const headers = nodes.filter(
@@ -105,6 +211,9 @@ export function Page({
105
211
  const bodies = nodes.filter(
106
212
  (node) => isValidElement(node) && node.type === PageBody,
107
213
  );
214
+ const intros = nodes.filter(
215
+ (node) => isValidElement(node) && node.type === PageIntro,
216
+ );
108
217
  const activeStateCount = useRef(0);
109
218
  const [integralState, setIntegralState] = useState(false);
110
219
  const registerIntegralState = useCallback(() => {
@@ -127,13 +236,22 @@ export function Page({
127
236
  );
128
237
  }
129
238
  if (!shorthand) {
130
- if (
131
- headers.length !== 1 ||
132
- bodies.length !== 1 ||
133
- headers.length + bodies.length !== nodes.length
134
- ) {
239
+ const validIntroComposition =
240
+ intros.length === 1 &&
241
+ headers.length === 0 &&
242
+ bodies.length === 1 &&
243
+ intros.length + bodies.length === nodes.length;
244
+ const validStandaloneComposition =
245
+ !insideShell &&
246
+ headers.length === 1 &&
247
+ intros.length === 0 &&
248
+ bodies.length === 1 &&
249
+ headers.length + bodies.length === nodes.length;
250
+ if (!validIntroComposition && !validStandaloneComposition) {
135
251
  throw new Error(
136
- "Page explícito exige exatamente um PageHeader e um PageBody como filhos diretos.",
252
+ insideShell
253
+ ? "Page explícito dentro de PageShell exige exatamente um PageIntro e um PageBody como filhos diretos."
254
+ : "Page explícito exige exatamente um PageHeader ou PageIntro e um PageBody como filhos diretos.",
137
255
  );
138
256
  }
139
257
  }
@@ -146,7 +264,18 @@ export function Page({
146
264
  containerClassName: className,
147
265
  };
148
266
 
149
- const content = shorthand ? (
267
+ const content = shorthand && insideShell ? (
268
+ <>
269
+ <PageIntro>
270
+ <PageTitle>{title}</PageTitle>
271
+ {description !== undefined && (
272
+ <PageDescription>{description}</PageDescription>
273
+ )}
274
+ {actions !== undefined && <PageActions>{actions}</PageActions>}
275
+ </PageIntro>
276
+ <PageBody>{children}</PageBody>
277
+ </>
278
+ ) : shorthand ? (
150
279
  <>
151
280
  <PageHeader>
152
281
  <PageTitle>{title}</PageTitle>
@@ -169,8 +298,12 @@ export function Page({
169
298
  data-slot="page"
170
299
  className={cn(
171
300
  "min-w-0 flex-1",
172
- headerVariant === "bar" && "flex min-h-0 flex-col",
173
- integralState && "flex min-h-full flex-col",
301
+ insideShell && "min-h-full",
302
+ (headerVariant === "bar" || integralState) && "flex flex-col",
303
+ // A barra contém a rolagem (`min-h-0`); o estado integral ocupa a altura
304
+ // disponível (`min-h-full`). Emitir os dois juntos deixava o resultado por
305
+ // conta da ordem do CSS gerado — no estado integral, quem manda é ele.
306
+ integralState ? "min-h-full" : headerVariant === "bar" && "min-h-0",
174
307
  )}
175
308
  {...props}
176
309
  >
@@ -195,6 +328,47 @@ export function Page({
195
328
  );
196
329
  }
197
330
 
331
+ /** Introdução opcional do conteúdo, separada da navegação persistente do shell. */
332
+ export function PageIntro({
333
+ className,
334
+ children,
335
+ ...props
336
+ }: HTMLAttributes<HTMLDivElement>): ReactElement {
337
+ const page = useContext(PageContext);
338
+ const insideShell = useContext(PageShellContext);
339
+ const integralState = useContext(PageIntegralStateContext);
340
+ requireParent(page !== null, "PageIntro", "Page");
341
+ if (integralState) {
342
+ if (!insideShell) return <></>;
343
+ const actions = Children.toArray(children).filter(
344
+ (node) => isValidElement(node) && node.type === PageActions,
345
+ );
346
+ return (
347
+ <PageHeaderContext.Provider value="intro">
348
+ {actions}
349
+ </PageHeaderContext.Provider>
350
+ );
351
+ }
352
+
353
+ return (
354
+ <PageHeaderContext.Provider value="intro">
355
+ <SurfaceHeader
356
+ name="PageIntro"
357
+ slot="page-intro"
358
+ slots={{
359
+ title: PageTitle,
360
+ description: PageDescription,
361
+ actions: PageActions,
362
+ }}
363
+ className={className}
364
+ {...props}
365
+ >
366
+ {children}
367
+ </SurfaceHeader>
368
+ </PageHeaderContext.Provider>
369
+ );
370
+ }
371
+
198
372
  /** A anatomia é a de `SurfaceHeader`, a mesma de `ContentHeader`; só os slots mudam de nome. */
199
373
  export interface PageHeaderProps extends HTMLAttributes<HTMLDivElement> {
200
374
  /** `default` fica no container; `bar` cria uma faixa compacta no topo da Page. */
@@ -225,12 +399,15 @@ export function PageHeader({
225
399
  leadingPlacement={variant === "bar" ? "inline" : "above"}
226
400
  contentClassName={
227
401
  variant === "bar"
228
- ? cn("mx-auto w-full max-w-7xl px-8 py-2", page?.containerClassName)
402
+ ? cn(
403
+ pageBarContentClassName,
404
+ "mx-auto max-w-7xl px-8",
405
+ page?.containerClassName,
406
+ )
229
407
  : undefined
230
408
  }
231
409
  className={cn(
232
- variant === "bar" &&
233
- "shrink-0 border-b border-border bg-background text-foreground",
410
+ variant === "bar" && pageBarClassName,
234
411
  className,
235
412
  )}
236
413
  {...props}
@@ -246,14 +423,16 @@ export function PageTitle({
246
423
  ...props
247
424
  }: HTMLAttributes<HTMLHeadingElement>): ReactElement {
248
425
  const variant = useContext(PageHeaderContext);
249
- requireParent(variant !== null, "PageTitle", "PageHeader");
426
+ requireParent(variant !== null, "PageTitle", "PageHeader ou PageIntro");
250
427
  return (
251
428
  <h1
252
429
  data-slot="page-title"
253
430
  className={cn(
254
431
  variant === "bar"
255
432
  ? "truncate text-sm font-semibold"
256
- : surfaceHeaderClasses.page.title,
433
+ : variant === "intro"
434
+ ? "text-3xl font-semibold tracking-tight"
435
+ : surfaceHeaderClasses.page.title,
257
436
  className,
258
437
  )}
259
438
  {...props}
@@ -266,7 +445,11 @@ export function PageDescription({
266
445
  ...props
267
446
  }: HTMLAttributes<HTMLParagraphElement>): ReactElement {
268
447
  const variant = useContext(PageHeaderContext);
269
- requireParent(variant !== null, "PageDescription", "PageHeader");
448
+ requireParent(
449
+ variant !== null,
450
+ "PageDescription",
451
+ "PageHeader ou PageIntro",
452
+ );
270
453
  return (
271
454
  <p
272
455
  data-slot="page-description"
@@ -286,13 +469,14 @@ export function PageActions({
286
469
  ...props
287
470
  }: HTMLAttributes<HTMLDivElement>): ReactElement {
288
471
  const variant = useContext(PageHeaderContext);
289
- requireParent(variant !== null, "PageActions", "PageHeader");
472
+ requireParent(variant !== null, "PageActions", "PageHeader ou PageIntro");
290
473
  const target = useContext(PageActionsTargetContext);
474
+ const insideShell = useContext(PageShellContext);
291
475
  const actions = (
292
476
  <div
293
477
  data-slot="page-actions"
294
478
  className={cn(
295
- variant === "bar"
479
+ variant === "bar" || insideShell
296
480
  ? "flex shrink-0 items-center gap-2"
297
481
  : surfaceHeaderClasses.page.actions,
298
482
  className,
@@ -300,6 +484,7 @@ export function PageActions({
300
484
  {...props}
301
485
  />
302
486
  );
487
+ if (insideShell && target === null) return <></>;
303
488
  return target === null ? actions : createPortal(actions, target);
304
489
  }
305
490
 
@@ -57,6 +57,8 @@ export interface SurfaceHeaderProps extends HTMLAttributes<HTMLDivElement> {
57
57
  leadingPlacement?: "above" | "inline";
58
58
  /** Classes de um container interno quando a moldura precisa ocupar toda a largura. */
59
59
  contentClassName?: string;
60
+ /** Permite uma superfície composta apenas pelas regiões leading/actions. */
61
+ titleRequired?: boolean;
60
62
  }
61
63
 
62
64
  /** Expande fragments de primeiro nível: o shorthand monta os slots dentro de um `<>`. */
@@ -74,6 +76,7 @@ export function SurfaceHeader({
74
76
  slots,
75
77
  leadingPlacement = "above",
76
78
  contentClassName,
79
+ titleRequired = true,
77
80
  className,
78
81
  children,
79
82
  ...props
@@ -100,7 +103,7 @@ export function SurfaceHeader({
100
103
  actionSlots.length;
101
104
 
102
105
  if (
103
- titles.length !== 1 ||
106
+ (titleRequired ? titles.length !== 1 : titles.length > 1) ||
104
107
  leading.length > 1 ||
105
108
  counts.length > 1 ||
106
109
  descriptions.length > 1 ||
@@ -130,11 +133,11 @@ export function SurfaceHeader({
130
133
  throw new Error(
131
134
  distinctLeading.size > 1
132
135
  ? `${name} aceita ${[...distinctLeading].map(label).join(" ou ")} na região introdutória, nunca os dois na mesma superfície.`
133
- : `${name} exige um ${label(slots.title)} e aceita no máximo um de cada: ${optionalSlots}.`,
136
+ : `${name} ${titleRequired ? `exige um ${label(slots.title)}` : `aceita no máximo um ${label(slots.title)}`} e aceita no máximo um de cada: ${optionalSlots}.`,
134
137
  );
135
138
  }
136
139
 
137
- const heading = (
140
+ const heading = titles.length + counts.length + descriptions.length > 0 && (
138
141
  <div
139
142
  data-slot={`${slot}-heading`}
140
143
  className={cn("min-w-0", leadingPlacement === "inline" && "flex-1")}
@@ -146,9 +149,23 @@ export function SurfaceHeader({
146
149
  {descriptions}
147
150
  </div>
148
151
  );
152
+ // A região introdutória carrega o mesmo `data-slot` nas duas apresentações: seletor de teste
153
+ // ou de CSS que funcione no header padrão precisa funcionar na barra.
149
154
  const row = (
150
155
  <>
151
- {leadingPlacement === "inline" && leading}
156
+ {leadingPlacement === "inline" && leading.length > 0 && (
157
+ // `min-w-0` aqui é o que deixa a trilha do PageNavigation ceder e truncar; sem ele o
158
+ // wrapper assume o min-content do breadcrumb e a compressão toda cai sobre o título.
159
+ <div
160
+ data-slot={`${slot}-navigation`}
161
+ className={cn(
162
+ "flex min-w-0 items-center",
163
+ !heading && "flex-1",
164
+ )}
165
+ >
166
+ {leading}
167
+ </div>
168
+ )}
152
169
  {heading}
153
170
  {actionSlots}
154
171
  </>
@@ -302,4 +302,4 @@ chamar a action.
302
302
  | `retryLabel` | `string` | `'Tentar de novo'` | Nome acessível e tooltip da ação de recuperação. |
303
303
  | `onRowClick` | `(item: TItem) => void` | | Clique na linha (ex.: navegar para o detalhe) — só na tabela. |
304
304
  | `toolbarActions` | `ReactNode` | | Ações do consumidor no fim da barra, separadas do grupo de controles. |
305
- | `rowActions` | `(item: TItem) => ReactNode` | | Ações por linha (coluna final, à direita), agrupadas automaticamente com intervalo compacto; cliques ali não disparam o `onRowClick`. |
305
+ | `rowActions` | `(item: TItem) => ReactNode` | | Ações por linha (coluna final, à direita). A partir de duas, entram num grupo com intervalo compacto; uma só fica solta, sem grupo. Cliques ali não disparam o `onRowClick`. |
@@ -1,22 +1,24 @@
1
1
  ## Esqueleto de página
2
2
 
3
- Use `Page` para manter navegação contextual, título, ações e conteúdo na mesma anatomia. O
4
- `PageHeader` padrão acompanha o conteúdo dentro do container; `variant="bar"` transforma o mesmo
5
- cabeçalho em uma faixa compacta no topo. Não monte um chrome paralelo para repetir essas regiões.
3
+ Use `Page` para manter título, ações, estado e conteúdo na mesma anatomia. Sozinha, ela apresenta
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, a página fornece as ações e o título passa a
6
+ `PageIntro` dentro do conteúdo.
6
7
 
7
8
  Em larguras amplas, as ações ficam no extremo oposto e acompanham a base do título e da descrição;
8
9
  em larguras estreitas, passam para uma linha abaixo. O container é centralizado e ocupa a largura
9
10
  disponível até `80rem` (`max-w-7xl`). Use `className` somente quando a composição pedir outro teto
10
11
  ou largura total.
11
12
 
12
- A forma curta é o padrão para páginas comuns. Ela cria internamente `PageHeader` e `PageBody`;
13
- portanto, não produz uma estrutura visual ou semântica diferente da forma explícita. O cabeçalho é a
14
- mesma base estrutural de `Content`, mas reconhece somente título, descrição e ações. Contadores e
15
- outros indicadores pertencem ao conteúdo que os explica.
13
+ A forma curta é o padrão para páginas comuns. Fora de `PageShell`, ela cria `PageHeader` e
14
+ `PageBody`. Dentro dele, cria `PageIntro` e `PageBody`, enquanto envia as ações para a barra. A forma
15
+ explícita permite escolher a região adequada: `PageHeader` reúne navegação e contexto; `PageIntro`
16
+ mais presença ao título dentro do conteúdo. Contadores e outros indicadores pertencem ao
17
+ conteúdo que os explica.
16
18
 
17
- `Page` também pode ocupar o painel principal de um shell com sidebar. Tabs ficam reservados a
18
- recortes da mesma superfície. Canvas e outros workspaces espaciais imersivos podem usar um shell
19
- próprio quando o cabeçalho reduzir a área útil ou duplicar controles persistentes.
19
+ `PageShell` não inclui sidebar nem inventa breadcrumb. Ele ocupa o painel principal delimitado e
20
+ mantém a barra com `3rem`, mesmo quando o conteúdo muda de estado. Tabs ficam reservados a recortes
21
+ da mesma superfície. Canvas e outros workspaces espaciais imersivos podem usar um shell próprio.
20
22
 
21
23
  Estados integrais de carregamento, falha ou ausência são compostos no body com `PageState`,
22
24
  detalhado abaixo. `Page` não recebe flags de dados: uma página pode agregar fontes independentes e
@@ -63,13 +65,63 @@ Não combine `PageBack` com breadcrumb. Use o retorno para um único pai conheci
63
65
  mais de um ancestral relevante, envolva o `Breadcrumb` em `PageNavigation`; ele ocupa a mesma
64
66
  posição introdutória sem transformar a trilha em ação.
65
67
 
66
- ## Cabeçalho em barra
68
+ ## Barra persistente do shell
67
69
 
68
- Use `variant="bar"` quando título, retorno e ações precisarem formar uma faixa compacta e persistente
69
- no topo da página. `Page` estende a borda por toda a largura e mantém o conteúdo da barra alinhado ao
70
- mesmo teto do body. Nesse modo, `PageBack` vira icon-only e recebe tooltip e nome acessível “Voltar
71
- para {destino}”. Mantenha também as ações compactas: use `sm` em botões com texto e `icon-sm` em
72
- botões que exibem somente um ícone.
70
+ Use `PageShell` quando o shell conhece o breadcrumb e a rota conhece título e ações. Não crie um
71
+ `PaneHeader` paralelo nem esconda slots de `Page` com CSS. O Opus projeta `PageActions` na barra e
72
+ transforma a introdução da forma curta em `PageIntro`.
73
+
74
+ ```tsx preview col
75
+ <PageShell
76
+ navigation={
77
+ <Breadcrumb>
78
+ <BreadcrumbList>
79
+ <BreadcrumbItem>Vendas</BreadcrumbItem>
80
+ <BreadcrumbSeparator />
81
+ <BreadcrumbPage>Clientes</BreadcrumbPage>
82
+ </BreadcrumbList>
83
+ </Breadcrumb>
84
+ }
85
+ >
86
+ <Page
87
+ title="Clientes"
88
+ actions={
89
+ <Button size="sm">
90
+ <Plus /> Novo cliente
91
+ </Button>
92
+ }
93
+ >
94
+ Conteúdo da listagem.
95
+ </Page>
96
+ </PageShell>
97
+ ```
98
+
99
+ A barra permanece visível durante `PageState`; somente a introdução desaparece. Se uma ação não
100
+ puder ser executada sem o conteúdo, a própria rota deve omiti-la naquele estado.
101
+
102
+ Na composição explícita dentro do shell, use `PageIntro` e `PageBody`:
103
+
104
+ ```tsx preview col
105
+ <PageShell navigation={<Breadcrumb>...</Breadcrumb>}>
106
+ <Page>
107
+ <PageIntro>
108
+ <PageTitle>Clientes</PageTitle>
109
+ <PageDescription>Cadastros disponíveis para atendimento.</PageDescription>
110
+ <PageActions>
111
+ <Button size="sm">Novo cliente</Button>
112
+ </PageActions>
113
+ </PageIntro>
114
+ <PageBody>Conteúdo da listagem.</PageBody>
115
+ </Page>
116
+ </PageShell>
117
+ ```
118
+
119
+ ## Cabeçalho em barra da própria Page
120
+
121
+ Use `PageHeader variant="bar"` quando uma página autocontida conhecer título, retorno e ações. Essa
122
+ forma continua útil fora de um shell persistente. `PageBack` vira icon-only e recebe tooltip e nome
123
+ acessível “Voltar para {destino}”. Use `sm` em botões com texto e `icon-sm` em botões somente com
124
+ ícone.
73
125
 
74
126
  ```tsx preview col
75
127
  <Page className="max-w-none">
@@ -86,11 +138,8 @@ botões que exibem somente um ícone.
86
138
  </Page>
87
139
  ```
88
140
 
89
- Uma página comum não ganha a barra apenas por estar dentro de um shell. Escolha essa variante quando
90
- a faixa acrescentar contexto ou ações persistentes; sem isso, mantenha o header padrão.
91
-
92
- `PageActionsTarget` continua disponível para um workspace imersivo que já possua um chrome próprio.
93
- Ele projeta somente `PageActions` no elemento informado; não cria uma segunda região de cabeçalho.
141
+ Não aninhe essa variante em `PageShell`, que possui a barra. `PageActionsTarget` continua
142
+ disponível para um workspace imersivo que tenha chrome próprio e não use `PageShell`.
94
143
 
95
144
  ## Composição explícita
96
145
 
@@ -118,15 +167,21 @@ render(
118
167
  );
119
168
  ```
120
169
 
170
+ Use `PageIntro` no lugar de `PageHeader` quando a página precisar apenas de uma introdução no
171
+ conteúdo, sem navegação própria. Dentro de `PageShell`, essa é a única composição explícita válida;
172
+ fora dele, as duas formas são aceitas porque cumprem papéis diferentes.
173
+
121
174
  ## Estados integrais
122
175
 
123
176
  Use `PageState` quando carregamento, falha ou ausência substituírem todo o conteúdo principal. Na
124
177
  forma curta ele pode ser escrito como filho direto de `Page`, que cria o `PageBody`; na forma
125
178
  explícita, coloque-o sozinho dentro de `PageBody`. Enquanto `status` for `loading`, `error` ou
126
- `empty`, `Page` oculta o cabeçalho e o estado ocupa a altura disponível. O título do estado assume o
127
- heading principal. Esse registro também funciona quando um componente intermediário decide qual
128
- `PageState` renderizar. Em `ready`, o cabeçalho e o conteúdo voltam à composição normal. O cabeçalho some inteiro, incluindo o `PageBack`: uma subpágina que dependa desse retorno
129
- oferece a saída pelo `action` do próprio `PageState` (ADR 0004, adendo).
179
+ `empty`, uma `Page` isolada oculta o cabeçalho inteiro incluindo `PageBack` e o estado ocupa a
180
+ altura disponível. Dentro de `PageShell`, a barra persistente permanece e somente `PageIntro` é
181
+ ocultado. Uma subpágina isolada que dependa do retorno oferece a saída pelo `action` do próprio
182
+ `PageState`. O título do estado assume o heading principal. Esse registro também funciona quando um
183
+ componente intermediário decide qual `PageState` renderizar. Em `ready`, o cabeçalho ou a introdução
184
+ e o conteúdo voltam à composição normal.
130
185
 
131
186
  ```tsx preview col
132
187
  <Page title="Relatório">
@@ -171,6 +226,25 @@ estado na própria seção com `DataState`, `ActionView`, `ActionList` ou `Alert
171
226
  | `className` | `string` | `max-w-7xl` | Classes do container para substituir o teto padrão de `80rem`. |
172
227
  | `children` | `ReactNode` | | O body da página — espaçamento e diagramação são seus. |
173
228
 
229
+ ## Propriedades de PageShell
230
+
231
+ | Propriedade | Tipo | Descrição |
232
+ | ----------------- | ----------------------------- | ---------------------------------------------------------------------- |
233
+ | `navigation` | `ReactNode` | Navegação contextual da barra, normalmente um `Breadcrumb`. |
234
+ | `actions` | `ReactNode` | Ações conhecidas pelo shell, antes das ações declaradas pela `Page`. |
235
+ | `children` | `ReactNode` | Rota que renderiza uma `Page` descendente. |
236
+ | `headerClassName` | `string` | Classes adicionais do container interno da barra. |
237
+ | `className` | `string` | Classes adicionais da moldura. |
238
+ | demais | Atributos de `HTMLDivElement` | Atributos nativos repassados à moldura. |
239
+
240
+ ## Propriedades de PageIntro
241
+
242
+ | Propriedade | Tipo | Descrição |
243
+ | ----------- | ----------------------------- | ---------------------------------------------------------------- |
244
+ | `children` | `ReactNode` | Um `PageTitle` e, opcionalmente, descrição e ações da página. |
245
+ | `className` | `string` | Classes adicionais da região introdutória. |
246
+ | demais | Atributos de `HTMLDivElement` | Atributos nativos repassados à região introdutória. |
247
+
174
248
  ## Propriedades de PageHeader
175
249
 
176
250
  | Propriedade | Tipo | Padrão | Descrição |
@@ -201,7 +275,7 @@ estado na própria seção com `DataState`, `ActionView`, `ActionList` ou `Alert
201
275
 
202
276
  | Propriedade | Tipo | Descrição |
203
277
  | ----------- | --------------------------------- | ------------------------------------------------------ |
204
- | `children` | `ReactNode` | Título principal `h1`; compacto na variante `bar`. |
278
+ | `children` | `ReactNode` | Título principal `h1`; compacto na barra e ampliado em `PageIntro`. |
205
279
  | `className` | `string` | Classes adicionais do título. |
206
280
  | demais | Atributos de `HTMLHeadingElement` | Atributos nativos repassados ao heading. |
207
281
 
@@ -244,7 +318,7 @@ estado na própria seção com `DataState`, `ActionView`, `ActionList` ou `Alert
244
318
  | `title` | `ReactNode` | Texto seguro por estado | Situação reconhecível pela pessoa. |
245
319
  | `description` | `ReactNode` | | Impacto ou próximo passo aplicável. |
246
320
  | `icon` | `ReactNode` | Alerta no erro | Ícone decorativo do estado. |
247
- | `action` | `ReactNode` | | Seleção ou criação aplicável ao estado. |
321
+ | `action` | `ReactNode` | | Seleção, criação ou saída aplicável ao estado — inclusive o retorno ao pai, já que o cabeçalho está oculto. |
248
322
  | `emptyMessage` | `string` | `'Nada por aqui'` | Título do vazio quando `title` não é informado. |
249
323
  | `errorMessage` | `string` | `'Não foi possível carregar esta página'` | Título do erro quando `title` não é informado. |
250
324
  | `onRetry` | `() => void \| Promise<void>` | | Recuperação do erro: acrescenta um botão `outline` textual ao lado de `action`. |
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, descrição e ações; a forma explícita acrescenta PageBack para retorno simples ou PageNavigation para uma trilha e permite `PageHeader variant=\"bar\"` quando a mesma anatomia precisar virar uma faixa compacta. No header padrão, PageBack fica acima do título; na barra, vira icon-only com tooltip. PageActionsTarget fica restrito a workspaces imersivos com chrome próprio. Quando todo o body estiver carregando, falhar ou estiver vazio, use PageState: ele oculta o header e ocupa a área disponível, centralizado e sem moldura. 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, descrição 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; título e descrição formam PageIntro no conteúdo. Fora dele, a forma explícita escolhe PageHeader para o cabeçalho completo ou PageIntro para uma introdução sem navegação; `PageHeader variant=\"bar\"` atende uma página autocontida. PageActionsTarget fica restrito a workspaces imersivos sem PageShell. PageState oculta o header da Page isolada ou somente PageIntro dentro de PageShell. Para uma região disponível à criação ou vínculo, use Empty.",
404
404
  },
405
405
  router: {
406
406
  name: "router",
package/src/ui/react.tsx CHANGED
@@ -421,8 +421,10 @@ export type { DataStateProps } from "./components/patterns/data-state.tsx";
421
421
 
422
422
  // Esqueleto de página do back-office (main + container + header título/descrição/ação).
423
423
  export {
424
+ PageShell,
424
425
  Page,
425
426
  PageHeader,
427
+ PageIntro,
426
428
  PageNavigation,
427
429
  PageBack,
428
430
  PageTitle,
@@ -433,6 +435,7 @@ export {
433
435
  } from "./components/patterns/page.tsx";
434
436
  export type {
435
437
  PageProps,
438
+ PageShellProps,
436
439
  PageBackProps,
437
440
  PageHeaderProps,
438
441
  PageHeaderVariant,