@softize/opus 15.2.2 → 16.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 (37) hide show
  1. package/CHANGELOG.md +51 -18
  2. package/bin/lib/check.mjs +98 -15
  3. package/bin/lib/copy.mjs +811 -314
  4. package/bin/lib/gen-manifest.mjs +24 -23
  5. package/bin/lib/gen-runner.mjs +188 -148
  6. package/docs/adr/0010-page-header-owns-page-chrome.md +3 -4
  7. package/docs/adr/0011-page-shell-coordinates-persistent-page-chrome.md +5 -3
  8. package/docs/adr/0012-modal-header-only-names-the-surface.md +45 -0
  9. package/docs/adr/0013-presentation-is-a-portable-action-oriented-artifact.md +92 -0
  10. package/docs/code-style.md +24 -19
  11. package/package.json +5 -1
  12. package/registry/skills/build-opus-ui/references/ui-patterns.md +43 -26
  13. package/src/core/presentation.ts +512 -0
  14. package/src/presentation/index.ts +1 -0
  15. package/src/ui/components/patterns/action-list-dialog.tsx +26 -9
  16. package/src/ui/components/patterns/confirm.tsx +34 -29
  17. package/src/ui/components/patterns/form-dialog.tsx +20 -8
  18. package/src/ui/components/patterns/list.tsx +26 -9
  19. package/src/ui/components/patterns/page.tsx +99 -139
  20. package/src/ui/components/patterns/presentation.tsx +316 -0
  21. package/src/ui/components/patterns/sidebar.tsx +3 -3
  22. package/src/ui/components/patterns/trigger.tsx +38 -17
  23. package/src/ui/components/primitives/button-group.tsx +53 -43
  24. package/src/ui/components/primitives/command.tsx +30 -72
  25. package/src/ui/components/primitives/dialog.tsx +23 -89
  26. package/src/ui/components/primitives/drawer.tsx +8 -34
  27. package/src/ui/docs/content/action-form-dialog.md +12 -10
  28. package/src/ui/docs/content/action-list-dialog.md +22 -17
  29. package/src/ui/docs/content/button.md +52 -35
  30. package/src/ui/docs/content/communication.md +26 -26
  31. package/src/ui/docs/content/dialog.md +173 -154
  32. package/src/ui/docs/content/drawer.md +12 -11
  33. package/src/ui/docs/content/page.md +72 -91
  34. package/src/ui/docs/content/presentation.md +158 -0
  35. package/src/ui/docs/registry.tsx +6 -0
  36. package/src/ui/meta.ts +8 -2
  37. package/src/ui/react.tsx +10 -3
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * `dialog` — respostas imperativas (alert · confirm · prompt · choose) em UMA linha.
3
3
  *
4
- * await dialog.alert({ title: 'Sessão expirada', description: 'Você será desconectado.' })
4
+ * await dialog.alert({ title: 'Sessão expirada', body: 'Você será desconectado.' })
5
5
  * if (await dialog.confirm({ title: 'Excluir a sessão?', context: 'danger' })) …
6
6
  * const nome = await dialog.prompt({ title: 'Nome do snapshot' })
7
7
  *
@@ -26,23 +26,17 @@ import {
26
26
  DialogBody,
27
27
  DialogClose,
28
28
  DialogContent,
29
- DialogDescription,
30
29
  DialogFooter,
31
30
  DialogHeader,
32
31
  DialogMedia,
33
32
  DialogTitle,
34
33
  } from '../primitives/dialog.tsx'
35
- import {
36
- Button,
37
- type ButtonContext,
38
- type ButtonVariant,
39
- } from '../primitives/button.tsx'
34
+ import { Button, type ButtonContext, type ButtonVariant } from '../primitives/button.tsx'
40
35
  import { Input } from '../primitives/input.tsx'
41
36
 
