@softize/opus 13.0.0 → 14.0.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 (164) hide show
  1. package/CHANGELOG.md +81 -0
  2. package/PROMOTED.md +46 -0
  3. package/README.md +28 -19
  4. package/bin/cli.mjs +87 -216
  5. package/bin/lib/cli-shared.mjs +131 -0
  6. package/bin/lib/copy.mjs +276 -6
  7. package/bin/lib/db.mjs +16 -74
  8. package/bin/lib/gen-openapi.mjs +3 -3
  9. package/bin/lib/gen-runner.mjs +1 -1
  10. package/bin/lib/gen.mjs +14 -69
  11. package/bin/lib/mcp.mjs +3 -1
  12. package/bin/lib/seed.mjs +5 -62
  13. package/docs/code-style.md +4 -1
  14. package/docs/ownership-vs-shadcn-lock.md +2 -3
  15. package/docs/protocol.md +7 -7
  16. package/docs/releasing.md +8 -2
  17. package/package.json +7 -3
  18. package/registry/instructions/opus.md +3 -3
  19. package/registry/templates/app/package.json +1 -1
  20. package/registry/templates/app/src/App.tsx +11 -6
  21. package/registry/templates/app/src/main.tsx +4 -4
  22. package/src/audit/drivers/console.ts +1 -0
  23. package/src/auth/drivers/better-auth.ts +1 -0
  24. package/src/auth/drivers/jwt.ts +1 -0
  25. package/src/cache/drivers/memory.ts +1 -0
  26. package/src/client/drivers/fetch.ts +2 -1
  27. package/src/core/actions.ts +6 -1
  28. package/src/core/audit.ts +9 -3
  29. package/src/core/contracts.ts +7 -0
  30. package/src/core/domain.ts +1 -1
  31. package/src/core/errors.ts +18 -15
  32. package/src/core/index.ts +4 -2
  33. package/src/core/package-version.ts +26 -0
  34. package/src/core/reactions.ts +1 -1
  35. package/src/core/runtime.ts +33 -23
  36. package/src/core/schedules.ts +1 -1
  37. package/src/core/types.ts +5 -6
  38. package/src/dsl/eval.ts +2 -2
  39. package/src/dsl/kysely.ts +2 -2
  40. package/src/dsl/loads.ts +1 -1
  41. package/src/dsl/parser.ts +5 -5
  42. package/src/events/drivers/mitt.ts +1 -0
  43. package/src/mcp/index.ts +2 -1
  44. package/src/observability/drivers/opentelemetry.ts +1 -0
  45. package/src/queue/drivers/bullmq.ts +3 -3
  46. package/src/scheduler/drivers/node-cron.ts +3 -2
  47. package/src/scheduler/every.ts +7 -7
  48. package/src/schema/openapi.ts +3 -3
  49. package/src/seed/index.ts +29 -0
  50. package/src/server/drivers/fastify.ts +5 -2
  51. package/src/server/drivers/node.ts +9 -6
  52. package/src/server/index.ts +3 -1
  53. package/src/storage/drivers/fs.ts +1 -0
  54. package/src/testing/index.ts +3 -3
  55. package/src/ui/components/patterns/action-list-dialog.tsx +10 -3
  56. package/src/ui/components/patterns/confirm.tsx +2 -31
  57. package/src/ui/components/patterns/content-header.tsx +44 -137
  58. package/src/ui/components/patterns/data-state.tsx +42 -68
  59. package/src/ui/components/patterns/dock.tsx +20 -3
  60. package/src/ui/components/patterns/form.tsx +26 -22
  61. package/src/ui/components/patterns/list.tsx +18 -12
  62. package/src/ui/components/patterns/page-state.tsx +39 -51
  63. package/src/ui/components/patterns/page.tsx +37 -54
  64. package/src/ui/components/patterns/sidebar.tsx +17 -6
  65. package/src/ui/components/patterns/state-surface.tsx +148 -0
  66. package/src/ui/components/patterns/surface-header.tsx +119 -0
  67. package/src/ui/components/patterns/trigger.tsx +21 -25
  68. package/src/ui/components/patterns/view.tsx +29 -22
  69. package/src/ui/components/primitives/alert.tsx +1 -27
  70. package/src/ui/components/primitives/ask.tsx +3 -3
  71. package/src/ui/components/primitives/avatar.tsx +15 -5
  72. package/src/ui/components/primitives/badge.tsx +5 -41
  73. package/src/ui/components/primitives/breadcrumb.tsx +2 -2
  74. package/src/ui/components/primitives/button.tsx +39 -30
  75. package/src/ui/components/primitives/calendar.tsx +28 -2
  76. package/src/ui/components/primitives/carousel.tsx +3 -3
  77. package/src/ui/components/primitives/chat.tsx +1 -1
  78. package/src/ui/components/primitives/checkbox.tsx +1 -1
  79. package/src/ui/components/primitives/command.tsx +2 -2
  80. package/src/ui/components/primitives/control.ts +72 -0
  81. package/src/ui/components/primitives/copyable.tsx +1 -1
  82. package/src/ui/components/primitives/dialog.tsx +12 -7
  83. package/src/ui/components/primitives/dot.tsx +1 -25
  84. package/src/ui/components/primitives/drawer.tsx +10 -3
  85. package/src/ui/components/primitives/field.tsx +3 -3
  86. package/src/ui/components/primitives/icon-picker.tsx +3 -1
  87. package/src/ui/components/primitives/input-group.tsx +12 -9
  88. package/src/ui/components/primitives/input-otp.tsx +1 -1
  89. package/src/ui/components/primitives/input.tsx +2 -2
  90. package/src/ui/components/primitives/item.tsx +3 -1
  91. package/src/ui/components/primitives/menu.tsx +1 -7
  92. package/src/ui/components/primitives/pagination.tsx +16 -8
  93. package/src/ui/components/primitives/progress.tsx +32 -3
  94. package/src/ui/components/primitives/radio-group.tsx +1 -1
  95. package/src/ui/components/primitives/resizable.tsx +3 -1
  96. package/src/ui/components/primitives/select.tsx +6 -6
  97. package/src/ui/components/primitives/slider.tsx +5 -1
  98. package/src/ui/components/primitives/sonner.tsx +3 -0
  99. package/src/ui/components/primitives/spinner.tsx +13 -16
  100. package/src/ui/components/primitives/switch.tsx +5 -1
  101. package/src/ui/components/primitives/tabs.tsx +6 -3
  102. package/src/ui/components/primitives/textarea.tsx +1 -1
  103. package/src/ui/components/primitives/toggle.tsx +9 -4
  104. package/src/ui/components/primitives/tooltip.tsx +1 -0
  105. package/src/ui/docs/changelog.tsx +1 -1
  106. package/src/ui/docs/content/action-form.md +36 -3
  107. package/src/ui/docs/content/action-list-dialog.md +2 -2
  108. package/src/ui/docs/content/action-list.md +13 -2
  109. package/src/ui/docs/content/action-trigger.md +12 -5
  110. package/src/ui/docs/content/action-view.md +11 -3
  111. package/src/ui/docs/content/ask.md +11 -0
  112. package/src/ui/docs/content/avatar.md +7 -3
  113. package/src/ui/docs/content/button.md +30 -14
  114. package/src/ui/docs/content/calendar.md +13 -0
  115. package/src/ui/docs/content/card.md +26 -0
  116. package/src/ui/docs/content/chat.md +20 -0
  117. package/src/ui/docs/content/cli.md +71 -19
  118. package/src/ui/docs/content/communication.md +36 -0
  119. package/src/ui/docs/content/composer.md +15 -0
  120. package/src/ui/docs/content/content.md +17 -1
  121. package/src/ui/docs/content/copyable.md +8 -0
  122. package/src/ui/docs/content/data-state.md +17 -13
  123. package/src/ui/docs/content/detail.md +19 -1
  124. package/src/ui/docs/content/dialog.md +1 -4
  125. package/src/ui/docs/content/dictionary-value.md +9 -2
  126. package/src/ui/docs/content/dock.md +8 -0
  127. package/src/ui/docs/content/dot.md +8 -0
  128. package/src/ui/docs/content/empty.md +2 -2
  129. package/src/ui/docs/content/getting-started.md +2 -2
  130. package/src/ui/docs/content/icon-picker.md +11 -0
  131. package/src/ui/docs/content/input.md +1 -1
  132. package/src/ui/docs/content/item.md +1 -1
  133. package/src/ui/docs/content/label.md +7 -0
  134. package/src/ui/docs/content/menu.md +6 -0
  135. package/src/ui/docs/content/metric-card.md +13 -0
  136. package/src/ui/docs/content/page.md +20 -4
  137. package/src/ui/docs/content/pagination.md +11 -9
  138. package/src/ui/docs/content/popover.md +6 -0
  139. package/src/ui/docs/content/progress.md +8 -11
  140. package/src/ui/docs/content/select.md +5 -5
  141. package/src/ui/docs/content/semantic-context.md +5 -4
  142. package/src/ui/docs/content/sidebar.md +13 -47
  143. package/src/ui/docs/content/skeleton.md +6 -0
  144. package/src/ui/docs/content/spinner.md +9 -6
  145. package/src/ui/docs/content/split.md +21 -0
  146. package/src/ui/docs/content/switch.md +1 -1
  147. package/src/ui/docs/content/tabs.md +1 -1
  148. package/src/ui/docs/content/textarea.md +7 -0
  149. package/src/ui/docs/content/toggle.md +1 -1
  150. package/src/ui/docs/content/tokens.md +4 -4
  151. package/src/ui/docs/content/truncate.md +8 -0
  152. package/src/ui/docs/content/ui.md +14 -0
  153. package/src/ui/docs/doc-client.tsx +5 -5
  154. package/src/ui/docs/doc.tsx +26 -14
  155. package/src/ui/docs/registry.tsx +5 -5
  156. package/src/ui/docs/standalone.tsx +2 -2
  157. package/src/ui/drivers/react.tsx +12 -12
  158. package/src/ui/lib/action-errors.ts +45 -0
  159. package/src/ui/lib/zod-pt-br.ts +31 -4
  160. package/src/ui/meta.ts +8 -8
  161. package/src/ui/react.tsx +10 -16
  162. package/src/ui/theme.css +10 -8
  163. package/src/vite/design.ts +6 -18
  164. package/src/ui/components/patterns/shell-nav.tsx +0 -147
