@softize/opus 21.0.1 → 22.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 (62) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/bin/cli.mjs +9 -1
  3. package/bin/lib/check.mjs +37 -29
  4. package/docs/adr/0004-page-content-state-is-composed.md +3 -2
  5. package/docs/adr/0005-structural-surfaces-share-an-explicit-anatomy.md +3 -0
  6. package/docs/adr/0006-semantic-context-precedes-visual-variant.md +23 -14
  7. package/docs/adr/0011-page-shell-coordinates-persistent-page-chrome.md +5 -0
  8. package/docs/adr/0013-presentation-is-a-portable-action-oriented-artifact.md +7 -4
  9. package/docs/adr/0016-list-collection-header-belongs-to-content.md +13 -8
  10. package/docs/adr/0023-surfaces-share-one-anatomy-and-two-layouts.md +27 -26
  11. package/docs/adr/0025-content-frame-treatment-does-not-change-semantics.md +1 -0
  12. package/docs/adr/0029-page-header-is-the-single-page-introduction.md +71 -0
  13. package/docs/adr/0030-framed-content-can-use-sectioned-anatomy.md +89 -0
  14. package/docs/adr/0031-detail-dividers-own-the-tabular-label-treatment.md +61 -0
  15. package/docs/adr/0032-tab-baseline-respects-the-container-inset.md +59 -0
  16. package/docs/adr/0033-drawer-uses-an-inset-structural-frame.md +72 -0
  17. package/docs/adr/0034-tabs-can-own-a-frame-without-owning-a-surface.md +60 -0
  18. package/docs/adr/0035-button-group-separates-frame-relationship-and-distribution.md +63 -0
  19. package/docs/adr/0036-tabs-declares-trigger-distribution.md +58 -0
  20. package/docs/adr/0037-design-data-lives-outside-the-contract.md +117 -0
  21. package/docs/radius-scale.md +5 -2
  22. package/package.json +5 -1
  23. package/registry/skills/build-opus-ui/SKILL.md +9 -6
  24. package/registry/skills/build-opus-ui/references/evaluations.md +5 -2
  25. package/registry/skills/build-opus-ui/references/ui-patterns.md +14 -16
  26. package/registry/skills/create-opus-action/references/contract-and-binding.md +5 -3
  27. package/registry/skills/create-opus-action/scripts/scaffold.mjs +0 -4
  28. package/registry/skills/test-opus-action/SKILL.md +2 -2
  29. package/registry/skills/test-opus-action/references/harness.md +2 -2
  30. package/registry/templates/app/src/App.tsx +1 -1
  31. package/registry/templates/app/src/domains/tasks/actions/list.test.ts +2 -19
  32. package/registry/templates/app/src/domains/tasks/actions/list.ts +3 -5
  33. package/registry/templates/app/vite.config.ts +3 -2
  34. package/src/core/domain.ts +2 -2
  35. package/src/core/types.ts +6 -20
  36. package/src/design/index.ts +179 -0
  37. package/src/testing/fake.ts +2 -2
  38. package/src/testing/index.ts +7 -3
  39. package/src/ui/components/patterns/content-header.tsx +51 -13
  40. package/src/ui/components/patterns/list.tsx +51 -33
  41. package/src/ui/components/patterns/page.tsx +139 -251
  42. package/src/ui/components/patterns/presentation.tsx +2 -6
  43. package/src/ui/components/primitives/button-group.tsx +80 -8
  44. package/src/ui/components/primitives/button.tsx +85 -60
  45. package/src/ui/components/primitives/detail.tsx +63 -57
  46. package/src/ui/components/primitives/dialog.tsx +8 -11
  47. package/src/ui/components/primitives/drawer.tsx +16 -12
  48. package/src/ui/components/primitives/tabs.tsx +123 -54
  49. package/src/ui/docs/content/actions.md +49 -43
  50. package/src/ui/docs/content/button.md +55 -29
  51. package/src/ui/docs/content/content.md +61 -36
  52. package/src/ui/docs/content/detail.md +28 -19
  53. package/src/ui/docs/content/drawer.md +53 -47
  54. package/src/ui/docs/content/input.md +51 -35
  55. package/src/ui/docs/content/page.md +84 -95
  56. package/src/ui/docs/content/pane.md +83 -63
  57. package/src/ui/docs/content/presentation.md +20 -20
  58. package/src/ui/docs/content/tabs.md +107 -36
  59. package/src/ui/docs/content/testing.md +9 -25
  60. package/src/ui/meta.ts +5 -5
  61. package/src/ui/react.tsx +0 -1
  62. package/src/vite/design.ts +133 -23
@@ -17,10 +17,7 @@ import {
17
17
  SurfaceMedia,
18
18
  type SurfaceMediaProps,
19
19
  } from "../patterns/surface-media.tsx";
