@softize/opus 13.1.0 → 15.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 (72) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/bin/cli.mjs +2 -0
  3. package/bin/lib/check.mjs +33 -5
  4. package/bin/lib/cli-shared.mjs +30 -1
  5. package/bin/lib/copy.mjs +279 -6
  6. package/bin/lib/db.mjs +2 -0
  7. package/docs/adr/0004-page-content-state-is-composed.md +39 -5
  8. package/docs/adr/0005-structural-surfaces-share-an-explicit-anatomy.md +9 -3
  9. package/docs/adr/0009-page-title-does-not-carry-a-counter.md +57 -0
  10. package/docs/adr/0010-page-header-owns-page-chrome.md +73 -0
  11. package/docs/code-style.md +4 -1
  12. package/docs/data-layer.md +9 -0
  13. package/package.json +1 -1
  14. package/registry/instructions/opus.md +3 -3
  15. package/registry/skills/build-opus-ui/SKILL.md +3 -2
  16. package/registry/skills/build-opus-ui/references/ui-patterns.md +17 -6
  17. package/registry/templates/app/src/App.tsx +11 -6
  18. package/src/core/types.ts +3 -4
  19. package/src/ui/components/patterns/action-list-dialog.tsx +10 -3
  20. package/src/ui/components/patterns/confirm.tsx +2 -31
  21. package/src/ui/components/patterns/content-header.tsx +42 -141
  22. package/src/ui/components/patterns/data-state.tsx +42 -68
  23. package/src/ui/components/patterns/form.tsx +15 -15
  24. package/src/ui/components/patterns/list.tsx +55 -34
  25. package/src/ui/components/patterns/page-state.tsx +81 -51
  26. package/src/ui/components/patterns/page.tsx +228 -97
  27. package/src/ui/components/patterns/state-surface.tsx +262 -0
  28. package/src/ui/components/patterns/surface-header.tsx +204 -0
  29. package/src/ui/components/patterns/trigger.tsx +9 -10
  30. package/src/ui/components/patterns/view.tsx +14 -16
  31. package/src/ui/components/primitives/alert.tsx +1 -33
  32. package/src/ui/components/primitives/avatar.tsx +15 -5
  33. package/src/ui/components/primitives/badge.tsx +2 -43
  34. package/src/ui/components/primitives/button-group.tsx +34 -8
  35. package/src/ui/components/primitives/button.tsx +31 -35
  36. package/src/ui/components/primitives/control.ts +69 -0
  37. package/src/ui/components/primitives/dot.tsx +1 -30
  38. package/src/ui/components/primitives/input-group.tsx +11 -8
  39. package/src/ui/components/primitives/item.tsx +3 -1
  40. package/src/ui/components/primitives/menu.tsx +1 -7
  41. package/src/ui/components/primitives/pagination.tsx +16 -8
  42. package/src/ui/components/primitives/select.tsx +2 -2
  43. package/src/ui/components/primitives/spinner.tsx +13 -16
  44. package/src/ui/components/primitives/switch.tsx +4 -1
  45. package/src/ui/components/primitives/tabs.tsx +5 -3
  46. package/src/ui/components/primitives/toggle.tsx +9 -4
  47. package/src/ui/docs/content/action-form.md +26 -0
  48. package/src/ui/docs/content/action-list-dialog.md +2 -2
  49. package/src/ui/docs/content/action-list.md +35 -2
  50. package/src/ui/docs/content/action-trigger.md +5 -4
  51. package/src/ui/docs/content/action-view.md +3 -2
  52. package/src/ui/docs/content/alert.md +16 -4
  53. package/src/ui/docs/content/avatar.md +7 -3
  54. package/src/ui/docs/content/button.md +48 -17
  55. package/src/ui/docs/content/communication.md +36 -0
  56. package/src/ui/docs/content/content.md +5 -4
  57. package/src/ui/docs/content/data-state.md +17 -13
  58. package/src/ui/docs/content/dialog.md +1 -4
  59. package/src/ui/docs/content/input.md +1 -1
  60. package/src/ui/docs/content/item.md +1 -1
  61. package/src/ui/docs/content/page.md +160 -37
  62. package/src/ui/docs/content/pagination.md +11 -9
  63. package/src/ui/docs/content/semantic-context.md +3 -2
  64. package/src/ui/docs/content/sidebar.md +2 -42
  65. package/src/ui/docs/content/spinner.md +9 -6
  66. package/src/ui/docs/content/switch.md +1 -1
  67. package/src/ui/docs/content/tabs.md +1 -1
  68. package/src/ui/docs/content/toggle.md +1 -1
  69. package/src/ui/drivers/react.tsx +1 -6
  70. package/src/ui/meta.ts +8 -8
  71. package/src/ui/react.tsx +16 -18
  72. package/src/ui/components/patterns/shell-nav.tsx +0 -154
