@softize/opus 15.0.1 → 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,21 @@ 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
+
10
25
  ## 15.0.1 — 2026-09-09
11
26
 
12
27
  Correções sobre a 15.0.0, publicada horas antes, todas vindas da revisão dela. Nenhuma novidade de
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 = [];
@@ -966,8 +992,15 @@ export function checkUiStructure(file, text) {
966
992
  attributes.has(attribute),
967
993
  );
968
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);
969
1002
  const structural =
970
- children.names.includes(root.header) ||
1003
+ acceptedHeaders.some((header) => children.names.includes(header)) ||
971
1004
  children.names.includes(root.body);
972
1005
  // A prop removida saiu do `shorthand`, então sozinha ela não marca o nó como forma curta:
973
1006
  // ele cai no ramo explícito, que cobra header e body — uma segunda mensagem inteiramente
@@ -981,18 +1014,18 @@ export function checkUiStructure(file, text) {
981
1014
  add(
982
1015
  node,
983
1016
  component,
984
- `${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}.`,
985
1018
  "ui-structure-mode",
986
1019
  );
987
1020
  } else if (!shorthand) {
988
- const headers = children.names.filter(
989
- (name) => name === root.header,
1021
+ const headers = children.names.filter((name) =>
1022
+ acceptedHeaders.includes(name),
990
1023
  ).length;
991
1024
  const bodies = children.names.filter(
992
1025
  (name) => name === root.body,
993
1026
  ).length;
994
1027
  const unexpected = children.names.some(
995
- (name) => name !== root.header && name !== root.body,
1028
+ (name) => !acceptedHeaders.includes(name) && name !== root.body,
996
1029
  );
997
1030
  if (
998
1031
  headers !== 1 ||
@@ -1003,7 +1036,7 @@ export function checkUiStructure(file, text) {
1003
1036
  add(
1004
1037
  node,
1005
1038
  component,
1006
- `${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.`,
1007
1040
  );
1008
1041
  }
1009
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
 
@@ -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.1",
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,10 +20,17 @@
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 inteiro
23
- incluindo o `PageBack` —, o estado ocupa a área disponível e seu título assume o heading
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
24
34
  principal, inclusive quando um componente intermediário renderiza o estado. Uma subpágina que
25
35
  dependa do retorno ao pai oferece essa saída pelo `action` do próprio `PageState`. O erro mantém
26
36
  `role="alert"`, usa a mesma composição central e sem moldura dos demais estados e apresenta a
@@ -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,6 +298,7 @@ export function Page({
169
298
  data-slot="page"
170
299
  className={cn(
171
300
  "min-w-0 flex-1",
301
+ insideShell && "min-h-full",
172
302
  (headerVariant === "bar" || integralState) && "flex flex-col",
173
303
  // A barra contém a rolagem (`min-h-0`); o estado integral ocupa a altura
174
304
  // disponível (`min-h-full`). Emitir os dois juntos deixava o resultado por
@@ -198,6 +328,47 @@ export function Page({
198
328
  );
199
329
  }
200
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
+
201
372
  /** A anatomia é a de `SurfaceHeader`, a mesma de `ContentHeader`; só os slots mudam de nome. */
202
373
  export interface PageHeaderProps extends HTMLAttributes<HTMLDivElement> {
203
374
  /** `default` fica no container; `bar` cria uma faixa compacta no topo da Page. */
@@ -228,12 +399,15 @@ export function PageHeader({
228
399
  leadingPlacement={variant === "bar" ? "inline" : "above"}
229
400
  contentClassName={
230
401
  variant === "bar"
231
- ? 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
+ )
232
407
  : undefined
233
408
  }
234
409
  className={cn(
235
- variant === "bar" &&
236
- "shrink-0 border-b border-border bg-background text-foreground",
410
+ variant === "bar" && pageBarClassName,
237
411
  className,
238
412
  )}
239
413
  {...props}
@@ -249,14 +423,16 @@ export function PageTitle({
249
423
  ...props
250
424
  }: HTMLAttributes<HTMLHeadingElement>): ReactElement {
251
425
  const variant = useContext(PageHeaderContext);
252
- requireParent(variant !== null, "PageTitle", "PageHeader");
426
+ requireParent(variant !== null, "PageTitle", "PageHeader ou PageIntro");
253
427
  return (
254
428
  <h1
255
429
  data-slot="page-title"
256
430
  className={cn(
257
431
  variant === "bar"
258
432
  ? "truncate text-sm font-semibold"
259
- : surfaceHeaderClasses.page.title,
433
+ : variant === "intro"
434
+ ? "text-3xl font-semibold tracking-tight"
435
+ : surfaceHeaderClasses.page.title,
260
436
  className,
261
437
  )}
262
438
  {...props}
@@ -269,7 +445,11 @@ export function PageDescription({
269
445
  ...props
270
446
  }: HTMLAttributes<HTMLParagraphElement>): ReactElement {
271
447
  const variant = useContext(PageHeaderContext);
272
- requireParent(variant !== null, "PageDescription", "PageHeader");
448
+ requireParent(
449
+ variant !== null,
450
+ "PageDescription",
451
+ "PageHeader ou PageIntro",
452
+ );
273
453
  return (
274
454
  <p
275
455
  data-slot="page-description"
@@ -289,13 +469,14 @@ export function PageActions({
289
469
  ...props
290
470
  }: HTMLAttributes<HTMLDivElement>): ReactElement {
291
471
  const variant = useContext(PageHeaderContext);
292
- requireParent(variant !== null, "PageActions", "PageHeader");
472
+ requireParent(variant !== null, "PageActions", "PageHeader ou PageIntro");
293
473
  const target = useContext(PageActionsTargetContext);
474
+ const insideShell = useContext(PageShellContext);
294
475
  const actions = (
295
476
  <div
296
477
  data-slot="page-actions"
297
478
  className={cn(
298
- variant === "bar"
479
+ variant === "bar" || insideShell
299
480
  ? "flex shrink-0 items-center gap-2"
300
481
  : surfaceHeaderClasses.page.actions,
301
482
  className,
@@ -303,6 +484,7 @@ export function PageActions({
303
484
  {...props}
304
485
  />
305
486
  );
487
+ if (insideShell && target === null) return <></>;
306
488
  return target === null ? actions : createPortal(actions, target);
307
489
  }
308
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")}
@@ -153,7 +156,13 @@ export function SurfaceHeader({
153
156
  {leadingPlacement === "inline" && leading.length > 0 && (
154
157
  // `min-w-0` aqui é o que deixa a trilha do PageNavigation ceder e truncar; sem ele o
155
158
  // wrapper assume o min-content do breadcrumb e a compressão toda cai sobre o título.
156
- <div data-slot={`${slot}-navigation`} className="flex min-w-0 items-center">
159
+ <div
160
+ data-slot={`${slot}-navigation`}
161
+ className={cn(
162
+ "flex min-w-0 items-center",
163
+ !heading && "flex-1",
164
+ )}
165
+ >
157
166
  {leading}
158
167
  </div>
159
168
  )}
@@ -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,16 +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 inteiro — incluindo o `PageBack` — e o estado ocupa a altura
127
- disponível. Uma subpágina que dependa desse retorno oferece a saída pelo `action` do próprio
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
128
182
  `PageState`. O título do estado assume o heading principal. Esse registro também funciona quando um
129
- componente intermediário decide qual `PageState` renderizar. Em `ready`, o cabeçalho e o conteúdo
130
- voltam à composição normal.
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.
131
185
 
132
186
  ```tsx preview col
133
187
  <Page title="Relatório">
@@ -172,6 +226,25 @@ estado na própria seção com `DataState`, `ActionView`, `ActionList` ou `Alert
172
226
  | `className` | `string` | `max-w-7xl` | Classes do container para substituir o teto padrão de `80rem`. |
173
227
  | `children` | `ReactNode` | | O body da página — espaçamento e diagramação são seus. |
174
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
+
175
248
  ## Propriedades de PageHeader
176
249
 
177
250
  | Propriedade | Tipo | Padrão | Descrição |
@@ -202,7 +275,7 @@ estado na própria seção com `DataState`, `ActionView`, `ActionList` ou `Alert
202
275
 
203
276
  | Propriedade | Tipo | Descrição |
204
277
  | ----------- | --------------------------------- | ------------------------------------------------------ |
205
- | `children` | `ReactNode` | Título principal `h1`; compacto na variante `bar`. |
278
+ | `children` | `ReactNode` | Título principal `h1`; compacto na barra e ampliado em `PageIntro`. |
206
279
  | `className` | `string` | Classes adicionais do título. |
207
280
  | demais | Atributos de `HTMLHeadingElement` | Atributos nativos repassados ao heading. |
208
281
 
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,