42
- /** Campos comuns aos três o corpo (`body`) é o que o texto da `description` não expressa. */
37
+ /** Campos comuns aos quatro modos imperativos. */
43
38
  interface DialogBase {
44
39
  title: React.ReactNode
45
- description?: React.ReactNode
46
40
  /** Ícone grande acima do título (o quadro do DialogMedia). */
47
41
  media?: React.ReactNode
48
42
  /** Conteúdo próprio entre a descrição e os botões (lista, detalhe destacado). */
@@ -80,16 +74,27 @@ export interface DialogChoice<TResult extends string = string> {
80
74
  }
81
75
 
82
76
  /** `choose` — uma escolha simples entre resultados explícitos. */
83
- export interface ChooseOptions<TResult extends string = string>
84
- extends Omit<DialogBase, 'action'> {
77
+ export interface ChooseOptions<TResult extends string = string> extends Omit<DialogBase, 'action'> {
85
78
  actions: readonly DialogChoice<TResult>[]
86
79
  }
87
80
 
88
81
  type Pending =
89
82
  | ({ kind: 'alert'; id: number; resolve: () => void } & AlertOptions)
90
- | ({ kind: 'confirm'; id: number; resolve: (ok: boolean) => void } & ConfirmOptions)
91
- | ({ kind: 'prompt'; id: number; resolve: (value: string | null) => void } & PromptOptions)
92
- | ({ kind: 'choose'; id: number; resolve: (result: string | null) => void } & ChooseOptions)
83
+ | ({
84
+ kind: 'confirm'
85
+ id: number
86
+ resolve: (ok: boolean) => void
87
+ } & ConfirmOptions)
88
+ | ({
89
+ kind: 'prompt'
90
+ id: number
91
+ resolve: (value: string | null) => void
92
+ } & PromptOptions)
93
+ | ({
94
+ kind: 'choose'
95
+ id: number
96
+ resolve: (result: string | null) => void
97
+ } & ChooseOptions)
93
98
 
94
99
  let nextId = 0
95
100
  let listener: ((p: Pending) => void) | null = null
@@ -205,10 +210,14 @@ export function DialogHost(): React.ReactElement {
205
210
  else p.resolve(value)
206
211
  }
207
212
 
208
- const context = current !== undefined && (current.kind === 'confirm' || current.kind === 'prompt')
209
- ? current.context ?? 'primary'
210
- : 'primary'
213
+ const context =
214
+ current !== undefined && (current.kind === 'confirm' || current.kind === 'prompt')
215
+ ? (current.context ?? 'primary')
216
+ : 'primary'
211
217
  const defaultAction = current?.kind === 'alert' ? 'OK' : 'Confirmar'
218
+ const bodyId = current === undefined ? undefined : `dialog-body-${current.id}`
219
+ const bodyDescribesDialog =
220
+ current !== undefined && (typeof current.body === 'string' || typeof current.body === 'number')
212
221
 
213
222
  return (
214
223
  <Dialog
@@ -223,6 +232,7 @@ export function DialogHost(): React.ReactElement {
223
232
  data-slot="dialog"
224
233
  data-kind={current.kind}
225
234
  className={current.kind === 'choose' ? 'max-w-lg' : undefined}
235
+ aria-describedby={bodyDescribesDialog ? bodyId : undefined}
226
236
  // Prompt: o foco vai pro input, não pro Cancelar (default do Dialog). Via
227
237
  // querySelector (não ref) — o Input é function component sem forwardRef, e isto
228
238
  // funciona igual em React 18 e 19.
@@ -241,11 +251,8 @@ export function DialogHost(): React.ReactElement {
241
251
  <DialogHeader>
242
252
  {current.media !== undefined && <DialogMedia>{current.media}</DialogMedia>}
243
253
  <DialogTitle>{current.title}</DialogTitle>
244
- {current.description !== undefined && (
245
- <DialogDescription>{current.description}</DialogDescription>
246
- )}
247
254
  </DialogHeader>
248
- {current.body !== undefined && <DialogBody>{current.body}</DialogBody>}
255
+ {current.body !== undefined && <DialogBody id={bodyId}>{current.body}</DialogBody>}
249
256
  {current.kind === 'prompt' && (
250
257
  <Input
251
258
  value={text}
@@ -270,18 +277,16 @@ export function DialogHost(): React.ReactElement {
270
277
  current.actions.map((action, index) => {
271
278
  const primary = index === current.actions.length - 1
272
279
  return (
273
- <DialogClose
274
- key={action.result}
275
- initialFocus={action.initialFocus}
276
- asChild
277
- >
280
+ <DialogClose key={action.result} initialFocus={action.initialFocus} asChild>
278
281
  <Button
279
282
  context={action.context ?? (primary ? 'primary' : 'neutral')}
280
283
  variant={action.variant ?? (primary ? 'solid' : 'ghost')}
281
284
  disabled={action.disabled}
282
- onClick={() => drop(current.id, (p) => {
283
- if (p.kind === 'choose') p.resolve(action.result)
284
- })}
285
+ onClick={() =>
286
+ drop(current.id, (p) => {
287
+ if (p.kind === 'choose') p.resolve(action.result)
288
+ })
289
+ }
285
290
  >
286
291
  {action.label}
287
292
  </Button>
@@ -8,6 +8,7 @@
8
8
  * pelo próprio form (fonte única do scroll e da faixa, sem string duplicada).
9
9
  */
10
10
 
11
+ import { useId, type ReactNode } from 'react'
11
12
  import {
12
13
  Dialog,
13
14
  DialogBody,
@@ -15,42 +16,53 @@ import {
15
16
  DialogFooter,
16
17
  DialogHeader,
17
18
  DialogTitle,
18
- DialogDescription,
19
19
  } from '../primitives/dialog.tsx'
20
20
  import { ActionForm, type ActionFormProps } from './form.tsx'
21
21
 
22
- export interface ActionFormDialogProps<TInput extends Record<string, unknown>, TData>
23
- extends ActionFormProps<TInput, TData> {
22
+ export interface ActionFormDialogProps<
23
+ TInput extends Record<string, unknown>,
24
+ TData,
25
+ > extends ActionFormProps<TInput, TData> {
24
26
  open: boolean
25
27
  onOpenChange: (open: boolean) => void
26
28
  title: string
27
- description?: string
29
+ /** Conteúdo relevante apresentado antes dos campos, como texto ou Alert. */
30
+ intro?: ReactNode
28
31
  }
29
32
 
30
33
  export function ActionFormDialog<TInput extends Record<string, unknown>, TData>({
31
34
  open,
32
35
  onOpenChange,
33
36
  title,
34
- description,
37
+ intro,
35
38
  onSuccess,
36
39
  onCancel,
37
40
  ...rest
38
41
  }: ActionFormDialogProps<TInput, TData>) {
42
+ const introId = useId()
39
43
  return (
40
44
  <Dialog open={open} onOpenChange={onOpenChange}>
41
45
  {/* Header e rodapé FIXOS, só o CORPO (campos) scrolla — o "X" não rola junto e
42
46
  forms longos não estouram a viewport. O content já é coluna flex com teto. */}
43
- <DialogContent>
47
+ <DialogContent aria-describedby={intro === undefined ? undefined : introId}>
44
48
  <DialogHeader>
45
49
  <DialogTitle>{title}</DialogTitle>
46
- {description !== undefined && <DialogDescription>{description}</DialogDescription>}
47
50
  </DialogHeader>
48
51
  <ActionForm
49
52
  {...rest}
50
53
  className="min-h-0 flex-1"
51
54
  // Slots injetados: o corpo vira DialogBody (rola), o rodapé vira DialogFooter (a
52
55
  // faixa da casa — fonte única). Os botões seguem dentro do <form> do ActionForm.
53
- body={(fields) => <DialogBody>{fields}</DialogBody>}
56
+ body={(fields) => (
57
+ <DialogBody>
58
+ {intro !== undefined && (
59
+ <div id={introId} className="mb-4">
60
+ {intro}
61
+ </div>
62
+ )}
63
+ {fields}
64
+ </DialogBody>
65
+ )}
54
66
  footer={(actions) => <DialogFooter>{actions}</DialogFooter>}
55
67
  onSuccess={(d) => {
56
68
  onSuccess?.(d)
@@ -25,6 +25,7 @@ import {
25
25
  isValidElement,
26
26
  useEffect,
27
27
  useLayoutEffect,
28
+ useId,
28
29
  useMemo,
29
30
  useRef,
30
31
  useState,
@@ -90,7 +91,6 @@ import {
90
91
  DialogBody,
91
92
  DialogClose,
92
93
  DialogContent,
93
- DialogDescription,
94
94
  DialogFooter,
95
95
  DialogHeader,
96
96
  DialogTitle,
@@ -1106,7 +1106,12 @@ export function ActionFilterBar({
1106
1106
  className="flex flex-wrap items-center gap-1.5"
1107
1107
  >
1108
1108
  {chips.map((chip) => (
1109
- <Badge key={chip.name} context="neutral" variant="solid" className="gap-1 pr-1">
1109
+ <Badge
1110
+ key={chip.name}
1111
+ context="neutral"
1112
+ variant="solid"
1113
+ className="gap-1 pr-1"
1114
+ >
1110
1115
  <span className="text-muted-foreground">{chip.label}:</span>{" "}
1111
1116
  {chip.value}
1112
1117
  <button
@@ -1193,6 +1198,7 @@ export function ActionList<TInput, TItem>({
1193
1198
  state: controlledState,
1194
1199
  onStateChange,
1195
1200
  }: ActionListProps<TInput, TItem>) {
1201
+ const confirmBodyId = useId();
1196
1202
  // Query-backed (useListAction): busca declarativa por [name, input] E refetch
1197
1203
  // automático quando um form/trigger invalida a action (`invalidates: ['x.list']`).
1198
1204
  // O input efetivo é computado abaixo; o hook re-busca quando ele muda.
@@ -1818,17 +1824,26 @@ export function ActionList<TInput, TItem>({
1818
1824
  if (!o) setConfirming(null);
1819
1825
  }}
1820
1826
  >
1821
- <DialogContent>
1827
+ <DialogContent
1828
+ aria-describedby={
1829
+ confirming.confirm?.message === undefined
1830
+ ? undefined
1831
+ : confirmBodyId
1832
+ }
1833
+ >
1822
1834
  <DialogHeader>
1823
1835
  <DialogTitle>
1824
1836
  {confirming.confirm?.title ?? confirming.label}
1825
1837
  </DialogTitle>
1826
- {confirming.confirm?.message !== undefined && (
1827
- <DialogDescription>
1828
- {confirming.confirm.message}
1829
- </DialogDescription>
1830
- )}
1831
1838
  </DialogHeader>
1839
+ {confirming.confirm?.message !== undefined && (
1840
+ <DialogBody
1841
+ id={confirmBodyId}
1842
+ className="text-sm text-muted-foreground"
1843
+ >
1844
+ {confirming.confirm.message}
1845
+ </DialogBody>
1846
+ )}
1832
1847
  <DialogFooter>
1833
1848
  <DialogClose initialFocus asChild>
1834
1849
  <Button variant="ghost" size="sm">
@@ -1838,7 +1853,9 @@ export function ActionList<TInput, TItem>({
1838
1853
  <DialogClose asChild>
1839
1854
  <Button
1840
1855
  size="sm"
1841
- context={confirming.destructive === true ? "danger" : "primary"}
1856
+ context={
1857
+ confirming.destructive === true ? "danger" : "primary"
1858
+ }
1842
1859
  variant="solid"
1843
1860
  onClick={() => {
1844
1861
  const a = confirming;