@softize/opus 16.1.0 → 17.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/bin/lib/check.mjs +9 -13
  3. package/bin/lib/copy.mjs +29 -4
  4. package/docs/adr/0005-structural-surfaces-share-an-explicit-anatomy.md +6 -3
  5. package/docs/adr/0009-page-title-does-not-carry-a-counter.md +5 -2
  6. package/docs/adr/0011-page-shell-coordinates-persistent-page-chrome.md +12 -14
  7. package/docs/adr/0012-modal-header-only-names-the-surface.md +8 -4
  8. package/docs/adr/0013-presentation-is-a-portable-action-oriented-artifact.md +20 -11
  9. package/docs/adr/0014-structural-headers-do-not-carry-description.md +35 -0
  10. package/docs/adr/0015-action-size-follows-interaction-density.md +64 -0
  11. package/package.json +2 -2
  12. package/registry/skills/build-opus-ui/references/evaluations.md +1 -1
  13. package/registry/skills/build-opus-ui/references/ui-patterns.md +19 -18
  14. package/registry/templates/app/package.json +1 -1
  15. package/src/core/presentation.ts +142 -13
  16. package/src/ui/components/patterns/form.tsx +35 -10
  17. package/src/ui/components/patterns/page.tsx +93 -48
  18. package/src/ui/components/patterns/presentation.tsx +489 -329
  19. package/src/ui/components/patterns/surface-header.tsx +4 -3
  20. package/src/ui/components/patterns/trigger.tsx +8 -1
  21. package/src/ui/components/patterns/view.tsx +2 -2
  22. package/src/ui/components/primitives/command.tsx +82 -42
  23. package/src/ui/components/primitives/dialog.tsx +180 -97
  24. package/src/ui/components/primitives/drawer.tsx +63 -22
  25. package/src/ui/docs/content/button.md +8 -2
  26. package/src/ui/docs/content/command.md +3 -1
  27. package/src/ui/docs/content/content.md +1 -1
  28. package/src/ui/docs/content/dialog.md +9 -9
  29. package/src/ui/docs/content/drawer.md +4 -4
  30. package/src/ui/docs/content/page.md +16 -31
  31. package/src/ui/docs/content/presentation.md +73 -80
  32. package/src/ui/meta.ts +2 -2
  33. package/src/ui/react.tsx +1 -8