20
- import {
21
- CloseButton,
22
- type CloseButtonSize,
23
- } from "./close-button.tsx";
20
+ import { CloseButton, type CloseButtonSize } from "./close-button.tsx";
24
21
  import { overlayClasses, preventDismissWhen } from "./dialog.tsx";
25
22
 
26
23
  interface DrawerContentContextValue {
@@ -117,15 +114,15 @@ function DrawerContent({
117
114
  data-slot="drawer-content"
118
115
  data-layout={layout}
119
116
  className={cn(
120
- "fixed z-50 flex flex-col bg-background shadow-lg transition ease-in-out [--surface-background:var(--background)] data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:animate-in data-[state=open]:duration-500",
117
+ "fixed z-50 flex flex-col overflow-hidden rounded-xl border bg-background shadow-lg transition ease-in-out [--surface-background:var(--background)] data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:animate-in data-[state=open]:duration-500",
121
118
  side === "right" &&
122
- "inset-y-0 right-0 h-full w-3/4 max-w-sm border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right",
119
+ "inset-y-1 right-1 h-[calc(100%-0.5rem)] w-3/4 max-w-sm data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right",
123
120
  side === "left" &&
124
- "inset-y-0 left-0 h-full w-3/4 max-w-sm border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left",
121
+ "inset-y-1 left-1 h-[calc(100%-0.5rem)] w-3/4 max-w-sm data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left",
125
122
  side === "top" &&
126
- "inset-x-0 top-0 h-auto border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
123
+ "inset-x-1 top-1 h-auto data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
127
124
  side === "bottom" &&
128
- "inset-x-0 bottom-0 h-auto border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
125
+ "inset-x-1 bottom-1 h-auto data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
129
126
  className,
130
127
  )}
131
128
  aria-describedby={ariaDescribedBy}
@@ -189,7 +186,10 @@ function DrawerMedia(props: SurfaceMediaProps) {
189
186
  }
190
187
 
191
188
  /** Região introdutória do header — o voltar de um fluxo modal encadeado. */
192
- function DrawerNavigation({ className, ...props }: React.ComponentProps<"div">) {
189
+ function DrawerNavigation({
190
+ className,
191
+ ...props
192
+ }: React.ComponentProps<"div">) {
193
193
  return (
194
194
  <div
195
195
  data-slot="drawer-navigation-content"
@@ -210,7 +210,11 @@ function DrawerActions({ className, ...props }: React.ComponentProps<"div">) {
210
210
  );
211
211
  }
212
212
 
213
- function DrawerBody({ className, style, ...props }: React.ComponentProps<"div">) {
213
+ function DrawerBody({
214
+ className,
215
+ style,
216
+ ...props
217
+ }: React.ComponentProps<"div">) {
214
218
  const layout = useSurfaceLayout("drawer");
215
219
  return (
216
220
  <div
@@ -220,7 +224,7 @@ function DrawerBody({ className, style, ...props }: React.ComponentProps<"div">)
220
224
  surfaceRegionClasses("drawer", layout, "body"),
221
225
  className,
222
226
  )}
223
- style={{ "--opus-inline-gutter": "1rem", ...style } as React.CSSProperties}
227
+ style={style}
224
228
  {...props}
225
229
  />
226
230
  );
@@ -1,43 +1,65 @@
1
- import * as React from "react"
2
- import { cva, type VariantProps } from "class-variance-authority"
3
- import { Tabs as TabsPrimitive } from "radix-ui"
1
+ import * as React from "react";
2
+ import { cva, type VariantProps } from "class-variance-authority";
3
+ import { Tabs as TabsPrimitive } from "radix-ui";
4
4
 
5
- import { cn } from '../../lib/cn.ts'
6
- import { controlHeight, type ControlSize } from './control.ts'
5
+ import { cn } from "../../lib/cn.ts";
6
+ import { controlHeight, type ControlSize } from "./control.ts";
7
7
 
8
8
  // Divergência da casa (ejetado): o Tabs ganhou `size` — Button e Select têm `sm`, o Tabs
9
9
  // não tinha, e uma fileira densa ficava com o segmento 0.25rem mais alto
10
10
  // que os irmãos. O tamanho flui pra TabsList via contexto (a altura é dela) e a medida é a
11
11
  // da escala de control.ts.
12
- type TabsSize = Extract<ControlSize, "default" | "sm">
13
- const TabsSizeContext = React.createContext<TabsSize>("default")
14
- type TabsOrientation = "horizontal" | "vertical"
15
- const TabsOrientationContext = React.createContext<TabsOrientation>("horizontal")
16
- const TabsListVariantContext = React.createContext<"default" | "line">("default")
12
+ type TabsSize = Extract<ControlSize, "default" | "sm">;
13
+ type TabsDistribution = "content" | "equal";
14
+ const TabsSizeContext = React.createContext<TabsSize>("default");
15
+ const TabsDistributionContext =
16
+ React.createContext<TabsDistribution>("content");
17
+ const TabsFramedContext = React.createContext(false);
18
+ type TabsOrientation = "horizontal" | "vertical";
19
+ const TabsOrientationContext =
20
+ React.createContext<TabsOrientation>("horizontal");
21
+ const TabsListVariantContext = React.createContext<"default" | "line">(
22
+ "default",
23
+ );
17
24
 
18
25
  function Tabs({
19
26
  className,
20
27
  orientation = "horizontal",
21
28
  size = "default",
29
+ distribution = "content",
30
+ framed = false,
22
31
  ...props
23
- }: React.ComponentProps<typeof TabsPrimitive.Root> & { size?: TabsSize }) {
32
+ }: React.ComponentProps<typeof TabsPrimitive.Root> & {
33
+ size?: TabsSize;
34
+ /** Distribui os gatilhos pela largura do conjunto ou preserva a largura do conteúdo. */
35
+ distribution?: TabsDistribution;
36
+ /** Delimita a composição sem criar uma nova superfície. */
37
+ framed?: boolean;
38
+ }) {
24
39
  return (
25
40
  <TabsOrientationContext.Provider value={orientation}>
26
- <TabsSizeContext.Provider value={size}>
27
- <TabsPrimitive.Root
28
- data-slot="tabs"
29
- data-orientation={orientation}
30
- data-size={size}
31
- orientation={orientation}
32
- className={cn(
33
- "group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
34
- className
35
- )}
36
- {...props}
37
- />
38
- </TabsSizeContext.Provider>
41
+ <TabsDistributionContext.Provider value={distribution}>
42
+ <TabsFramedContext.Provider value={framed}>
43
+ <TabsSizeContext.Provider value={size}>
44
+ <TabsPrimitive.Root
45
+ data-slot="tabs"
46
+ data-orientation={orientation}
47
+ data-size={size}
48
+ data-distribution={distribution}
49
+ data-framed={framed}
50
+ orientation={orientation}
51
+ className={cn(
52
+ "group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
53
+ framed && "overflow-hidden rounded-xl border",
54
+ className,
55
+ )}
56
+ {...props}
57
+ />
58
+ </TabsSizeContext.Provider>
59
+ </TabsFramedContext.Provider>
60
+ </TabsDistributionContext.Provider>
39
61
  </TabsOrientationContext.Provider>
40
- )
62
+ );
41
63
  }
42
64
 
43
65
  const tabsListVariants = cva(
@@ -49,14 +71,14 @@ const tabsListVariants = cva(
49
71
  variants: {
50
72
  variant: {
51
73
  default: "bg-muted",
52
- line: "gap-5 bg-transparent",
74
+ line: "gap-1 bg-transparent",
53
75
  },
54
76
  },
55
77
  defaultVariants: {
56
78
  variant: "default",
57
79
  },
58
- }
59
- )
80
+ },
81
+ );
60
82
 
61
83
  function TabsList({
62
84
  className,
@@ -67,20 +89,33 @@ function TabsList({
67
89
  }: React.ComponentProps<typeof TabsPrimitive.List> &
68
90
  VariantProps<typeof tabsListVariants>) {
69
91
  // O size vem do <Tabs> (contexto) — a altura mora aqui, na list.
70
- const size = React.useContext(TabsSizeContext)
71
- const orientation = React.useContext(TabsOrientationContext)
72
- const normalizedVariant = variant ?? "default"
73
- const hasHorizontalLine = normalizedVariant === "line" && orientation === "horizontal"
92
+ const size = React.useContext(TabsSizeContext);
93
+ const orientation = React.useContext(TabsOrientationContext);
94
+ const distribution = React.useContext(TabsDistributionContext);
95
+ const normalizedVariant = variant ?? "default";
96
+ const hasHorizontalLine =
97
+ normalizedVariant === "line" && orientation === "horizontal";
74
98
  return (
75
99
  <TabsListVariantContext.Provider value={normalizedVariant}>
76
100
  <TabsPrimitive.List
77
101
  data-slot="tabs-list"
78
102
  data-variant={normalizedVariant}
79
103
  data-size={size}
80
- className={cn(tabsListVariants({ variant: normalizedVariant }), controlHeight[size], className)}
104
+ data-distribution={distribution}
105
+ className={cn(
106
+ tabsListVariants({ variant: normalizedVariant }),
107
+ controlHeight[size],
108
+ distribution === "equal" && orientation === "horizontal" && "w-full",
109
+ className,
110
+ )}
81
111
  style={{
82
112
  ...(hasHorizontalLine
83
- ? { position: "relative", width: "100%", height: "auto", justifyContent: "flex-start" }
113
+ ? {
114
+ position: "relative",
115
+ width: "100%",
116
+ height: "auto",
117
+ justifyContent: "flex-start",
118
+ }
84
119
  : {}),
85
120
  ...style,
86
121
  }}
@@ -92,7 +127,7 @@ function TabsList({
92
127
  data-slot="tabs-line"
93
128
  style={{
94
129
  position: "absolute",
95
- insetInline: "calc(var(--opus-inline-gutter, 0rem) * -1)",
130
+ insetInline: "0rem",
96
131
  bottom: 0,
97
132
  borderBottomWidth: "0.0625rem",
98
133
  borderBottomStyle: "solid",
@@ -104,7 +139,7 @@ function TabsList({
104
139
  {children}
105
140
  </TabsPrimitive.List>
106
141
  </TabsListVariantContext.Provider>
107
- )
142
+ );
108
143
  }
109
144
 
110
145
  function TabsTrigger({
@@ -115,38 +150,72 @@ function TabsTrigger({
115
150
  ...props
116
151
  }: React.ComponentProps<typeof TabsPrimitive.Trigger> & {
117
152
  /** Ícone decorativo antes do rótulo. Em uma aba sem texto, declare também `aria-label`. */
118
- icon?: React.ReactNode
153
+ icon?: React.ReactNode;
119
154
  }) {
120
- const variant = React.useContext(TabsListVariantContext)
121
- const orientation = React.useContext(TabsOrientationContext)
122
- const hasHorizontalLine = variant === "line" && orientation === "horizontal"
155
+ const variant = React.useContext(TabsListVariantContext);
156
+ const orientation = React.useContext(TabsOrientationContext);
157
+ const size = React.useContext(TabsSizeContext);
158
+ const distribution = React.useContext(TabsDistributionContext);
159
+ const framed = React.useContext(TabsFramedContext);
160
+ const hasHorizontalLine = variant === "line" && orientation === "horizontal";
161
+ const content = (
162
+ <>
163
+ {icon !== undefined && (
164
+ <span
165
+ data-slot="tabs-trigger-icon"
166
+ aria-hidden="true"
167
+ className="flex size-4 shrink-0 items-center justify-center [&_svg]:size-4"
168
+ >
169
+ {icon}
170
+ </span>
171
+ )}
172
+ {children}
173
+ </>
174
+ );
123
175
  return (
124
176
  <TabsPrimitive.Trigger
125
177
  data-slot="tabs-trigger"
126
178
  // px: altura e indicador compensam a hairline do contêiner (um pixel físico, não escala).
127
179
  className={cn(
128
180
  "relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[0.1875rem] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none dark:text-muted-foreground dark:hover:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
129
- "group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
181
+ "dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent",
130
182
  "data-[state=active]:bg-background data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:text-foreground",
131
- "after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-1px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
132
- hasHorizontalLine && "px-0 py-3",
133
- className
183
+ "after:absolute after:bg-primary after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-1px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
184
+ hasHorizontalLine &&
185
+ cn(
186
+ "group/tab-trigger rounded-none px-0 py-0 hover:bg-transparent active:brightness-100 data-[state=active]:bg-transparent data-[state=active]:hover:bg-transparent",
187
+ framed ? "pb-2" : "pb-1.5",
188
+ ),
189
+ className,
134
190
  )}
135
- style={{ ...(hasHorizontalLine ? { flex: "none", height: "auto" } : {}), ...style }}
191
+ style={{
192
+ ...(hasHorizontalLine
193
+ ? {
194
+ flex: distribution === "equal" ? "1 1 0%" : "none",
195
+ height: "auto",
196
+ minWidth: distribution === "equal" ? 0 : undefined,
197
+ }
198
+ : {}),
199
+ ...style,
200
+ }}
136
201
  {...props}
137
202
  >
138
- {icon !== undefined && (
203
+ {hasHorizontalLine ? (
139
204
  <span
140
- data-slot="tabs-trigger-icon"
141
- aria-hidden="true"
142
- className="flex size-4 shrink-0 items-center justify-center [&_svg]:size-4"
205
+ data-slot="tabs-trigger-content"
206
+ className={cn(
207
+ "inline-flex items-center justify-center gap-1.5 rounded-md px-3 transition-colors hover:bg-accent hover:text-accent-foreground active:brightness-90 group-data-[state=active]/tab-trigger:text-foreground",
208
+ distribution === "equal" && "w-full",
209
+ controlHeight[size],
210
+ )}
143
211
  >
144
- {icon}
212
+ {content}
145
213
  </span>
214
+ ) : (
215
+ content
146
216
  )}
147
- {children}
148
217
  </TabsPrimitive.Trigger>
149
- )
218
+ );
150
219
  }
151
220
 
152
221
  function TabsContent({
@@ -159,7 +228,7 @@ function TabsContent({
159
228
  className={cn("flex-1 outline-none", className)}
160
229
  {...props}
161
230
  />
162
- )
231
+ );
163
232
  }
164
233
 
165
- export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
234
+ export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants };
@@ -152,54 +152,62 @@ A autorização é fechada por padrão: sem `public: true`, a action exige usuá
152
152
 
153
153
  ## Prototipar com dados simulados
154
154
 
155
- Para desenhar a interface antes de o backend existir, declare um `mockHandler` no contrato. Com
156
- `OPUS_MODE=design`, o runtime executa o `mockHandler` no lugar do `handler`; sem ele, usa o
157
- `handler` real. Como o mock fica no contrato, a mesma declaração serve ao design e, depois, ao
158
- handler real, e o mock não precisa acessar `ctx.db`.
155
+ Para desenhar a interface antes de o backend existir, rode o app em modo design. Nesse modo, toda
156
+ action responde sem que o contrato declare nada: a resposta é gerada pelo `fake` a partir do
157
+ `output` do contrato (ADR 0037).
159
158
 
160
- `fake` e `fakeMany` geram dados determinísticos a partir do próprio schema (ver
161
- [Testes de action](/docs/testing)). Em `list`, o `mockHandler` devolve a página inteira, com
162
- `items`, `cursor` e `total`:
159
+ - A mesma requisição recebe sempre a mesma resposta.
160
+ - Uma `list` devolve três páginas do tamanho pedido em `limit` (20 quando a entrada não pede, até
161
+ 100) e respeita a paginação declarada. Busca e filtros não filtram o dado gerado.
162
+ - Quando a entrada e a saída têm `id`, a saída repete o `id` da entrada, desde que o schema da saída
163
+ aceite esse valor. Assim, abrir um item da lista mostra o mesmo identificador.
164
+
165
+ O dado gerado tem o formato certo, mas não segue a regra de negócio: os valores de um enum são
166
+ sorteados, e uma listagem e um detalhe só coincidem no `id`. Quando o design precisa de coerência,
167
+ como um item em cada situação ou algo criado que aparece na lista, escreva um cenário. Ele fica fora
168
+ do contrato e é ligado à action pelo nome:
163
169
 
164
170
  ```ts
165
- import { bindAction, defineContract } from '@softize/opus/core'
166
- import { entityRowSchema } from '@softize/opus/schema'
167
- import { fake, fakeMany } from '@softize/opus/testing'
168
- import { z } from 'zod'
169
- import { EventEntity } from '../entity.ts'
171
+ import { defineDesignScenario } from '@softize/opus/design'
172
+ import { eventGet, eventList } from '../domains/event/contract.ts'
173
+
174
+ // Valor fixo: o caso mais comum.
175
+ export const eventsInEachState = defineDesignScenario(eventList, {
176
+ items: [
177
+ { id: 'e1', title: 'Revisão agendada', status: 'scheduled' },
178
+ { id: 'e2', title: 'Revisão perdida', status: 'lost' },
179
+ ],
180
+ cursor: { next: null },
181
+ total: 2,
182
+ })
170
183
 
171
- // `entityRowSchema` deriva da entidade o schema de uma linha.
172
- const eventRowSchema = entityRowSchema(EventEntity)
184
+ // Função: quando a resposta depende da entrada.
185
+ export const eventDetail = defineDesignScenario(eventGet, (input) => ({
186
+ id: input.id,
187
+ title: 'Revisão agendada',
188
+ status: 'scheduled',
189
+ }))
190
+ ```
173
191
 
174
- export const eventGet = defineContract({
175
- name: 'event.get',
176
- kind: 'view',
177
- input: z.object({ id: z.string().uuid() }),
178
- output: eventRowSchema,
179
- mockHandler: () => fake(eventRowSchema),
180
- })
192
+ A resposta é tipada pela entrada e pela saída do contrato; em `list`, é a página inteira. Informe ao
193
+ plugin onde os cenários estão, com caminhos ou globs:
194
+ `opusDesign({ scenarios: 'design/scenarios/*.ts' })`. O cenário vence a resposta gerada. Um cenário
195
+ de uma action que não existe, ou dois cenários para a mesma action, impedem o runtime de design de
196
+ subir, e o erro lista os nomes.
181
197
 
182
- export const eventList = defineContract({
183
- name: 'event.list',
184
- kind: 'list',
185
- input: z.object({}),
186
- output: eventRowSchema,
187
- paginate: 'cursor',
188
- mockHandler: async () => ({ items: fakeMany(eventRowSchema, 20), cursor: { next: null }, total: 20 }),
189
- })
198
+ O Opus não decide onde os cenários ficam nem se entram no repositório. Eles não fazem parte da
199
+ aplicação: podem ficar numa pasta ignorada pelo git e ser escritos durante a sessão de design,
200
+ quando forem necessários.
190
201
 
191
- // Depois, o servidor liga o handler real ao mesmo contrato.
192
- export const eventGetImpl = bindAction(eventGet, {
193
- handler: async (ctx, input) =>
194
- ctx.db.selectFrom('events').selectAll().where('id', '=', input.id).executeTakeFirstOrThrow(),
195
- })
196
- ```
202
+ `mockHandler` está obsoleto desde a 22.1.0 e sai na próxima major. Enquanto existir, ele ainda
203
+ responde no modo design, abaixo do cenário e acima da resposta gerada, e o `opus check` conta
204
+ quantas actions ainda o declaram. Para migrar, apague o `mockHandler`; se a resposta dele importar
205
+ para o design, transforme-a em cenário.
197
206
 
198
- O `mockHandler` roda no servidor, não no navegador. O plugin `opusDesign()`, de
199
- `@softize/opus/vite`, monta o runtime do Opus dentro do dev server e responde `/api` no próprio
200
- processo. No modo design, qualquer `server.proxy` para um backend externo fica desligado: um prefixo
201
- que ia para o proxy e não tem action correspondente responde 503 em envelope, sem alcançar o
202
- backend. A aplicação chama `/api` como sempre e recebe os dados simulados.
207
+ O plugin `opusDesign()`, de `@softize/opus/vite`, monta o runtime do Opus dentro do dev server e
208
+ responde `/api` no próprio processo. No modo design, qualquer `server.proxy` para um backend externo
209
+ fica desligado: um prefixo que ia para o proxy e não tem action correspondente responde 503 em
210
+ envelope, sem alcançar o backend. A aplicação chama `/api` como sempre e recebe os dados simulados.
203
211
 
204
212
  ```ts
205
213
  // vite.config.ts — o plugin só carrega no modo design, por import dinâmico.
@@ -233,9 +241,7 @@ nem reiniciar o dev server. Quando uma action não é encontrada, o plugin recon
233
241
  vez por ciclo de mudança e tenta de novo; se a action continuar ausente, a resposta indica o
234
242
  `entry` a conferir.
235
243
 
236
- Como o contrato vai para os dois lados, o `mockHandler` também entra no bundle da web, onde nunca é
237
- chamado. Não o condicione com `import.meta.env` no contrato: essa variável não existe no Node e
238
- quebraria o servidor.
244
+ Os cenários não entram no bundle da web: eles ficam fora do contrato, e só o plugin os carrega.
239
245
 
240
246
  ## Consumir o contrato na interface
241
247
 
@@ -1,8 +1,9 @@
1
1
  ## Contexto e variante
2
2
 
3
- Use `Button` para iniciar uma ação. `context` declara a hierarquia ou o risco da ação; `variant`
4
- escolhe o tratamento visual. Use `primary` para a ação principal, `neutral` para ações de apoio e
5
- `danger` quando a ação tiver uma consequência perigosa. A variante `outline` mantém o fundo
3
+ Use `Button` para iniciar uma ação. `context` declara a hierarquia, a cautela ou o risco da ação;
4
+ `variant` escolhe o tratamento visual. Use `primary` para a ação principal, `neutral` para ações de
5
+ apoio, `warning` quando a pessoa puder prosseguir mas o efeito merecer atenção e `danger` quando a
6
+ ação tiver uma consequência perigosa. A variante `outline` mantém o fundo
6
7
  transparente em repouso e usa uma superfície apenas no hover, preservando o fundo da região onde o
7
8
  botão está inserido. `subtle` preenche o botão com um tom suave do contexto, para ações que precisam
8
9
  de presença sem competir com a principal.
@@ -13,6 +14,7 @@ de presença sem competir com a principal.
13
14
  <Button variant="subtle">Convidar pessoa</Button>
14
15
  <Button context="neutral" variant="outline">Ver prévia</Button>
15
16
  <Button context="neutral" variant="ghost">Cancelar</Button>
17
+ <Button context="warning" variant="ghost">Registrar tentativa sem sucesso</Button>
16
18
  <Button context="danger" variant="solid">Excluir</Button>
17
19
  <Button context="primary" variant="link">Ver documentação</Button>
18
20
  ```
@@ -27,16 +29,16 @@ há texto visível. O glifo dentro do controle acompanha o tamanho (0.875rem em
27
29
  `icon-xs`, `icon-sm` e `icon`; 1rem em `default`; 1.25rem em `lg` e `icon-lg`), a menos que o
28
30
  ícone traga um `size-*` próprio. O quadrado `icon` mantém a área de 2.25rem com o glifo discreto.
29
31
 
30
- | Nome | Medida | Uso |
31
- | --------- | ------- | -------------------------------------------------------------------------------------------------------------------------------- |
32
- | `xs` | 1.5rem | Ação dentro de um campo (`InputGroupButton`). |
33
- | `sm` | 2rem | Operação densa em toolbar ou dentro de uma seção, ao lado de `Select` e `Tabs` `sm`. |
32
+ | Nome | Medida | Uso |
33
+ | --------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------- |
34
+ | `xs` | 1.5rem | Ação dentro de um campo (`InputGroupButton`). |
35
+ | `sm` | 2rem | Operação densa em toolbar ou dentro de uma seção, ao lado de `Select` e `Tabs` `sm`. |
34
36
  | `default` | 2.25rem | Decisão da superfície: header de Page, criação no cabeçalho de uma coleção e footer de Dialog ou Drawer; é também a linha de `Input`. |
35
- | `lg` | 2.5rem | Chamada principal com mais área de toque. |
36
- | `icon-xs` | 1.5rem | Ação só de ícone dentro de um campo ou de uma linha densa; é o quadrado de `ActionTrigger` com `icon`. |
37
- | `icon-sm` | 1.75rem | Ação só de ícone em composições compactas que precisam de mais presença; também é o quadrado das setas do pager de `ActionList`. |
38
- | `icon` | 2.25rem | Ação só de ícone na linha padrão. |
39
- | `icon-lg` | 2.5rem | Ação só de ícone ao lado de um `lg`. |
37
+ | `lg` | 2.5rem | Chamada principal com mais área de toque. |
38
+ | `icon-xs` | 1.5rem | Ação só de ícone dentro de um campo ou de uma linha densa; é o quadrado de `ActionTrigger` com `icon`. |
39
+ | `icon-sm` | 1.75rem | Ação só de ícone em composições compactas que precisam de mais presença; também é o quadrado das setas do pager de `ActionList`. |
40
+ | `icon` | 2.25rem | Ação só de ícone na linha padrão. |
41
+ | `icon-lg` | 2.5rem | Ação só de ícone ao lado de um `lg`. |
40
42
 
41
43
  Escolha o tamanho pela região, não pela importância visual: `variant` e `context` resolvem a
42
44
  hierarquia da ação. Headers de Page, a criação no cabeçalho de uma coleção e footers de
@@ -102,15 +104,15 @@ estilos, use `buttonVariants` para obter as classes.
102
104
 
103
105
  ## Propriedades de Button
104
106
 
105
- | Propriedade | Tipo | Padrão | Descrição |
106
- | ----------- | ------------------------------------------------------------------------------------ | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
107
- | `context` | `'neutral' \| 'primary' \| 'danger'` | `'primary'` em `solid` e `subtle`; `'neutral'` nas demais variantes | A hierarquia ou o risco comunicado pela ação. |
108
- | `variant` | `'solid' \| 'subtle' \| 'outline' \| 'ghost' \| 'link'` | `'solid'` | O tratamento visual aplicado ao contexto. |
109
- | `size` | `'xs' \| 'sm' \| 'default' \| 'lg' \| 'icon-xs' \| 'icon-sm' \| 'icon' \| 'icon-lg'` | `'default'` | A medida na escala única dos controles (tabela acima). Os `icon-*` são quadrados para botões só de ícone, com `aria-label`. |
110
- | `shape` | `'default' \| 'pill'` | `'default'` | Geometria do botão; `pill` arredonda as extremidades por completo. |
111
- | `asChild` | `boolean` | `false` | Renderiza como o filho (Radix Slot) em vez de `<button>` — para âncoras e afins. |
112
- | `busy` | `boolean` | `false` | Ação em andamento, depois do clique: mostra o `Spinner` e desabilita o botão. Não indica carregamento de conteúdo; para isso, use `DataState` ou `Skeleton`. |
113
- | `icon` | `React.ReactNode` | | Ícone à esquerda, como nó (ex.: `icon={<Plus />}`); o glifo segue o `size`. Com `busy`, dá lugar ao `Spinner`; os dois não aparecem juntos. |
107
+ | Propriedade | Tipo | Padrão | Descrição |
108
+ | ----------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
109
+ | `context` | `'neutral' \| 'primary' \| 'warning' \| 'danger'` | `'primary'` em `solid` e `subtle`; `'neutral'` nas demais variantes | A hierarquia, a cautela ou o risco comunicado pela ação. |
110
+ | `variant` | `'solid' \| 'subtle' \| 'outline' \| 'ghost' \| 'link'` | `'solid'` | O tratamento visual aplicado ao contexto. |
111
+ | `size` | `'xs' \| 'sm' \| 'default' \| 'lg' \| 'icon-xs' \| 'icon-sm' \| 'icon' \| 'icon-lg'` | `'default'` | A medida na escala única dos controles (tabela acima). Os `icon-*` são quadrados para botões só de ícone, com `aria-label`. |
112
+ | `shape` | `'default' \| 'pill'` | `'default'` | Geometria do botão; `pill` arredonda as extremidades por completo. |
113
+ | `asChild` | `boolean` | `false` | Renderiza como o filho (Radix Slot) em vez de `<button>` — para âncoras e afins. |
114
+ | `busy` | `boolean` | `false` | Ação em andamento, depois do clique: mostra o `Spinner` e desabilita o botão. Não indica carregamento de conteúdo; para isso, use `DataState` ou `Skeleton`. |
115
+ | `icon` | `React.ReactNode` | | Ícone à esquerda, como nó (ex.: `icon={<Plus />}`); o glifo segue o `size`. Com `busy`, dá lugar ao `Spinner`; os dois não aparecem juntos. |
114
116
 
115
117
  ## ButtonGroup
116
118
 
@@ -146,6 +148,27 @@ conectar bordas. Esse modo atende ações só com ícone em barras e linhas de l
146
148
  </ButtonGroup>
147
149
  ```
148
150
 
151
+ ### Ações divididas
152
+
153
+ Use `mode="divided"` quando cada controle precisar preservar sua própria interação, mas divisores
154
+ visuais ajudarem a ler o conjunto. O grupo insere os divisores entre os filhos e acompanha a
155
+ orientação automaticamente. Combine `framed` para delimitar o conjunto sem criar um fundo e
156
+ `distribution="equal"` quando as ações precisarem ocupar larguras iguais.
157
+
158
+ ```tsx preview col
159
+ <ButtonGroup framed mode="divided" distribution="equal">
160
+ <Button variant="ghost" icon={<Phone />}>
161
+ Ligar
162
+ </Button>
163
+ <Button variant="ghost" icon={<MessageSquare />}>
164
+ Anotar
165
+ </Button>
166
+ <Button context="danger" variant="ghost" icon={<CircleX />}>
167
+ Encerrar
168
+ </Button>
169
+ </ButtonGroup>
170
+ ```
171
+
149
172
  ### No rodapé de Dialog e Drawer
150
173
 
151
174
  `DialogFooter` e `DrawerFooter` cuidam somente da faixa; envolva as decisões em um
@@ -169,13 +192,15 @@ confirmação. As ações dividem a largura disponível; use `outline` no `Cance
169
192
  ```tsx preview col
170
193
  <div className="w-80">
171
194
  <ButtonGroup mode="spaced" distribution="equal">
172
- <Button context="neutral" variant="outline">Cancelar</Button>
195
+ <Button context="neutral" variant="outline">
196
+ Cancelar
197
+ </Button>
173
198
  <Button context="danger">Excluir</Button>
174
199
  </ButtonGroup>
175
200
  </div>
176
201
  ```
177
202
 
178
- ### Ação dividida
203
+ ### Ação com variações
179
204
 
180
205
  Combine a ação principal, um separador e um botão de ícone quando o mesmo comando oferecer
181
206
  variações.
@@ -227,12 +252,13 @@ semântica de outro elemento, como `label`.
227
252
 
228
253
  ### Propriedades de ButtonGroup
229
254
 
230
- | Propriedade | Tipo | Padrão | Descrição |
231
- | -------------- | ---------------------------- | -------------- | ----------------------------------------------------------------------------------------- |
232
- | `orientation` | `'horizontal' \| 'vertical'` | `'horizontal'` | Direção do bloco e do colapso das bordas. |
233
- | `mode` | `'connected' \| 'spaced'` | `'connected'` | Conecta as bordas dos controles ou preserva cada controle com intervalo compacto. |
234
- | `shape` | `'default' \| 'pill'` | `'default'` | Geometria das extremidades externas do grupo. |
235
- | `distribution` | `'content' \| 'equal'` | `'content'` | Mantém a largura do conteúdo ou divide igualmente o espaço disponível entre os controles. |
255
+ | Propriedade | Tipo | Padrão | Descrição |
256
+ | -------------- | -------------------------------------- | -------------- | ----------------------------------------------------------------------------------------- |
257
+ | `orientation` | `'horizontal' \| 'vertical'` | `'horizontal'` | Direção do bloco e do colapso das bordas. |
258
+ | `mode` | `'connected' \| 'spaced' \| 'divided'` | `'connected'` | Conecta as bordas, separa por espaço ou insere divisores entre os controles. |
259
+ | `shape` | `'default' \| 'pill'` | `'default'` | Geometria das extremidades externas do grupo. |
260
+ | `distribution` | `'content' \| 'equal'` | `'content'` | Mantém a largura do conteúdo ou divide igualmente o espaço disponível entre os controles. |
261
+ | `framed` | `boolean` | `false` | Aplica borda, raio e padding ao grupo sem definir uma nova superfície. |
236
262
 
237
263
  ### Propriedades de ButtonGroupSeparator
238
264