@@ -74,6 +74,7 @@ import {
74
74
  PaginationPrevious,
75
75
  } from "../primitives/pagination.tsx";
76
76
  import { Button } from "../primitives/button.tsx";
77
+ import { ButtonGroup } from "../primitives/button-group.tsx";
77
78
  import { Calendar } from "../primitives/calendar.tsx";
78
79
  import { Checkbox } from "../primitives/checkbox.tsx";
79
80
  import {
@@ -262,7 +263,9 @@ export interface ActionFilterBarProps {
262
263
  filterOptions?: Record<string, SelectOption[]>;
263
264
  onRefresh?: () => Promise<void> | void;
264
265
  refreshing?: boolean;
265
- /** Ações de apresentação do consumidor, renderizadas depois do refresh. */
266
+ /** Controles auxiliares, agrupados com o refresh em um ButtonGroup espaçado. */
267
+ controls?: ReactNode;
268
+ /** Ações do consumidor, separadas do grupo de controles. */
266
269
  actions?: ReactNode;
267
270
  }
268
271
 
@@ -293,6 +296,8 @@ export interface ActionListProps<TInput, TItem> {
293
296
  action: ListActionLike;
294
297
  /** Input BASE (escopo fixo, ex.: { workspaceId }) — a toolbar soma por cima. */
295
298
  input: TInput;
299
+ /** Ações do consumidor no fim da barra de filtros, depois dos controles de exibição. */
300
+ toolbarActions?: ReactNode;
296
301
  /** Tabela explícita — sobrepõe as colunas do contrato. */
297
302
  columns?: ActionListColumn<TItem>[];
298
303
  /** Células custom POR CIMA das colunas do contrato (chave = column.key). */
@@ -309,8 +314,12 @@ export interface ActionListProps<TInput, TItem> {
309
314
  /** Itens por página (o pattern manda `limit` e `page` no input; o handler implementa
310
315
  * o OFFSET e devolve `total`). O rodapé mostra o total e o pager quando transborda. */
311
316
  pageSize?: number;
312
- /** Mensagem de empty state. Default: "Nenhum resultado." */
317
+ /** Mensagem do estado vazio. Default: "Nenhum resultado." */
313
318
  emptyMessage?: string;
319
+ /** Mensagem do estado de erro (calma, sem o texto técnico). Default do DataState. */
320
+ errorMessage?: string;
321
+ /** Rótulo do botão de recuperação no erro. Default: "Tentar de novo". */
322
+ retryLabel?: string;
314
323
  /** Sobrepõe o vazio derivado (items.length === 0) — ex.: form inline aberto conta
315
324
  * como conteúdo, então os children devem renderizar mesmo com a lista vazia. */
316
325
  empty?: (items: TItem[]) => boolean;
@@ -900,6 +909,7 @@ export function ActionFilterBar({
900
909
  filterOptions,
901
910
  onRefresh,
902
911
  refreshing = false,
912
+ controls,
903
913
  actions,
904
914
  }: ActionFilterBarProps): React.ReactElement {
905
915
  const dicts = useDicts();
@@ -1043,7 +1053,7 @@ export function ActionFilterBar({
1043
1053
  <Tooltip>
1044
1054
  <TooltipTrigger asChild>
1045
1055
  <Button
1046
- variant="outline"
1056
+ variant="ghost"
1047
1057
  size="icon"
1048
1058
  className="shrink-0"
1049
1059
  aria-label="Filtros"
@@ -1057,27 +1067,32 @@ export function ActionFilterBar({
1057
1067
  )}
1058
1068
  <div className="ml-auto flex min-w-0 items-center gap-2">
1059
1069
  {!searchFirst && searchBox}
1060
- {onRefresh !== undefined && (
1061
- <Tooltip>
1062
- <TooltipTrigger asChild>
1063
- <Button
1064
- variant="outline"
1065
- size="icon"
1066
- className="shrink-0"
1067
- aria-label="Recarregar"
1068
- onClick={() => void onRefresh()}
1069
- disabled={refreshing}
1070
- >
1071
- <RefreshCw
1072
- className={cn(
1073
- "h-3.5 w-3.5",
1074
- refreshing && "animate-spin",
1075
- )}
1076
- />
1077
- </Button>
1078
- </TooltipTrigger>
1079
- <TooltipContent>Recarregar</TooltipContent>
1080
- </Tooltip>
1070
+ {(onRefresh !== undefined || controls !== undefined) && (
1071
+ <ButtonGroup mode="spaced">
1072
+ {onRefresh !== undefined && (
1073
+ <Tooltip>
1074
+ <TooltipTrigger asChild>
1075
+ <Button
1076
+ variant="ghost"
1077
+ size="icon"
1078
+ className="shrink-0"
1079
+ aria-label="Recarregar"
1080
+ onClick={() => void onRefresh()}
1081
+ disabled={refreshing}
1082
+ >
1083
+ <RefreshCw
1084
+ className={cn(
1085
+ "h-3.5 w-3.5",
1086
+ refreshing && "animate-spin",
1087
+ )}
1088
+ />
1089
+ </Button>
1090
+ </TooltipTrigger>
1091
+ <TooltipContent>Recarregar</TooltipContent>
1092
+ </Tooltip>
1093
+ )}
1094
+ {controls}
1095
+ </ButtonGroup>
1081
1096
  )}
1082
1097
  {actions}
1083
1098
  </div>
@@ -1130,6 +1145,7 @@ export function ActionFilterBar({
1130
1145
  export function ActionList<TInput, TItem>({
1131
1146
  action,
1132
1147
  input,
1148
+ toolbarActions,
1133
1149
  columns,
1134
1150
  cells,
1135
1151
  filterOptions,
@@ -1138,6 +1154,8 @@ export function ActionList<TInput, TItem>({
1138
1154
  rowId = (item: TItem) => String((item as { id?: unknown }).id ?? ""),
1139
1155
  pageSize = 50,
1140
1156
  emptyMessage = "Nenhum resultado.",
1157
+ errorMessage,
1158
+ retryLabel,
1141
1159
  empty,
1142
1160
  loading: extraLoading,
1143
1161
  onRowClick,
@@ -1388,7 +1406,7 @@ export function ActionList<TInput, TItem>({
1388
1406
  filterOptions={filterOptions}
1389
1407
  onRefresh={() => refetch()}
1390
1408
  refreshing={isLoading}
1391
- actions={
1409
+ controls={
1392
1410
  <>
1393
1411
  {hasSegment && (
1394
1412
  <ToggleGroup
@@ -1427,7 +1445,7 @@ export function ActionList<TInput, TItem>({
1427
1445
  <TooltipTrigger asChild>
1428
1446
  <PopoverTrigger asChild>
1429
1447
  <Button
1430
- variant="outline"
1448
+ variant="ghost"
1431
1449
  size="icon"
1432
1450
  className="shrink-0"
1433
1451
  aria-label="Exibição"
@@ -1511,6 +1529,7 @@ export function ActionList<TInput, TItem>({
1511
1529
  </Popover>
1512
1530
  </>
1513
1531
  }
1532
+ actions={toolbarActions}
1514
1533
  />
1515
1534
  </div>
1516
1535
  ) : null;
@@ -1615,7 +1634,9 @@ export function ActionList<TInput, TItem>({
1615
1634
  className="w-px whitespace-nowrap text-right"
1616
1635
  onClick={(e) => e.stopPropagation()}
1617
1636
  >
1618
- {rowActions(item)}
1637
+ <ButtonGroup mode="spaced" className="ml-auto">
1638
+ {rowActions(item)}
1639
+ </ButtonGroup>
1619
1640
  </TableCell>
1620
1641
  )}
1621
1642
  </TableRow>
@@ -1629,7 +1650,9 @@ export function ActionList<TInput, TItem>({
1629
1650
  loading={busy && items.length === 0 && !(empty !== undefined && !isEmpty)}
1630
1651
  error={isError ? error : null}
1631
1652
  empty={isEmpty}
1632
- emptyText={emptyMessage}
1653
+ emptyMessage={emptyMessage}
1654
+ {...(errorMessage !== undefined ? { errorMessage } : {})}
1655
+ {...(retryLabel !== undefined ? { retryLabel } : {})}
1633
1656
  onRetry={() => refetch()}
1634
1657
  >
1635
1658
  {content}
@@ -1697,15 +1720,14 @@ export function ActionList<TInput, TItem>({
1697
1720
  Página {state.page} de {pages}
1698
1721
  </span>
1699
1722
  {pages > 1 && (
1700
- // A primitiva pública na escala densa do rodapé: setas quadradas `size-7`,
1723
+ // A primitiva pública na escala densa do rodapé: setas no quadrado `icon-sm`,
1701
1724
  // números `h-7 min-w-7` que crescem com os dígitos (5726 e 5727 não se colam).
1702
1725
  <Pagination className="mx-0 w-auto">
1703
1726
  <PaginationContent className="gap-0.5">
1704
1727
  <PaginationItem>
1705
1728
  <PaginationPrevious
1706
1729
  iconOnly
1707
- className="size-7"
1708
- iconClassName="size-3.5"
1730
+ size="icon-sm"
1709
1731
  disabled={state.page <= 1}
1710
1732
  onClick={() => goTo(state.page - 1)}
1711
1733
  />
@@ -1714,7 +1736,7 @@ export function ActionList<TInput, TItem>({
1714
1736
  n === "…" ? (
1715
1737
  // eslint-disable-next-line react/no-array-index-key
1716
1738
  <PaginationItem key={`e${i}`}>
1717
- <PaginationEllipsis className="size-7" />
1739
+ <PaginationEllipsis size="icon-sm" />
1718
1740
  </PaginationItem>
1719
1741
  ) : (
1720
1742
  <PaginationItem key={n}>
@@ -1730,8 +1752,7 @@ export function ActionList<TInput, TItem>({
1730
1752
  <PaginationItem>
1731
1753
  <PaginationNext
1732
1754
  iconOnly
1733
- className="size-7"
1734
- iconClassName="size-3.5"
1755
+ size="icon-sm"
1735
1756
  disabled={state.page >= pages}
1736
1757
  onClick={() => goTo(state.page + 1)}
1737
1758
  />
@@ -3,25 +3,43 @@
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
- import type { ReactNode } from "react";
10
- import { TriangleAlert } from "lucide-react";
10
+ import {
11
+ createContext,
12
+ useContext,
13
+ useLayoutEffect,
14
+ type ReactNode,
15
+ } from "react";
11
16
  import { cn } from "../../lib/cn.ts";
12
- import { Alert } from "../primitives/alert.tsx";
13
17
  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";
18
+ DEFAULT_RETRY_LABEL,
19
+ EmptySurface,
20
+ ErrorSurface,
21
+ LoadingSurface,
22
+ } from "./state-surface.tsx";
22
23
 
23
24
  export type PageStateStatus = "loading" | "error" | "empty" | "ready";
24
25
 
26
+ const PageStatePresenceContext = createContext<(() => () => void) | null>(null);
27
+
28
+ /** Integração interna: permite que Page reconheça um PageState mesmo dentro de outro componente. */
29
+ export function PageStatePresenceProvider({
30
+ register,
31
+ children,
32
+ }: {
33
+ register: () => () => void;
34
+ children: ReactNode;
35
+ }): React.ReactElement {
36
+ return (
37
+ <PageStatePresenceContext.Provider value={register}>
38
+ {children}
39
+ </PageStatePresenceContext.Provider>
40
+ );
41
+ }
42
+
25
43
  export interface PageStateProps {
26
44
  status: PageStateStatus;
27
45
  /** Título específico do estado. Defaults seguros existem para loading, error e empty. */
@@ -30,8 +48,16 @@ export interface PageStateProps {
30
48
  description?: ReactNode;
31
49
  /** Ícone decorativo. Error usa TriangleAlert por default; empty não inventa um ícone. */
32
50
  icon?: ReactNode;
33
- /** Ação aplicável ao estado, como tentar novamente, escolher ou criar. */
51
+ /** Ação aplicável ao estado, como escolher ou criar. */
34
52
  action?: ReactNode;
53
+ /** Título do vazio quando `title` não for informado. Default: "Nada por aqui". */
54
+ emptyMessage?: string;
55
+ /** Título do erro quando `title` não for informado. Default: "Não foi possível carregar esta página". */
56
+ errorMessage?: string;
57
+ /** Recuperação do erro: com `onRetry`, o botão de tentar de novo entra ao lado de `action`. */
58
+ onRetry?: () => Promise<void> | void;
59
+ /** Rótulo do botão de recuperação. Default: "Tentar de novo". */
60
+ retryLabel?: string;
35
61
  /** Conteúdo normal da página, renderizado somente em ready. */
36
62
  children?: ReactNode;
37
63
  className?: string;
@@ -43,37 +69,39 @@ export function PageState({
43
69
  description,
44
70
  icon,
45
71
  action,
72
+ emptyMessage = "Nada por aqui",
73
+ errorMessage = "Não foi possível carregar esta página",
74
+ onRetry,
75
+ retryLabel = DEFAULT_RETRY_LABEL,
46
76
  children,
47
77
  className,
48
78
  }: PageStateProps): React.ReactElement {
79
+ const registerPresence = useContext(PageStatePresenceContext);
80
+ useLayoutEffect(() => {
81
+ if (status === "ready" || registerPresence === null) return;
82
+ return registerPresence();
83
+ }, [registerPresence, status]);
84
+
49
85
  if (status === "ready") return <>{children}</>;
50
86
 
87
+ const stateClassName = cn(
88
+ "flex min-h-64 flex-1 items-center justify-center",
89
+ className,
90
+ );
91
+
51
92
  if (status === "loading") {
52
93
  return (
53
94
  <div
54
95
  data-slot="page-state"
55
96
  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
- )}
97
+ className={stateClassName}
62
98
  >
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>
99
+ <LoadingSurface
100
+ className="w-full min-h-64"
101
+ title={title ?? "Carregando…"}
102
+ description={description}
103
+ titleLevel={1}
104
+ />
77
105
  </div>
78
106
  );
79
107
  }
@@ -83,33 +111,35 @@ export function PageState({
83
111
  <div
84
112
  data-slot="page-state"
85
113
  data-status="error"
86
- className={cn("flex min-h-64 items-center justify-center", className)}
114
+ className={stateClassName}
87
115
  >
88
- <Alert
89
- className="max-w-xl"
90
- context="danger"
91
- icon={icon ?? <TriangleAlert />}
92
- title={title ?? "Não foi possível carregar esta página"}
116
+ <ErrorSurface
117
+ presentation="state"
118
+ className="w-full max-w-xl"
119
+ titleLevel={1}
120
+ icon={icon}
121
+ title={title ?? errorMessage}
93
122
  description={description}
123
+ {...(onRetry !== undefined ? { onRetry } : {})}
124
+ retryLabel={retryLabel}
94
125
  >
95
126
  {action}
96
- </Alert>
127
+ </ErrorSurface>
97
128
  </div>
98
129
  );
99
130
  }
100
131
 
101
132
  return (
102
- <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>
133
+ <div data-slot="page-state" data-status="empty" className={stateClassName}>
134
+ <EmptySurface
135
+ frame="bare"
136
+ className="min-h-64"
137
+ titleLevel={1}
138
+ icon={icon}
139
+ title={title ?? emptyMessage}
140
+ description={description}
141
+ action={action}
142
+ />
113
143
  </div>
114
144
  );
115
145
  }