@@ -94,6 +94,8 @@ const presentationBodySchema = z
94
94
  .object({
95
95
  action: z.string().min(1),
96
96
  input: z.record(presentationBindingSchema).default({}),
97
+ onSuccess: z.array(presentationEffectSchema).default([]),
98
+ submitLabel: z.string().min(1).optional(),
97
99
  fields: z.array(presentationFieldSchema).min(1).optional(),
98
100
  open: z
99
101
  .object({
@@ -153,13 +155,14 @@ type DefinedPresentationOpen<Open> = Open extends object
153
155
  }
154
156
  : never;
155
157
  type DefinedPresentationBody<Body extends PresentationDefinitionInput["body"]> =
156
- Omit<Body, "input" | "open"> &
157
- Omit<PresentationDefinition["body"], "input" | "open"> & {
158
+ Omit<Body, "input" | "open" | "onSuccess"> &
159
+ Omit<PresentationDefinition["body"], "input" | "open" | "onSuccess"> & {
158
160
  input: DefaultedProperty<
159
161
  Body,
160
162
  "input",
161
163
  PresentationDefinition["body"]["input"]
162
164
  >;
165
+ onSuccess: DefinedPresentationEffects<Body, "onSuccess">;
163
166
  } & ("open" extends keyof Body
164
167
  ? undefined extends Body["open"]
165
168
  ? { open?: DefinedPresentationOpen<Exclude<Body["open"], undefined>> }
@@ -417,6 +420,12 @@ export function validatePresentations(
417
420
  `A action simple “${bodyAction.name}” não pode ocupar o body.`,
418
421
  );
419
422
  } else {
423
+ validateActionBindings(
424
+ presentation.body.input,
425
+ bodyAction,
426
+ `${presentation.id}.body.input`,
427
+ problems,
428
+ );
420
429
  if (
421
430
  bodyAction.kind === "view" &&
422
431
  presentation.body.fields === undefined
@@ -434,20 +443,34 @@ export function validatePresentations(
434
443
  if (bodyAction.kind !== "list" && presentation.body.open !== undefined) {
435
444
  problems.push(`Open só pode ser declarado para uma action list.`);
436
445
  }
446
+ if (
447
+ bodyAction.kind !== "form" &&
448
+ presentation.body.submitLabel !== undefined
449
+ ) {
450
+ problems.push(`SubmitLabel só pode ser declarado para uma action form.`);
451
+ }
437
452
  }
438
453
 
439
454
  for (const command of presentation.actions) {
440
455
  const action = actions[command.action];
441
456
  if (action === undefined) {
442
457
  problems.push(`A action “${command.action}” não está disponível.`);
443
- } else if (action.kind === "simple" && command.target !== undefined) {
444
- problems.push(
445
- `A action simple “${command.action}” executa diretamente e não aceita target.`,
446
- );
447
- } else if (action.kind !== "simple" && command.target === undefined) {
448
- problems.push(
449
- `A action ${action.kind} “${command.action}” precisa abrir outra Presentation.`,
458
+ } else {
459
+ validateActionBindings(
460
+ command.input,
461
+ action,
462
+ `${presentation.id}.actions.${command.action}.input`,
463
+ problems,
450
464
  );
465
+ if (action.kind === "simple" && command.target !== undefined) {
466
+ problems.push(
467
+ `A action simple “${command.action}” executa diretamente e não aceita target.`,
468
+ );
469
+ } else if (action.kind !== "simple" && command.target === undefined) {
470
+ problems.push(
471
+ `A action ${action.kind} “${command.action}” precisa abrir outra Presentation.`,
472
+ );
473
+ }
451
474
  }
452
475
  }
453
476
  }
@@ -458,13 +481,57 @@ export function validatePresentations(
458
481
  problems.push(
459
482
  `A lista “${presentation.id}” abre a Presentation inexistente “${open.presentation}”.`,
460
483
  );
484
+ } else if (open !== undefined) {
485
+ const target = byId.get(open.presentation);
486
+ const action =
487
+ target === undefined ? undefined : actions[target.body.action];
488
+ if (action !== undefined) {
489
+ validateActionBindings(
490
+ open.input,
491
+ action,
492
+ `${presentation.id}.body.open.input`,
493
+ problems,
494
+ );
495
+ }
496
+ }
497
+ for (const effect of presentation.body.onSuccess) {
498
+ if (effect.effect !== "navigate") continue;
499
+ const target = byId.get(effect.presentation);
500
+ if (target === undefined) {
501
+ problems.push(
502
+ `O body de “${presentation.id}” navega para a Presentation inexistente “${effect.presentation}”.`,
503
+ );
504
+ continue;
505
+ }
506
+ const action = actions[target.body.action];
507
+ if (action !== undefined) {
508
+ validateActionBindings(
509
+ effect.input,
510
+ action,
511
+ `${presentation.id}.body.onSuccess.navigate.input`,
512
+ problems,
513
+ );
514
+ }
461
515
  }
462
516
  for (const command of presentation.actions) {
463
517
  for (const effect of command.onSuccess) {
464
- if (effect.effect === "navigate" && !byId.has(effect.presentation)) {
465
- problems.push(
466
- `A action “${command.action}” navega para a Presentation inexistente “${effect.presentation}”.`,
467
- );
518
+ if (effect.effect === "navigate") {
519
+ const target = byId.get(effect.presentation);
520
+ if (target === undefined) {
521
+ problems.push(
522
+ `A action “${command.action}” navega para a Presentation inexistente “${effect.presentation}”.`,
523
+ );
524
+ } else {
525
+ const action = actions[target.body.action];
526
+ if (action !== undefined) {
527
+ validateActionBindings(
528
+ effect.input,
529
+ action,
530
+ `${presentation.id}.actions.${command.action}.onSuccess.navigate.input`,
531
+ problems,
532
+ );
533
+ }
534
+ }
468
535
  }
469
536
  }
470
537
  if (command.target === undefined) continue;
@@ -485,6 +552,68 @@ export function validatePresentations(
485
552
  return parsed;
486
553
  }
487
554
 
555
+ function unwrapObjectSchema(schema: unknown): z.ZodObject<any> | null {
556
+ let current = schema;
557
+ const visited = new Set<unknown>();
558
+ while (current !== null && typeof current === "object" && !visited.has(current)) {
559
+ visited.add(current);
560
+ if (current instanceof z.ZodObject) return current;
561
+ const definition = (current as { _def?: Record<string, unknown> })._def;
562
+ current = definition?.innerType ?? definition?.schema ?? null;
563
+ }
564
+ return null;
565
+ }
566
+
567
+ function validateActionBindings(
568
+ bindings: Readonly<Record<string, PresentationBinding>>,
569
+ action: ActionContract,
570
+ path: string,
571
+ problems: string[],
572
+ ): void {
573
+ const schema = unwrapObjectSchema(action.input);
574
+ if (schema === null) return;
575
+ const shape = schema.shape as Record<string, z.ZodTypeAny>;
576
+
577
+ for (const key of Object.keys(bindings)) {
578
+ const field = shape[key];
579
+ if (field === undefined) {
580
+ problems.push(
581
+ `O binding “${path}.${key}” não existe no input da action “${action.name}”.`,
582
+ );
583
+ continue;
584
+ }
585
+ const binding = bindings[key];
586
+ if (binding?.source === "fixed" && !field.safeParse(binding.value).success) {
587
+ problems.push(
588
+ `O valor fixo de “${path}.${key}” é incompatível com o input da action “${action.name}”.`,
589
+ );
590
+ }
591
+ if (
592
+ binding?.source === "now" &&
593
+ !field.safeParse(new Date(0).toISOString()).success
594
+ ) {
595
+ problems.push(
596
+ `O binding temporal “${path}.${key}” é incompatível com o input da action “${action.name}”.`,
597
+ );
598
+ }
599
+ }
600
+
601
+ for (const [key, field] of Object.entries(shape)) {
602
+ const suppliedByForm =
603
+ action.kind === "form" &&
604
+ key in ((action.fields ?? {}) as Record<string, unknown>);
605
+ if (
606
+ bindings[key] === undefined &&
607
+ !suppliedByForm &&
608
+ !field.safeParse(undefined).success
609
+ ) {
610
+ problems.push(
611
+ `O input obrigatório “${key}” da action “${action.name}” não possui binding em “${path}”.`,
612
+ );
613
+ }
614
+ }
615
+ }
616
+
488
617
  export function resolvePresentationBinding(
489
618
  binding: PresentationBinding,
490
619
  context: PresentationBindingContext,
@@ -17,7 +17,7 @@
17
17
  * inline; toast em sucesso/erro. Emite `data-action="<action.name>"` na raiz (selector E2E).
18
18
  */
19
19
 
20
- import { createContext, useContext, useEffect } from 'react'
20
+ import { createContext, useContext, useEffect, useId } from 'react'
21
21
  import type { UseFormReturn } from 'react-hook-form'
22
22
  import {
23
23
  getLogicalType,
@@ -616,6 +616,10 @@ export interface ActionFormProps<TInput extends Record<string, unknown>, TData>
616
616
  submitLabel?: string
617
617
  cancelLabel?: string
618
618
  onCancel?: () => void
619
+ /** Bloqueia campos e ações sem desmontar o formulário. */
620
+ disabled?: boolean
621
+ /** Informa carregamento para superfícies que coordenam ações como um grupo. */
622
+ onLoadingChange?: (loading: boolean) => void
619
623
  /** Opções por campo pra select/multiselect carregados em runtime (ex.: papéis/skills
620
624
  * por id). Sobrepõe as opções estáticas inferidas do z.enum. Chave = nome do campo. */
621
625
  fieldOptions?: Record<string, SelectOption[]>
@@ -640,12 +644,15 @@ export function ActionForm<TInput extends Record<string, unknown>, TData>({
640
644
  submitLabel = 'Salvar',
641
645
  cancelLabel = 'Cancelar',
642
646
  onCancel,
647
+ disabled = false,
648
+ onLoadingChange,
643
649
  fieldOptions,
644
650
  className,
645
651
  body,
646
652
  footer,
647
653
  children,
648
654
  }: ActionFormProps<TInput, TData>) {
655
+ const formId = useId()
649
656
  const { form, submit, isLoading, error, isSuccess } = useFormAction<TInput, TData>(action, {
650
657
  ...(defaultValues !== undefined ? { defaultValues: defaultValues as never } : {}),
651
658
  onSuccess: (data) => {
@@ -657,6 +664,11 @@ export function ActionForm<TInput extends Record<string, unknown>, TData>({
657
664
  },
658
665
  })
659
666
 
667
+ useEffect(() => {
668
+ onLoadingChange?.(isLoading)
669
+ return () => onLoadingChange?.(false)
670
+ }, [isLoading, onLoadingChange])
671
+
660
672
  const shape = (objectSchemaShape(action.input) ?? {}) as Record<string, ZodTypeAny | undefined>
661
673
  const fields = action.fields as Record<string, FieldSpec>
662
674
 
@@ -692,26 +704,39 @@ export function ActionForm<TInput extends Record<string, unknown>, TData>({
692
704
  fieldOptions,
693
705
  }}
694
706
  >
695
- <form onSubmit={submit} className={cn('flex flex-col', className)} data-action={action.name}>
707
+ <form id={formId} onSubmit={submit} className={cn('flex flex-col', className)} data-action={action.name}>
696
708
  {wrapBody(
697
- <FieldGroup>
698
- {children !== undefined
699
- ? children
700
- : Object.keys(fields).map((name) => <ActionFormField key={name} name={name} />)}
701
- {errorBanner}
702
- </FieldGroup>,
709
+ <fieldset disabled={disabled || isLoading} className="contents">
710
+ <FieldGroup>
711
+ {children !== undefined
712
+ ? children
713
+ : Object.keys(fields).map((name) => <ActionFormField key={name} name={name} />)}
714
+ {errorBanner}
715
+ </FieldGroup>
716
+ </fieldset>,
703
717
  )}
704
718
 
705
719
  {wrapFooter(
706
720
  <>
707
721
  {onCancel !== undefined && (
708
- <Button type="button" variant="ghost" onClick={onCancel}>
722
+ <Button
723
+ type="button"
724
+ form={formId}
725
+ variant="ghost"
726
+ disabled={disabled || isLoading}
727
+ onClick={onCancel}
728
+ >
709
729
  {cancelLabel}
710
730
  </Button>
711
731
  )}
712
732
  {/* Gated por dirty: só habilita com mudança real (igual aos forms antigos).
713
733
  busy = a submissão em andamento (spinner + disable vêm do Button). */}
714
- <Button type="submit" busy={isLoading} disabled={!form.formState.isDirty}>
734
+ <Button
735
+ type="submit"
736
+ form={formId}
737
+ busy={isLoading}
738
+ disabled={disabled || !form.formState.isDirty}
739
+ >
715
740
  {submitLabel}
716
741
  </Button>
717
742
  </>,
@@ -33,6 +33,8 @@ type PageHeadingRegion = "header" | "intro";
33
33
  const PageHeaderContext = createContext<PageHeadingRegion | null>(null);
34
34
  const PageIntegralStateContext = createContext(false);
35
35
  const PageActionsTargetContext = createContext<HTMLElement | null>(null);
36
+ const PageNavigationTargetContext = createContext<HTMLElement | null>(null);
37
+ const PageTitleTargetContext = createContext<HTMLElement | null>(null);
36
38
  const PageShellContext = createContext(false);
37
39
  const pageBarClassName =
38
40
  "flex h-12 shrink-0 items-center border-b border-border bg-background text-foreground";
@@ -44,13 +46,34 @@ export function useInsidePageShell(): boolean {
44
46
  }
45
47
 
46
48
  function PageShellNavigationSlot({
49
+ targetRef,
47
50
  className,
48
51
  ...props
49
- }: HTMLAttributes<HTMLDivElement>): ReactElement {
52
+ }: HTMLAttributes<HTMLDivElement> & {
53
+ targetRef?: (target: HTMLDivElement | null) => void;
54
+ }): ReactElement {
50
55
  return (
51
56
  <div
57
+ ref={targetRef}
52
58
  data-slot="page-shell-navigation"
53
- className={cn("min-w-0 flex-1 overflow-hidden", className)}
59
+ className={cn("min-w-0 shrink-0 overflow-hidden", className)}
60
+ {...props}
61
+ />
62
+ );
63
+ }
64
+
65
+ function PageShellTitleSlot({
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-title"
76
+ className={cn("min-w-0 flex-1", className)}
54
77
  {...props}
55
78
  />
56
79
  );
@@ -99,6 +122,9 @@ export function PageShell({
99
122
  const [actionsTarget, setActionsTarget] = useState<HTMLDivElement | null>(
100
123
  null,
101
124
  );
125
+ const [navigationTarget, setNavigationTarget] =
126
+ useState<HTMLDivElement | null>(null);
127
+ const [titleTarget, setTitleTarget] = useState<HTMLDivElement | null>(null);
102
128
 
103
129
  return (
104
130
  <div
@@ -114,8 +140,7 @@ export function PageShell({
114
140
  slot="page"
115
141
  slots={{
116
142
  leading: PageShellNavigationSlot,
117
- title: PageTitle,
118
- description: PageDescription,
143
+ title: PageShellTitleSlot,
119
144
  actions: PageShellActionsSlot,
120
145
  }}
121
146
  leadingPlacement="inline"
@@ -124,17 +149,24 @@ export function PageShell({
124
149
  className={pageBarClassName}
125
150
  data-variant="bar"
126
151
  >
127
- <PageShellNavigationSlot>{navigation}</PageShellNavigationSlot>
152
+ <PageShellNavigationSlot targetRef={setNavigationTarget}>
153
+ {navigation}
154
+ </PageShellNavigationSlot>
155
+ <PageShellTitleSlot targetRef={setTitleTarget} />
128
156
  <PageShellActionsSlot targetRef={setActionsTarget}>
129
157
  {actions}
130
158
  </PageShellActionsSlot>
131
159
  </SurfaceHeader>
132
160
  <PageShellContext.Provider value>
133
- <PageActionsTarget target={actionsTarget}>
134
- <div data-slot="page-shell-content" className="min-h-0 flex-1">
135
- {children}
136
- </div>
137
- </PageActionsTarget>
161
+ <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>
169
+ </PageNavigationTargetContext.Provider>
138
170
  </PageShellContext.Provider>
139
171
  </div>
140
172
  );
@@ -172,7 +204,6 @@ interface PageBaseProps extends Omit<
172
204
 
173
205
  interface PageShorthandProps extends PageBaseProps {
174
206
  title: ReactNode;
175
- description?: ReactNode;
176
207
  actions?: ReactNode;
177
208
  children: ReactNode;
178
209
  }
@@ -188,7 +219,6 @@ export type PageProps = PageShorthandProps | PageComposedProps;
188
219
 
189
220
  export function Page({
190
221
  title,
191
- description,
192
222
  actions,
193
223
  className,
194
224
  children,
@@ -232,6 +262,7 @@ export function Page({
232
262
  }
233
263
  if (!shorthand) {
234
264
  const validIntroComposition =
265
+ !insideShell &&
235
266
  intros.length === 1 &&
236
267
  headers.length === 0 &&
237
268
  bodies.length === 1 &&
@@ -244,10 +275,22 @@ export function Page({
244
275
  bodies.length === 1 &&
245
276
  footers.length <= 1 &&
246
277
  headers.length + bodies.length + footers.length === nodes.length;
247
- if (!validIntroComposition && !validStandaloneComposition) {
278
+ const validShellComposition =
279
+ insideShell &&
280
+ headers.length === 1 &&
281
+ bodies.length === 1 &&
282
+ intros.length <= 1 &&
283
+ footers.length <= 1 &&
284
+ headers.length + intros.length + bodies.length + footers.length ===
285
+ nodes.length;
286
+ if (
287
+ !validIntroComposition &&
288
+ !validStandaloneComposition &&
289
+ !validShellComposition
290
+ ) {
248
291
  throw new Error(
249
292
  insideShell
250
- ? "Page explícito dentro de PageShell exige um PageIntro, um PageBody e no máximo um PageFooter como filhos diretos."
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."
251
294
  : "Page explícito exige um PageHeader ou PageIntro, um PageBody e no máximo um PageFooter como filhos diretos.",
252
295
  );
253
296
  }
@@ -255,22 +298,16 @@ export function Page({
255
298
  const content =
256
299
  shorthand && insideShell ? (
257
300
  <>
258
- <PageIntro>
301
+ <PageHeader>
259
302
  <PageTitle>{title}</PageTitle>
260
- {description !== undefined && (
261
- <PageDescription>{description}</PageDescription>
262
- )}
263
303
  {actions !== undefined && <PageActions>{actions}</PageActions>}
264
- </PageIntro>
304
+ </PageHeader>
265
305
  <PageBody>{children}</PageBody>
266
306
  </>
267
307
  ) : shorthand ? (
268
308
  <>
269
309
  <PageHeader>
270
310
  <PageTitle>{title}</PageTitle>
271
- {description !== undefined && (
272
- <PageDescription>{description}</PageDescription>
273
- )}
274
311
  {actions !== undefined && <PageActions>{actions}</PageActions>}
275
312
  </PageHeader>
276
313
  <PageBody>{children}</PageBody>
@@ -337,12 +374,9 @@ export function PageIntro({
337
374
  name="PageIntro"
338
375
  slot="page-intro"
339
376
  slots={{
340
- leading: [PageBack, PageNavigation],
341
377
  title: PageTitle,
342
- description: PageDescription,
343
378
  actions: PageActions,
344
379
  }}
345
- leadingPlacement="above"
346
380
  className={className}
347
381
  {...props}
348
382
  >
@@ -361,9 +395,17 @@ export function PageHeader({
361
395
  ...props
362
396
  }: PageHeaderProps): ReactElement {
363
397
  const page = useContext(PageContext);
398
+ const insideShell = useContext(PageShellContext);
364
399
  const integralState = useContext(PageIntegralStateContext);
365
400
  requireParent(page !== null, "PageHeader", "Page");
366
- if (integralState) return <></>;
401
+ if (integralState && !insideShell) return <></>;
402
+ if (insideShell) {
403
+ return (
404
+ <PageHeaderContext.Provider value="header">
405
+ {children}
406
+ </PageHeaderContext.Provider>
407
+ );
408
+ }
367
409
  return (
368
410
  <PageHeaderContext.Provider value="header">
369
411
  <SurfaceHeader
@@ -372,7 +414,6 @@ export function PageHeader({
372
414
  slots={{
373
415
  leading: [PageBack, PageNavigation],
374
416
  title: PageTitle,
375
- description: PageDescription,
376
417
  actions: PageActions,
377
418
  }}
378
419
  leadingPlacement="inline"
@@ -390,8 +431,10 @@ export function PageTitle({
390
431
  ...props
391
432
  }: HTMLAttributes<HTMLHeadingElement>): ReactElement {
392
433
  const variant = useContext(PageHeaderContext);
434
+ const insideShell = useContext(PageShellContext);
435
+ const target = useContext(PageTitleTargetContext);
393
436
  requireParent(variant !== null, "PageTitle", "PageHeader ou PageIntro");
394
- return (
437
+ const title = (
395
438
  <h1
396
439
  data-slot="page-title"
397
440
  className={cn(
@@ -403,21 +446,10 @@ export function PageTitle({
403
446
  {...props}
404
447
  />
405
448
  );
406
- }
407
-
408
- export function PageDescription({
409
- className,
410
- ...props
411
- }: HTMLAttributes<HTMLParagraphElement>): ReactElement {
412
- const variant = useContext(PageHeaderContext);
413
- requireParent(variant !== null, "PageDescription", "PageHeader ou PageIntro");
414
- return (
415
- <p
416
- data-slot="page-description"
417
- className={cn(surfaceHeaderClasses.page.description, className)}
418
- {...props}
419
- />
420
- );
449
+ if (insideShell && variant === "header") {
450
+ return target === null ? <></> : createPortal(title, target);
451
+ }
452
+ return title;
421
453
  }
422
454
 
423
455
  export function PageActions({
@@ -460,18 +492,25 @@ export function PageNavigation({
460
492
  className,
461
493
  ...props
462
494
  }: HTMLAttributes<HTMLDivElement>): ReactElement {
495
+ const variant = useContext(PageHeaderContext);
496
+ const insideShell = useContext(PageShellContext);
497
+ const target = useContext(PageNavigationTargetContext);
463
498
  requireParent(
464
- useContext(PageHeaderContext) !== null,
499
+ variant !== null,
465
500
  "PageNavigation",
466
- "PageHeader ou PageIntro",
501
+ "PageHeader",
467
502
  );
468
- return (
503
+ const navigation = (
469
504
  <div
470
505
  data-slot="page-navigation-content"
471
506
  className={cn("min-w-0", className)}
472
507
  {...props}
473
508
  />
474
509
  );
510
+ if (insideShell && variant === "header") {
511
+ return target === null ? <></> : createPortal(navigation, target);
512
+ }
513
+ return navigation;
475
514
  }
476
515
 
477
516
  export function PageBack({
@@ -481,11 +520,13 @@ export function PageBack({
481
520
  ...props
482
521
  }: PageBackProps): ReactElement {
483
522
  const variant = useContext(PageHeaderContext);
484
- requireParent(variant !== null, "PageBack", "PageHeader ou PageIntro");
523
+ const insideShell = useContext(PageShellContext);
524
+ const target = useContext(PageNavigationTargetContext);
525
+ requireParent(variant !== null, "PageBack", "PageHeader");
485
526
  const destination =
486
527
  typeof children === "string" ? children : "a página anterior";
487
528
  const accessibleLabel = ariaLabel ?? `Voltar para ${destination}`;
488
- return (
529
+ const back = (
489
530
  <Button asChild variant="ghost" size="icon-sm">
490
531
  <a
491
532
  data-slot="page-back"
@@ -499,6 +540,10 @@ export function PageBack({
499
540
  </a>
500
541
  </Button>
501
542
  );
543
+ if (insideShell && variant === "header") {
544
+ return target === null ? <></> : createPortal(back, target);
545
+ }
546
+ return back;
502
547
  }
503
548
 
504
549
  export function PageBody({