@@ -309,8 +309,12 @@ export interface ActionListProps<TInput, TItem> {
309
309
  /** Itens por página (o pattern manda `limit` e `page` no input; o handler implementa
310
310
  * o OFFSET e devolve `total`). O rodapé mostra o total e o pager quando transborda. */
311
311
  pageSize?: number;
312
- /** Mensagem de empty state. Default: "Nenhum resultado." */
312
+ /** Mensagem do estado vazio. Default: "Nenhum resultado." */
313
313
  emptyMessage?: string;
314
+ /** Mensagem do estado de erro (calma, sem o texto técnico). Default do DataState. */
315
+ errorMessage?: string;
316
+ /** Rótulo do botão de recuperação no erro. Default: "Tentar de novo". */
317
+ retryLabel?: string;
314
318
  /** Sobrepõe o vazio derivado (items.length === 0) — ex.: form inline aberto conta
315
319
  * como conteúdo, então os children devem renderizar mesmo com a lista vazia. */
316
320
  empty?: (items: TItem[]) => boolean;
@@ -754,7 +758,7 @@ function PeriodControl({
754
758
  <Button
755
759
  key={p.value}
756
760
  size="sm"
757
- context={period === p.value ? "neutral" : "primary"}
761
+ context={period === p.value ? "primary" : "neutral"}
758
762
  variant={period === p.value ? "solid" : "ghost"}
759
763
  className="justify-start"
760
764
  onClick={() => {
@@ -1138,6 +1142,8 @@ export function ActionList<TInput, TItem>({
1138
1142
  rowId = (item: TItem) => String((item as { id?: unknown }).id ?? ""),
1139
1143
  pageSize = 50,
1140
1144
  emptyMessage = "Nenhum resultado.",
1145
+ errorMessage,
1146
+ retryLabel,
1141
1147
  empty,
1142
1148
  loading: extraLoading,
1143
1149
  onRowClick,
@@ -1428,6 +1434,7 @@ export function ActionList<TInput, TItem>({
1428
1434
  <PopoverTrigger asChild>
1429
1435
  <Button
1430
1436
  variant="outline"
1437
+ size="icon"
1431
1438
  className="shrink-0"
1432
1439
  aria-label="Exibição"
1433
1440
  >
@@ -1628,7 +1635,9 @@ export function ActionList<TInput, TItem>({
1628
1635
  loading={busy && items.length === 0 && !(empty !== undefined && !isEmpty)}
1629
1636
  error={isError ? error : null}
1630
1637
  empty={isEmpty}
1631
- emptyText={emptyMessage}
1638
+ emptyMessage={emptyMessage}
1639
+ {...(errorMessage !== undefined ? { errorMessage } : {})}
1640
+ {...(retryLabel !== undefined ? { retryLabel } : {})}
1632
1641
  onRetry={() => refetch()}
1633
1642
  >
1634
1643
  {content}
@@ -1696,15 +1705,14 @@ export function ActionList<TInput, TItem>({
1696
1705
  Página {state.page} de {pages}
1697
1706
  </span>
1698
1707
  {pages > 1 && (
1699
- // A primitiva pública na escala densa do rodapé: setas quadradas `size-7`,
1708
+ // A primitiva pública na escala densa do rodapé: setas no quadrado `icon-sm`,
1700
1709
  // números `h-7 min-w-7` que crescem com os dígitos (5726 e 5727 não se colam).
1701
1710
  <Pagination className="mx-0 w-auto">
1702
1711
  <PaginationContent className="gap-0.5">
1703
1712
  <PaginationItem>
1704
1713
  <PaginationPrevious
1705
1714
  iconOnly
1706
- className="size-7"
1707
- iconClassName="size-3.5"
1715
+ size="icon-sm"
1708
1716
  disabled={state.page <= 1}
1709
1717
  onClick={() => goTo(state.page - 1)}
1710
1718
  />
@@ -1713,7 +1721,7 @@ export function ActionList<TInput, TItem>({
1713
1721
  n === "…" ? (
1714
1722
  // eslint-disable-next-line react/no-array-index-key
1715
1723
  <PaginationItem key={`e${i}`}>
1716
- <PaginationEllipsis className="size-7" />
1724
+ <PaginationEllipsis size="icon-sm" />
1717
1725
  </PaginationItem>
1718
1726
  ) : (
1719
1727
  <PaginationItem key={n}>
@@ -1729,8 +1737,7 @@ export function ActionList<TInput, TItem>({
1729
1737
  <PaginationItem>
1730
1738
  <PaginationNext
1731
1739
  iconOnly
1732
- className="size-7"
1733
- iconClassName="size-3.5"
1740
+ size="icon-sm"
1734
1741
  disabled={state.page >= pages}
1735
1742
  onClick={() => goTo(state.page + 1)}
1736
1743
  />
@@ -1790,9 +1797,8 @@ export function ActionList<TInput, TItem>({
1790
1797
  <DialogClose asChild>
1791
1798
  <Button
1792
1799
  size="sm"
1793
- variant={
1794
- confirming.destructive === true ? "destructive" : "default"
1795
- }
1800
+ context={confirming.destructive === true ? "danger" : "primary"}
1801
+ variant="solid"
1796
1802
  onClick={() => {
1797
1803
  const a = confirming;
1798
1804
  setConfirming(null);
@@ -3,22 +3,18 @@
3
3
  *
4
4
  * Page continua responsável somente pelo esqueleto. Na forma curta, PageState pode ser escrito
5
5
  * como filho direto porque Page cria PageBody; na composição explícita, ele fica dentro de
6
- * PageBody. Estados de uma seção ou coleção continuam em DataState, ActionView e ActionList.
6
+ * PageBody. Estados de uma seção ou coleção continuam em DataState, ActionView e ActionList
7
+ * as superfícies são as mesmas (state-surface.tsx), aqui na escala da página.
7
8
  */
8
9
 
9
10
  import type { ReactNode } from "react";
10
- import { TriangleAlert } from "lucide-react";
11
11
  import { cn } from "../../lib/cn.ts";
12
- import { Alert } from "../primitives/alert.tsx";
13
12
  import {
14
- Empty,
15
- EmptyActions,
16
- EmptyDescription,
17
- EmptyHeader,
18
- EmptyMedia,
19
- EmptyTitle,
20
- } from "../primitives/empty.tsx";
21
- import { Spinner } from "../primitives/spinner.tsx";
13
+ DEFAULT_RETRY_LABEL,
14
+ EmptySurface,
15
+ ErrorSurface,
16
+ LoadingSurface,
17
+ } from "./state-surface.tsx";
22
18
 
23
19
  export type PageStateStatus = "loading" | "error" | "empty" | "ready";
24
20
 
@@ -30,8 +26,16 @@ export interface PageStateProps {
30
26
  description?: ReactNode;
31
27
  /** Ícone decorativo. Error usa TriangleAlert por default; empty não inventa um ícone. */
32
28
  icon?: ReactNode;
33
- /** Ação aplicável ao estado, como tentar novamente, escolher ou criar. */
29
+ /** Ação aplicável ao estado, como escolher ou criar. */
34
30
  action?: ReactNode;
31
+ /** Título do vazio quando `title` não for informado. Default: "Nada por aqui". */
32
+ emptyMessage?: string;
33
+ /** Título do erro quando `title` não for informado. Default: "Não foi possível carregar esta página". */
34
+ errorMessage?: string;
35
+ /** Recuperação do erro: com `onRetry`, o botão de tentar de novo entra ao lado de `action`. */
36
+ onRetry?: () => Promise<void> | void;
37
+ /** Rótulo do botão de recuperação. Default: "Tentar de novo". */
38
+ retryLabel?: string;
35
39
  /** Conteúdo normal da página, renderizado somente em ready. */
36
40
  children?: ReactNode;
37
41
  className?: string;
@@ -43,6 +47,10 @@ export function PageState({
43
47
  description,
44
48
  icon,
45
49
  action,
50
+ emptyMessage = "Nada por aqui",
51
+ errorMessage = "Não foi possível carregar esta página",
52
+ onRetry,
53
+ retryLabel = DEFAULT_RETRY_LABEL,
46
54
  children,
47
55
  className,
48
56
  }: PageStateProps): React.ReactElement {
@@ -50,30 +58,12 @@ export function PageState({
50
58
 
51
59
  if (status === "loading") {
52
60
  return (
53
- <div
54
- data-slot="page-state"
55
- data-status="loading"
56
- role="status"
57
- aria-live="polite"
58
- className={cn(
59
- "flex min-h-64 flex-col items-center justify-center gap-3 text-center",
60
- className,
61
- )}
62
- >
63
- <Spinner aria-hidden="true" aria-label={undefined} role="presentation" />
64
- <div className="max-w-md space-y-1">
65
- <p data-slot="page-state-title" className="text-sm font-medium">
66
- {title ?? "Carregando…"}
67
- </p>
68
- {description !== undefined && (
69
- <p
70
- data-slot="page-state-description"
71
- className="text-sm text-muted-foreground"
72
- >
73
- {description}
74
- </p>
75
- )}
76
- </div>
61
+ <div data-slot="page-state" data-status="loading" className={className}>
62
+ <LoadingSurface
63
+ className="min-h-64"
64
+ title={title ?? "Carregando…"}
65
+ description={description}
66
+ />
77
67
  </div>
78
68
  );
79
69
  }
@@ -85,31 +75,29 @@ export function PageState({
85
75
  data-status="error"
86
76
  className={cn("flex min-h-64 items-center justify-center", className)}
87
77
  >
88
- <Alert
78
+ <ErrorSurface
89
79
  className="max-w-xl"
90
- context="danger"
91
- icon={icon ?? <TriangleAlert />}
92
- title={title ?? "Não foi possível carregar esta página"}
80
+ icon={icon}
81
+ title={title ?? errorMessage}
93
82
  description={description}
83
+ {...(onRetry !== undefined ? { onRetry } : {})}
84
+ retryLabel={retryLabel}
94
85
  >
95
86
  {action}
96
- </Alert>
87
+ </ErrorSurface>
97
88
  </div>
98
89
  );
99
90
  }
100
91
 
101
92
  return (
102
93
  <div data-slot="page-state" data-status="empty" className={className}>
103
- <Empty className="min-h-64">
104
- <EmptyHeader>
105
- {icon !== undefined && <EmptyMedia variant="icon">{icon}</EmptyMedia>}
106
- <EmptyTitle>{title ?? "Nada por aqui"}</EmptyTitle>
107
- {description !== undefined && (
108
- <EmptyDescription>{description}</EmptyDescription>
109
- )}
110
- </EmptyHeader>
111
- {action !== undefined && <EmptyActions>{action}</EmptyActions>}
112
- </Empty>
94
+ <EmptySurface
95
+ className="min-h-64"
96
+ icon={icon}
97
+ title={title ?? emptyMessage}
98
+ description={description}
99
+ action={action}
100
+ />
113
101
  </div>
114
102
  );
115
103
  }
@@ -14,10 +14,27 @@ import {
14
14
  type ReactElement,
15
15
  type ReactNode,
16
16
  } from "react";
17
+ import { createPortal } from "react-dom";
17
18
  import { cn } from "../../lib/cn.ts";
19
+ import { SurfaceHeader, surfaceHeaderClasses } from "./surface-header.tsx";
18
20
 
19
21
  const PageContext = createContext(false);
20
22
  const PageHeaderContext = createContext(false);
23
+ const PageActionsTargetContext = createContext<HTMLElement | null>(null);
24
+
25
+ export function PageActionsTarget({
26
+ target,
27
+ children,
28
+ }: {
29
+ target: HTMLElement | null;
30
+ children: ReactNode;
31
+ }): ReactElement {
32
+ return (
33
+ <PageActionsTargetContext.Provider value={target}>
34
+ {children}
35
+ </PageActionsTargetContext.Provider>
36
+ );
37
+ }
21
38
 
22
39
  function requireParent(
23
40
  inside: boolean,
@@ -94,7 +111,7 @@ export function Page({
94
111
  return (
95
112
  <PageContext.Provider value>
96
113
  <main data-slot="page" className="min-w-0 flex-1" {...props}>
97
- <div className={cn("mx-auto max-w-7xl px-8 py-8", className)}>
114
+ <div className={cn("mx-auto max-w-7xl space-y-6 px-8 py-8", className)}>
98
115
  {shorthand ? (
99
116
  <>
100
117
  <PageHeader>
@@ -116,60 +133,27 @@ export function Page({
116
133
  );
117
134
  }
118
135
 
136
+ /** A anatomia é a de `SurfaceHeader`, a mesma de `ContentHeader`; só os slots mudam de nome. */
119
137
  export function PageHeader({
120
- className,
121
138
  children,
122
139
  ...props
123
140
  }: HTMLAttributes<HTMLDivElement>): ReactElement {
124
141
  requireParent(useContext(PageContext), "PageHeader", "Page");
125
- const nodes = Children.toArray(children);
126
- const titles = nodes.filter(
127
- (node) => isValidElement(node) && node.type === PageTitle,
128
- );
129
- const metas = nodes.filter(
130
- (node) => isValidElement(node) && node.type === PageMeta,
131
- );
132
- const descriptions = nodes.filter(
133
- (node) => isValidElement(node) && node.type === PageDescription,
134
- );
135
- const actionSlots = nodes.filter(
136
- (node) => isValidElement(node) && node.type === PageActions,
137
- );
138
- const recognized =
139
- titles.length + metas.length + descriptions.length + actionSlots.length;
140
- if (
141
- titles.length !== 1 ||
142
- metas.length > 1 ||
143
- descriptions.length > 1 ||
144
- actionSlots.length > 1 ||
145
- recognized !== nodes.length
146
- ) {
147
- throw new Error(
148
- "PageHeader exige um PageTitle e aceita no máximo um PageDescription, PageMeta e PageActions como filhos diretos.",
149
- );
150
- }
151
-
152
142
  return (
153
143
  <PageHeaderContext.Provider value>
154
- <div
155
- data-slot="page-header"
156
- className={cn(
157
- "mb-6",
158
- actionSlots.length > 0 &&
159
- "flex flex-col items-start gap-4 sm:flex-row sm:items-end sm:justify-between",
160
- className,
161
- )}
144
+ <SurfaceHeader
145
+ name="PageHeader"
146
+ slot="page"
147
+ slots={{
148
+ title: PageTitle,
149
+ count: PageMeta,
150
+ description: PageDescription,
151
+ actions: PageActions,
152
+ }}
162
153
  {...props}
163
154
  >
164
- <div data-slot="page-heading" className="min-w-0">
165
- <div className="flex items-baseline gap-2">
166
- {titles}
167
- {metas}
168
- </div>
169
- {descriptions}
170
- </div>
171
- {actionSlots}
172
- </div>
155
+ {children}
156
+ </SurfaceHeader>
173
157
  </PageHeaderContext.Provider>
174
158
  );
175
159
  }
@@ -182,7 +166,7 @@ export function PageTitle({
182
166
  return (
183
167
  <h1
184
168
  data-slot="page-title"
185
- className={cn("text-2xl font-semibold tracking-tight", className)}
169
+ className={cn(surfaceHeaderClasses.page.title, className)}
186
170
  {...props}
187
171
  />
188
172
  );
@@ -196,7 +180,7 @@ export function PageDescription({
196
180
  return (
197
181
  <p
198
182
  data-slot="page-description"
199
- className={cn("mt-1 truncate text-sm text-muted-foreground", className)}
183
+ className={cn(surfaceHeaderClasses.page.description, className)}
200
184
  {...props}
201
185
  />
202
186
  );
@@ -210,7 +194,7 @@ export function PageMeta({
210
194
  return (
211
195
  <span
212
196
  data-slot="page-meta"
213
- className={cn("font-mono text-base text-muted-foreground/60", className)}
197
+ className={cn(surfaceHeaderClasses.page.count, className)}
214
198
  {...props}
215
199
  />
216
200
  );
@@ -221,16 +205,15 @@ export function PageActions({
221
205
  ...props
222
206
  }: HTMLAttributes<HTMLDivElement>): ReactElement {
223
207
  requireParent(useContext(PageHeaderContext), "PageActions", "PageHeader");
224
- return (
208
+ const target = useContext(PageActionsTargetContext);
209
+ const actions = (
225
210
  <div
226
211
  data-slot="page-actions"
227
- className={cn(
228
- "flex w-full shrink-0 items-center gap-4 sm:w-auto",
229
- className,
230
- )}
212
+ className={cn(surfaceHeaderClasses.page.actions, className)}
231
213
  {...props}
232
214
  />
233
215
  );
216
+ return target === null ? actions : createPortal(actions, target);
234
217
  }
235
218
 
236
219
  export function PageBody({
@@ -7,6 +7,7 @@ import {
7
7
  import { ChevronRight } from "lucide-react";
8
8
  import { useOverflowing } from "../../lib/overflow.ts";
9
9
  import { cn } from "../../lib/cn.ts";
10
+ import { focusRing } from "../primitives/control.ts";
10
11
  import {
11
12
  Tooltip,
12
13
  TooltipContent,
@@ -177,17 +178,24 @@ export function SidebarItem({
177
178
  <button
178
179
  type="button"
179
180
  disabled={disabled}
181
+ aria-label={label}
180
182
  aria-current={active ? "page" : undefined}
181
183
  onClick={onClick}
182
184
  className={cn(
183
185
  "mx-auto grid size-9 place-items-center rounded-md transition-colors disabled:pointer-events-none disabled:opacity-40",
184
- "outline-none focus-visible:ring-2 focus-visible:ring-ring/50",
186
+ "outline-none",
187
+ focusRing,
185
188
  active
186
189
  ? "bg-muted font-medium text-foreground"
187
190
  : "text-foreground/80 hover:bg-muted/60",
188
191
  )}
189
192
  >
190
- {icon}
193
+ <span
194
+ data-slot="sidebar-item-icon"
195
+ className="grid size-4 shrink-0 place-items-center [&_svg]:size-4"
196
+ >
197
+ {icon}
198
+ </span>
191
199
  </button>
192
200
  );
193
201
  // `className` vai no MESMO elemento nos dois modos — a linha —, senão a prop mudaria de alvo
@@ -235,8 +243,9 @@ export function SidebarItem({
235
243
  {/* Sem ícone, o chevron não se esconde: ele é o único ocupante do lugar. */}
236
244
  {icon !== undefined && (
237
245
  <span
246
+ data-slot="sidebar-item-icon"
238
247
  className={cn(
239
- "absolute inset-0 flex items-center justify-center transition-opacity",
248
+ "absolute inset-0 flex items-center justify-center transition-opacity [&_svg]:size-4",
240
249
  onToggle !== undefined &&
241
250
  "group-hover/sidebar-item:opacity-0 group-has-[[data-slot=sidebar-item-toggle]:focus-visible]/sidebar-item:opacity-0",
242
251
  )}
@@ -252,7 +261,8 @@ export function SidebarItem({
252
261
  aria-label={expanded ? `Fechar ${label}` : `Abrir ${label}`}
253
262
  aria-expanded={expanded}
254
263
  className={cn(
255
- "absolute inset-0 grid place-items-center rounded-sm text-muted-foreground transition-[color,opacity] outline-none hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-ring/50",
264
+ "absolute inset-0 grid place-items-center rounded-sm text-muted-foreground transition-[color,opacity] outline-none hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100",
265
+ focusRing,
256
266
  icon === undefined
257
267
  ? "pointer-events-auto opacity-100"
258
268
  : "pointer-events-none opacity-0 group-hover/sidebar-item:pointer-events-auto group-hover/sidebar-item:opacity-100",
@@ -275,7 +285,8 @@ export function SidebarItem({
275
285
  onClick={onClick}
276
286
  className={cn(
277
287
  "flex min-w-0 flex-1 items-center gap-2.5 rounded-md px-2.5 py-1.5 text-left text-sm disabled:pointer-events-none",
278
- "outline-none focus-visible:ring-2 focus-visible:ring-ring/50",
288
+ "outline-none",
289
+ focusRing,
279
290
  disabled
280
291
  ? "text-muted-foreground/50"
281
292
  : active
@@ -349,7 +360,7 @@ export function SidebarGroupLabel({
349
360
  <div
350
361
  data-slot="sidebar-group-label"
351
362
  className={cn(
352
- "px-2.5 pb-1 text-sm font-medium text-muted-foreground/70",
363
+ "px-2.5 py-1 text-sm font-medium text-muted-foreground/70",
353
364
  className,
354
365
  )}
355
366
  >
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Superfícies de estado compartilhadas por DataState, PageState e ActionView (interno).
3
+ *
4
+ * Uma composição só para cada estado: carregar é um contêiner `role="status"` com o Spinner
5
+ * (decorativo — o status é um só); erro é `Alert context="danger"` com o botão de recuperação;
6
+ * vazio compõe `Empty*`. Os patterns públicos só escolhem o enquadramento (bloco, linha de tabela,
7
+ * página inteira) e os textos. Não exportado no barrel: é anatomia, não API.
8
+ */
9
+
10
+ import type { ReactElement, ReactNode } from 'react'
11
+ import { TriangleAlert } from 'lucide-react'
12
+ import { cn } from '../../lib/cn.ts'
13
+ import { Alert } from '../primitives/alert.tsx'
14
+ import { Button } from '../primitives/button.tsx'
15
+ import {
16
+ Empty,
17
+ EmptyActions,
18
+ EmptyDescription,
19
+ EmptyHeader,
20
+ EmptyMedia,
21
+ EmptyTitle,
22
+ } from '../primitives/empty.tsx'
23
+ import { Spinner } from '../primitives/spinner.tsx'
24
+
25
+ export const DEFAULT_EMPTY_MESSAGE = 'Nada por aqui.'
26
+ export const DEFAULT_ERROR_MESSAGE = 'Não foi possível carregar.'
27
+ export const DEFAULT_RETRY_LABEL = 'Tentar de novo'
28
+
29
+ export interface LoadingSurfaceProps {
30
+ /** Texto visível ao lado do Spinner; sem ele, só o contêiner nomeia a espera. */
31
+ title?: ReactNode
32
+ description?: ReactNode
33
+ className?: string
34
+ }
35
+
36
+ /** O contêiner é o único `role="status"`; o Spinner dentro dele é decorativo. */
37
+ export function LoadingSurface({ title, description, className }: LoadingSurfaceProps): ReactElement {
38
+ return (
39
+ <div
40
+ role="status"
41
+ aria-live="polite"
42
+ aria-label={title === undefined ? 'Carregando' : undefined}
43
+ className={cn('flex flex-col items-center justify-center gap-3 text-center text-muted-foreground', className)}
44
+ >
45
+ <Spinner />
46
+ {(title !== undefined || description !== undefined) && (
47
+ <div className="max-w-md space-y-1">
48
+ {title !== undefined && (
49
+ <p data-slot="state-title" className="text-sm font-medium text-foreground">
50
+ {title}
51
+ </p>
52
+ )}
53
+ {description !== undefined && (
54
+ <p data-slot="state-description" className="text-sm text-muted-foreground">
55
+ {description}
56
+ </p>
57
+ )}
58
+ </div>
59
+ )}
60
+ </div>
61
+ )
62
+ }
63
+
64
+ export interface ErrorSurfaceProps {
65
+ /** A frase segura para a pessoa; o texto técnico fica no console e no `cause`. */
66
+ title: ReactNode
67
+ description?: ReactNode
68
+ icon?: ReactNode
69
+ /** Recuperação: com `onRetry`, o botão entra nas ações do Alert. */
70
+ onRetry?: () => Promise<void> | void
71
+ retryLabel?: string
72
+ /** Outras ações do estado, ao lado do botão de recuperação. */
73
+ children?: ReactNode
74
+ className?: string
75
+ }
76
+
77
+ export function ErrorSurface({
78
+ title,
79
+ description,
80
+ icon,
81
+ onRetry,
82
+ retryLabel = DEFAULT_RETRY_LABEL,
83
+ children,
84
+ className,
85
+ }: ErrorSurfaceProps): ReactElement {
86
+ return (
87
+ <Alert
88
+ context="danger"
89
+ icon={icon ?? <TriangleAlert />}
90
+ title={title}
91
+ description={description}
92
+ className={className}
93
+ >
94
+ {onRetry !== undefined && (
95
+ <Button context="neutral" variant="outline" size="sm" onClick={() => void onRetry()}>
96
+ {retryLabel}
97
+ </Button>
98
+ )}
99
+ {children}
100
+ </Alert>
101
+ )
102
+ }
103
+
104
+ export interface EmptySurfaceProps {
105
+ /** Situação reconhecível: a frase curta do vazio. */
106
+ title: ReactNode
107
+ description?: ReactNode
108
+ icon?: ReactNode
109
+ action?: ReactNode
110
+ /**
111
+ * `region`: a moldura tracejada de Empty (área disponível para criar ou vincular).
112
+ * `structural`: a moldura sólida de uma coleção sem registros; `bare`: sem moldura, quando a
113
+ * estrutura ao redor (tabela) já a fornece.
114
+ */
115
+ frame?: 'region' | 'structural' | 'bare'
116
+ /** Título como frase discreta (seção) em vez do título grande (página). */
117
+ compact?: boolean
118
+ className?: string
119
+ }
120
+
121
+ export function EmptySurface({
122
+ title,
123
+ description,
124
+ icon,
125
+ action,
126
+ frame = 'region',
127
+ compact = false,
128
+ className,
129
+ }: EmptySurfaceProps): ReactElement {
130
+ return (
131
+ <Empty
132
+ data-frame={frame}
133
+ className={cn(
134
+ frame === 'structural' && 'border-solid',
135
+ frame === 'bare' && 'border-0 p-0 md:p-0',
136
+ compact && 'gap-4 p-6 md:p-6',
137
+ className,
138
+ )}
139
+ >
140
+ <EmptyHeader>
141
+ {icon !== undefined && <EmptyMedia variant="icon">{icon}</EmptyMedia>}
142
+ <EmptyTitle className={cn(compact && 'text-sm font-normal text-muted-foreground')}>{title}</EmptyTitle>
143
+ {description !== undefined && <EmptyDescription>{description}</EmptyDescription>}
144
+ </EmptyHeader>
145
+ {action !== undefined && <EmptyActions>{action}</EmptyActions>}
146
+ </Empty>
147
+ )
148
+ }