@softize/opus 12.10.0 → 13.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 (154) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/bin/lib/check.mjs +1098 -310
  3. package/bin/lib/copy.mjs +12 -5
  4. package/docs/adr/0003-dictionary-presentation-is-declared.md +3 -0
  5. package/docs/adr/0004-page-content-state-is-composed.md +65 -0
  6. package/docs/adr/0005-structural-surfaces-share-an-explicit-anatomy.md +180 -0
  7. package/docs/adr/0006-semantic-context-precedes-visual-variant.md +182 -0
  8. package/docs/adr/0007-toast-actions-form-an-ordered-collection.md +63 -0
  9. package/docs/adr/0008-hierarchical-navigation-is-composed-at-the-consumer-boundary.md +71 -0
  10. package/docs/radius-scale.md +1 -1
  11. package/package.json +1 -1
  12. package/registry/instructions/opus.md +5 -0
  13. package/registry/skills/build-opus-ui/SKILL.md +27 -16
  14. package/registry/skills/build-opus-ui/references/evaluations.md +16 -5
  15. package/registry/skills/build-opus-ui/references/ui-patterns.md +38 -15
  16. package/registry/skills/maintain-opus-docs/SKILL.md +83 -0
  17. package/registry/skills/maintain-opus-docs/agents/openai.yaml +4 -0
  18. package/registry/skills/maintain-opus-docs/references/editorial-standard.md +85 -0
  19. package/registry/skills/maintain-opus-docs/references/evaluations.md +34 -0
  20. package/registry/skills/maintain-opus-docs/scripts/audit-docs.mjs +81 -0
  21. package/registry/skills/model-opus-dictionary/SKILL.md +4 -2
  22. package/registry/skills/model-opus-dictionary/references/evaluations.md +4 -3
  23. package/registry/templates/app/src/App.tsx +1 -1
  24. package/src/core/dictionary.ts +52 -14
  25. package/src/core/index.ts +10 -0
  26. package/src/core/ui-context.ts +29 -0
  27. package/src/schema/drivers/zod.ts +17 -8
  28. package/src/ui/components/patterns/action-form-card.tsx +18 -12
  29. package/src/ui/components/patterns/confirm.tsx +163 -40
  30. package/src/ui/components/patterns/content-header.tsx +335 -61
  31. package/src/ui/components/patterns/data-state.tsx +23 -10
  32. package/src/ui/components/patterns/list.tsx +1097 -783
  33. package/src/ui/components/patterns/page-state.tsx +115 -0
  34. package/src/ui/components/patterns/page.tsx +231 -41
  35. package/src/ui/components/patterns/sidebar.tsx +357 -83
  36. package/src/ui/components/patterns/trigger.tsx +37 -30
  37. package/src/ui/components/patterns/view.tsx +7 -11
  38. package/src/ui/components/primitives/alert.tsx +298 -110
  39. package/src/ui/components/primitives/ask.tsx +2 -1
  40. package/src/ui/components/primitives/badge.tsx +91 -30
  41. package/src/ui/components/primitives/button.tsx +99 -60
  42. package/src/ui/components/primitives/calendar.tsx +39 -39
  43. package/src/ui/components/primitives/card.tsx +96 -23
  44. package/src/ui/components/primitives/detail.tsx +2 -2
  45. package/src/ui/components/primitives/dialog.tsx +196 -39
  46. package/src/ui/components/primitives/dictionary-value.tsx +9 -14
  47. package/src/ui/components/primitives/dot.tsx +74 -21
  48. package/src/ui/components/primitives/drawer.tsx +40 -24
  49. package/src/ui/components/primitives/empty.tsx +3 -3
  50. package/src/ui/components/primitives/item.tsx +135 -79
  51. package/src/ui/components/primitives/menu.tsx +11 -3
  52. package/src/ui/components/primitives/metric-card.tsx +133 -0
  53. package/src/ui/components/primitives/sonner.tsx +187 -8
  54. package/src/ui/components/primitives/table.tsx +2 -2
  55. package/src/ui/docs/DocBrowser.tsx +104 -25
  56. package/src/ui/docs/changelog.tsx +1 -1
  57. package/src/ui/docs/content/accordion.md +22 -16
  58. package/src/ui/docs/content/action-form-card.md +8 -8
  59. package/src/ui/docs/content/action-form-dialog.md +9 -9
  60. package/src/ui/docs/content/action-form.md +28 -34
  61. package/src/ui/docs/content/action-list-dialog.md +11 -6
  62. package/src/ui/docs/content/action-list.md +64 -39
  63. package/src/ui/docs/content/action-trigger.md +21 -14
  64. package/src/ui/docs/content/action-view.md +8 -8
  65. package/src/ui/docs/content/actions.md +9 -9
  66. package/src/ui/docs/content/ai.md +3 -3
  67. package/src/ui/docs/content/alert.md +54 -28
  68. package/src/ui/docs/content/aspect-ratio.md +4 -4
  69. package/src/ui/docs/content/audit.md +2 -2
  70. package/src/ui/docs/content/auth.md +3 -3
  71. package/src/ui/docs/content/avatar.md +34 -14
  72. package/src/ui/docs/content/badge.md +21 -22
  73. package/src/ui/docs/content/breadcrumb.md +13 -8
  74. package/src/ui/docs/content/button.md +93 -15
  75. package/src/ui/docs/content/calendar.md +5 -5
  76. package/src/ui/docs/content/card.md +6 -6
  77. package/src/ui/docs/content/carousel.md +16 -11
  78. package/src/ui/docs/content/chat.md +3 -3
  79. package/src/ui/docs/content/checkbox.md +7 -7
  80. package/src/ui/docs/content/cli.md +5 -5
  81. package/src/ui/docs/content/collapsible.md +8 -8
  82. package/src/ui/docs/content/command.md +16 -8
  83. package/src/ui/docs/content/composer.md +2 -2
  84. package/src/ui/docs/content/content.md +44 -0
  85. package/src/ui/docs/content/copyable.md +4 -3
  86. package/src/ui/docs/content/customization.md +7 -7
  87. package/src/ui/docs/content/cycle.md +3 -3
  88. package/src/ui/docs/content/data-state.md +11 -12
  89. package/src/ui/docs/content/data.md +26 -33
  90. package/src/ui/docs/content/detail.md +8 -5
  91. package/src/ui/docs/content/dialog.md +339 -31
  92. package/src/ui/docs/content/dictionary-value.md +19 -18
  93. package/src/ui/docs/content/dock.md +3 -3
  94. package/src/ui/docs/content/dot.md +7 -7
  95. package/src/ui/docs/content/drawer.md +32 -16
  96. package/src/ui/docs/content/empty-value.md +2 -2
  97. package/src/ui/docs/content/empty.md +19 -12
  98. package/src/ui/docs/content/events.md +4 -4
  99. package/src/ui/docs/content/field.md +34 -12
  100. package/src/ui/docs/content/getting-started.md +1 -1
  101. package/src/ui/docs/content/icon-picker.md +8 -4
  102. package/src/ui/docs/content/input-otp.md +20 -12
  103. package/src/ui/docs/content/input.md +121 -9
  104. package/src/ui/docs/content/item.md +64 -24
  105. package/src/ui/docs/content/kbd.md +19 -11
  106. package/src/ui/docs/content/label.md +5 -3
  107. package/src/ui/docs/content/log.md +4 -4
  108. package/src/ui/docs/content/markdown.md +7 -6
  109. package/src/ui/docs/content/mcp.md +13 -15
  110. package/src/ui/docs/content/menu.md +36 -17
  111. package/src/ui/docs/content/metric-card.md +41 -0
  112. package/src/ui/docs/content/observability.md +2 -2
  113. package/src/ui/docs/content/page.md +93 -10
  114. package/src/ui/docs/content/pagination.md +22 -17
  115. package/src/ui/docs/content/popover.md +16 -8
  116. package/src/ui/docs/content/progress.md +7 -5
  117. package/src/ui/docs/content/queue.md +5 -5
  118. package/src/ui/docs/content/radio-group.md +20 -12
  119. package/src/ui/docs/content/router.md +11 -6
  120. package/src/ui/docs/content/scheduler.md +4 -5
  121. package/src/ui/docs/content/scroll-area.md +12 -7
  122. package/src/ui/docs/content/select.md +42 -29
  123. package/src/ui/docs/content/semantic-context.md +63 -0
  124. package/src/ui/docs/content/separator.md +5 -5
  125. package/src/ui/docs/content/sidebar.md +325 -56
  126. package/src/ui/docs/content/skeleton.md +5 -4
  127. package/src/ui/docs/content/slider.md +8 -7
  128. package/src/ui/docs/content/spinner.md +8 -8
  129. package/src/ui/docs/content/split.md +8 -5
  130. package/src/ui/docs/content/storage.md +6 -8
  131. package/src/ui/docs/content/switch.md +8 -7
  132. package/src/ui/docs/content/table.md +16 -6
  133. package/src/ui/docs/content/tabs.md +28 -14
  134. package/src/ui/docs/content/testing.md +9 -11
  135. package/src/ui/docs/content/textarea.md +5 -4
  136. package/src/ui/docs/content/toast.md +47 -13
  137. package/src/ui/docs/content/toggle.md +75 -7
  138. package/src/ui/docs/content/tokens.md +31 -3
  139. package/src/ui/docs/content/tooltip.md +19 -11
  140. package/src/ui/docs/content/truncate.md +7 -8
  141. package/src/ui/docs/content/ui.md +10 -9
  142. package/src/ui/docs/content/upgrading.md +7 -8
  143. package/src/ui/docs/doc-client.tsx +2 -2
  144. package/src/ui/docs/registry.tsx +580 -229
  145. package/src/ui/lib/semantic-context.ts +30 -0
  146. package/src/ui/meta.ts +278 -286
  147. package/src/ui/react.tsx +377 -111
  148. package/src/ui/theme.css +116 -0
  149. package/src/ui/components/primitives/alert-dialog.tsx +0 -190
  150. package/src/ui/docs/content/alert-dialog.md +0 -73
  151. package/src/ui/docs/content/button-group.md +0 -71
  152. package/src/ui/docs/content/confirm.md +0 -120
  153. package/src/ui/docs/content/input-group.md +0 -78
  154. package/src/ui/docs/content/toggle-group.md +0 -81
@@ -8,14 +8,20 @@
8
8
 
9
9
  import { getLogicalType } from './logical-type.ts'
10
10
  import type { LogicalTypeMeta } from './types.ts'
11
+ import {
12
+ DICT_CONTEXTS,
13
+ isDictContext,
14
+ type DictContext,
15
+ } from './ui-context.ts'
11
16
 
12
17
  /** Papel de apresentação declarado no nível do dicionário. */
13
18
  export const DICT_PRESENTATIONS = ['classification', 'status', 'stage', 'plain'] as const
14
19
  export type DictPresentation = (typeof DICT_PRESENTATIONS)[number]
15
20
 
16
- /** Tom explícito de uma entrada — honrado apenas por status e estágios. */
17
- export const DICT_TONES = ['neutral', 'info', 'success', 'warning', 'danger'] as const
18
- export type DictTone = (typeof DICT_TONES)[number]
21
+ /** @deprecated Use `DICT_CONTEXTS` (ADR 0006). */
22
+ export const DICT_TONES = DICT_CONTEXTS
23
+ /** @deprecated Use `DictContext` (ADR 0006). */
24
+ export type DictTone = DictContext
19
25
 
20
26
  /**
21
27
  * Meta de cada chave de um dict. `label` obrigatório; resto livre.
@@ -24,7 +30,7 @@ export type DictTone = (typeof DICT_TONES)[number]
24
30
  * entra/sai) — a fonte rica de consulta rápida; flui pro manifest/Lens e NÃO vira tooltip.
25
31
  * - `description` é o texto curto voltado à pessoa: aparece no tooltip do `DictionaryValue`
26
32
  * e como apoio de opção. Não repetir o rótulo — descrição igual ao rótulo não renderiza.
27
- * - `tone` é o tom tonal do badge de status/estágio; classificação ignora.
33
+ * - `context` é o contexto semântico do status/estágio; classificação ignora.
28
34
  * - `icon` é o identificador estável do catálogo de ícones do Opus (`iconPickerIcons`);
29
35
  * nome fora do catálogo não renderiza ícone.
30
36
  * - `color` permanece metadata livre (lida pela Lens); o renderer não a interpreta.
@@ -33,7 +39,9 @@ export interface DictEntryMeta {
33
39
  label: string
34
40
  doc?: string
35
41
  description?: string
36
- tone?: DictTone
42
+ context?: DictContext
43
+ /** @deprecated Use `context`. Compatibilidade temporária da ADR 0006. */
44
+ tone?: DictContext
37
45
  icon?: string
38
46
  color?: string
39
47
  order?: number
@@ -44,8 +52,16 @@ export function isDictPresentation(value: unknown): value is DictPresentation {
44
52
  return typeof value === 'string' && (DICT_PRESENTATIONS as readonly string[]).includes(value)
45
53
  }
46
54
 
47
- export function isDictTone(value: unknown): value is DictTone {
48
- return typeof value === 'string' && (DICT_TONES as readonly string[]).includes(value)
55
+ /** @deprecated Use `isDictContext` (ADR 0006). */
56
+ export const isDictTone = isDictContext
57
+
58
+ /** Normaliza a metadata canônica e seu alias temporário. */
59
+ export function dictionaryEntryContext(entry: DictEntryMeta): DictContext | undefined {
60
+ return isDictContext(entry.context)
61
+ ? entry.context
62
+ : isDictContext(entry.tone)
63
+ ? entry.tone
64
+ : undefined
49
65
  }
50
66
 
51
67
  /** Forma normalizada de um dicionário, independente de onde a meta foi lida. */
@@ -92,7 +108,11 @@ export interface DictionaryValuePresentation {
92
108
  /** False quando o valor não está no dicionário. */
93
109
  known: boolean
94
110
  presentation: DictPresentation
95
- /** `null` = texto; `'outline'` = classificação; tom = badge tonal de status/estágio. */
111
+ /** Contexto semântico; `null` quando a apresentação é texto. */
112
+ context: DictContext | null
113
+ /** Tratamento visual; `null` quando a apresentação é texto. */
114
+ variant: 'subtle' | 'outline' | null
115
+ /** @deprecated Use `context` e `variant`. */
96
116
  badge: 'outline' | DictTone | null
97
117
  /** Identificador do catálogo, só quando declarado na entrada. */
98
118
  icon: string | null
@@ -115,8 +135,8 @@ export function dictionaryDescription(label: string, description: unknown): stri
115
135
  * Aplica a tabela de decisão da ADR 0003:
116
136
  *
117
137
  * | papel | forma | variante |
118
- * | classification | badge | outline (ignora `tone`) |
119
- * | status / stage | badge | tonal: `tone` ?? neutral |
138
+ * | classification | badge | neutral + outline |
139
+ * | status / stage | badge | `context` + subtle |
120
140
  * | plain / ausente | texto | — |
121
141
  *
122
142
  * Ícone só quando declarado; tooltip só quando `description` acrescenta. Valor fora do
@@ -132,20 +152,38 @@ export function presentDictionaryValue(
132
152
  ? descriptor.entries[value]
133
153
  : undefined
134
154
  if (entry === undefined || typeof entry.label !== 'string') {
135
- return { value, label: value, known: false, presentation, badge: null, icon: null, description: null }
155
+ return {
156
+ value,
157
+ label: value,
158
+ known: false,
159
+ presentation,
160
+ context: null,
161
+ variant: null,
162
+ badge: null,
163
+ icon: null,
164
+ description: null,
165
+ }
136
166
  }
137
- const badge: DictionaryValuePresentation['badge'] =
167
+ const context: DictionaryValuePresentation['context'] =
168
+ presentation === 'classification'
169
+ ? 'neutral'
170
+ : presentation === 'status' || presentation === 'stage'
171
+ ? (dictionaryEntryContext(entry) ?? 'neutral')
172
+ : null
173
+ const variant: DictionaryValuePresentation['variant'] =
138
174
  presentation === 'classification'
139
175
  ? 'outline'
140
176
  : presentation === 'status' || presentation === 'stage'
141
- ? (isDictTone(entry.tone) ? entry.tone : 'neutral')
177
+ ? 'subtle'
142
178
  : null
143
179
  return {
144
180
  value,
145
181
  label: entry.label,
146
182
  known: true,
147
183
  presentation,
148
- badge,
184
+ context,
185
+ variant,
186
+ badge: variant === 'outline' ? 'outline' : context,
149
187
  icon: typeof entry.icon === 'string' && entry.icon.length > 0 ? entry.icon : null,
150
188
  description: dictionaryDescription(entry.label, entry.description),
151
189
  }
package/src/core/index.ts CHANGED
@@ -138,12 +138,22 @@ export { normalizeTraceContext } from './trace.ts'
138
138
  // `@softize/opus/schema` re-exporta os dois pra manter a API de sempre.
139
139
  export { attachLogicalType, getLogicalType } from './logical-type.ts'
140
140
 
141
+ // — Contextos semânticos de UI (ADR 0006) ————————————————————————————————————
142
+ export {
143
+ UI_CONTEXTS,
144
+ DICT_CONTEXTS,
145
+ isUiContext,
146
+ isDictContext,
147
+ } from './ui-context.ts'
148
+ export type { UiContext, DictContext } from './ui-context.ts'
149
+
141
150
  // — Dicionários (apresentação declarada — ADR 0003) ————————————————————————————
142
151
  export {
143
152
  DICT_PRESENTATIONS,
144
153
  DICT_TONES,
145
154
  isDictPresentation,
146
155
  isDictTone,
156
+ dictionaryEntryContext,
147
157
  dictionaryDescriptor,
148
158
  dictionaryDescription,
149
159
  presentDictionaryValue,
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Famílias semânticas compartilhadas entre contratos e UI (ADR 0006).
3
+ *
4
+ * Contexto responde por que um realce existe; cada componente decide como materializá-lo.
5
+ * Tema (light/dark), geometria e variante visual são eixos independentes.
6
+ */
7
+ export const UI_CONTEXTS = [
8
+ 'neutral',
9
+ 'primary',
10
+ 'info',
11
+ 'success',
12
+ 'warning',
13
+ 'danger',
14
+ ] as const
15
+
16
+ export type UiContext = (typeof UI_CONTEXTS)[number]
17
+
18
+ /** Contextos válidos para estados e estágios de domínio. */
19
+ export const DICT_CONTEXTS = ['neutral', 'info', 'success', 'warning', 'danger'] as const
20
+
21
+ export type DictContext = (typeof DICT_CONTEXTS)[number]
22
+
23
+ export function isUiContext(value: unknown): value is UiContext {
24
+ return typeof value === 'string' && (UI_CONTEXTS as readonly string[]).includes(value)
25
+ }
26
+
27
+ export function isDictContext(value: unknown): value is DictContext {
28
+ return typeof value === 'string' && (DICT_CONTEXTS as readonly string[]).includes(value)
29
+ }
@@ -36,6 +36,7 @@ import {
36
36
  type DictEntryMeta,
37
37
  type DictPresentation,
38
38
  } from '../../core/dictionary.ts'
39
+ import { DICT_CONTEXTS, isDictContext } from '../../core/ui-context.ts'
39
40
  import {
40
41
  formatDatetime,
41
42
  formatDate,
@@ -656,7 +657,8 @@ function isLogicalType(value: unknown): value is LogicalType<unknown> {
656
657
  /**
657
658
  * Meta de cada chave de um dict — o tipo mora no core (`@softize/opus/core`, ADR 0003) e é
658
659
  * reexportado aqui pra manter a API: `label` obrigatório, `doc` (negócio, manifest/Lens),
659
- * `description` (tooltip), `tone` (badge tonal de status/estágio), `icon` (catálogo).
660
+ * `description` (tooltip), `context` (contexto semântico de status/estágio), `icon` (catálogo).
661
+ * `tone` permanece como alias temporário de compatibilidade.
660
662
  */
661
663
  export type { DictEntryMeta }
662
664
 
@@ -716,16 +718,23 @@ const dict = <const M extends Record<string, DictEntryMeta>>(
716
718
  `t.dict: presentation "${String(opts.presentation)}" inválida; use ${DICT_PRESENTATIONS.join(' | ')}`,
717
719
  )
718
720
  }
719
- const tonal = opts?.presentation === 'status' || opts?.presentation === 'stage'
721
+ const contextual = opts?.presentation === 'status' || opts?.presentation === 'stage'
720
722
  for (const key of keys) {
721
- const tone = (entries[key] as DictEntryMeta).tone
722
- if (tone === undefined) continue
723
- if (!isDictTone(tone)) {
724
- throw new Error(`t.dict: tone "${String(tone)}" inválido em "${key}"; use ${DICT_TONES.join(' | ')}`)
723
+ const entry = entries[key] as DictEntryMeta
724
+ if (entry.context !== undefined && entry.tone !== undefined) {
725
+ throw new Error(`t.dict: use context ou tone em "${key}", nunca os dois`)
725
726
  }
726
- if (!tonal) {
727
+ const context = entry.context ?? entry.tone
728
+ if (context === undefined) continue
729
+ const legacy = entry.context === undefined
730
+ if (!(legacy ? isDictTone(context) : isDictContext(context))) {
731
+ const values = legacy ? DICT_TONES : DICT_CONTEXTS
732
+ const field = legacy ? 'tone' : 'context'
733
+ throw new Error(`t.dict: ${field} "${String(context)}" inválido em "${key}"; use ${values.join(' | ')}`)
734
+ }
735
+ if (!contextual) {
727
736
  throw new Error(
728
- `t.dict: tone em "${key}" exige presentation "status" ou "stage" (classificação usa outline; plain é texto)`,
737
+ `t.dict: ${legacy ? 'tone' : 'context'} em "${key}" exige presentation "status" ou "stage" (classificação usa outline; plain é texto)`,
729
738
  )
730
739
  }
731
740
  }
@@ -6,22 +6,24 @@
6
6
  */
7
7
  import {
8
8
  Card,
9
- CardContent,
9
+ CardBody,
10
10
  CardDescription,
11
11
  CardFooter,
12
12
  CardHeader,
13
13
  CardTitle,
14
- } from '../primitives/card.tsx'
15
- import { ActionForm, type ActionFormProps } from './form.tsx'
14
+ } from "../primitives/card.tsx";
15
+ import { ActionForm, type ActionFormProps } from "./form.tsx";
16
16
 
17
- export interface ActionFormCardProps<TInput extends Record<string, unknown>, TData>
18
- extends Omit<ActionFormProps<TInput, TData>, 'body' | 'footer'> {
17
+ export interface ActionFormCardProps<
18
+ TInput extends Record<string, unknown>,
19
+ TData,
20
+ > extends Omit<ActionFormProps<TInput, TData>, "body" | "footer"> {
19
21
  /** Título do header do card. Sem ele, o card começa direto no conteúdo. */
20
- title?: string
22
+ title?: string;
21
23
  /** Subtítulo opcional, abaixo do título. */
22
- description?: string
24
+ description?: string;
23
25
  /** Classes do Card (a superfície). `className` vai pro <form>. */
24
- cardClassName?: string
26
+ cardClassName?: string;
25
27
  }
26
28
 
27
29
  export function ActionFormCard<TInput extends Record<string, unknown>, TData>({
@@ -35,14 +37,18 @@ export function ActionFormCard<TInput extends Record<string, unknown>, TData>({
35
37
  {(title !== undefined || description !== undefined) && (
36
38
  <CardHeader>
37
39
  {title !== undefined && <CardTitle>{title}</CardTitle>}
38
- {description !== undefined && <CardDescription>{description}</CardDescription>}
40
+ {description !== undefined && (
41
+ <CardDescription>{description}</CardDescription>
42
+ )}
39
43
  </CardHeader>
40
44
  )}
41
45
  <ActionForm
42
46
  {...rest}
43
- body={(fields) => <CardContent>{fields}</CardContent>}
44
- footer={(actions) => <CardFooter className="justify-end">{actions}</CardFooter>}
47
+ body={(fields) => <CardBody>{fields}</CardBody>}
48
+ footer={(actions) => (
49
+ <CardFooter className="justify-end">{actions}</CardFooter>
50
+ )}
45
51
  />
46
52
  </Card>
47
- )
53
+ );
48
54
  }
@@ -1,42 +1,49 @@
1
1
  /**
2
- * `dialog` — o trio imperativo (alert · confirm · prompt) em UMA linha.
2
+ * `dialog` — respostas imperativas (alert · confirm · prompt · choose) em UMA linha.
3
3
  *
4
4
  * await dialog.alert({ title: 'Sessão expirada', description: 'Você será desconectado.' })
5
- * if (await dialog.confirm({ title: 'Excluir a sessão?', variant: 'destructive' })) …
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
  *
8
8
  * Mesma forma do `toast` (superfície imperativa + host no shell), com uma diferença: eles
9
9
  * RESPONDEM — `alert` devolve `Promise<void>`, `confirm` `Promise<boolean>`, `prompt`
10
- * `Promise<string | null>`. É o que tira as ~15 linhas de JSX que cada tela montava.
10
+ * `Promise<string | null>` e `choose` a união dos resultados declarados. É o que tira as
11
+ * ~15 linhas de JSX que cada tela montava.
11
12
  *
12
13
  * Namespace `dialog` de propósito: `window.alert/confirm/prompt` são globais do browser, e
13
14
  * um import esquecido cairia no nativo (cinza, sem tema) sem erro. `window.dialog` não
14
15
  * existe — a família fica imune ao footgun. `confirm()` segue exportado como alias.
15
16
  *
16
- * Por baixo é o <AlertDialog>: `role="alertdialog"`, não fecha no clique fora, foco preso
17
+ * Por baixo é o <Dialog mode="alert">: `role="alertdialog"`, não fecha no clique fora, foco preso
17
18
  * — a semântica que uma interrupção que EXIGE resposta pede. ESC/Cancelar resolve o "não"
18
19
  * de cada tipo (void, `false`, `null`).
19
20
  */
20
21
 
21
22
  import * as React from 'react'
23
+ import type { UiContext } from '../../../core/ui-context.ts'
22
24
  import {
23
- AlertDialog,
24
- AlertDialogAction,
25
- AlertDialogCancel,
26
- AlertDialogContent,
27
- AlertDialogDescription,
28
- AlertDialogFooter,
29
- AlertDialogHeader,
30
- AlertDialogMedia,
31
- AlertDialogTitle,
32
- } from '../primitives/alert-dialog.tsx'
25
+ Dialog,
26
+ DialogBody,
27
+ DialogClose,
28
+ DialogContent,
29
+ DialogDescription,
30
+ DialogFooter,
31
+ DialogHeader,
32
+ DialogMedia,
33
+ DialogTitle,
34
+ } from '../primitives/dialog.tsx'
35
+ import {
36
+ Button,
37
+ type ButtonContext,
38
+ type ButtonVariant,
39
+ } from '../primitives/button.tsx'
33
40
  import { Input } from '../primitives/input.tsx'
34
41
 
35
42
  /** Campos comuns aos três — o corpo (`body`) é o que o texto da `description` não expressa. */
36
43
  interface DialogBase {
37
44
  title: React.ReactNode
38
45
  description?: React.ReactNode
39
- /** Ícone grande acima do título (o quadro do AlertDialogMedia). */
46
+ /** Ícone grande acima do título (o quadro do DialogMedia). */
40
47
  media?: React.ReactNode
41
48
  /** Conteúdo próprio entre a descrição e os botões (lista, detalhe destacado). */
42
49
  body?: React.ReactNode
@@ -50,22 +57,43 @@ export interface AlertOptions extends DialogBase {}
50
57
  /** `confirm` — pergunta de sim/não. */
51
58
  export interface ConfirmOptions extends DialogBase {
52
59
  cancel?: string
53
- /** `destructive` pinta o botão de confirmar — o que apaga tem que parecer o que apaga. */
60
+ /** Contexto do botão de confirmação. */
61
+ context?: Extract<UiContext, 'primary' | 'danger'>
62
+ /** @deprecated Use `context="danger"`. */
54
63
  variant?: 'default' | 'destructive'
55
64
  }
56
65
 
57
66
  /** `prompt` — uma string livre. Form de verdade (campos, contrato) → ActionFormDialog. */
58
67
  export interface PromptOptions extends DialogBase {
59
68
  cancel?: string
69
+ context?: Extract<UiContext, 'primary' | 'danger'>
70
+ /** @deprecated Use `context="danger"`. */
60
71
  variant?: 'default' | 'destructive'
61
72
  placeholder?: string
62
73
  defaultValue?: string
63
74
  }
64
75
 
76
+ export interface DialogChoice<TResult extends string = string> {
77
+ /** Valor devolvido quando esta ação for escolhida. Deve ser único na coleção. */
78
+ result: TResult
79
+ label: React.ReactNode
80
+ context?: ButtonContext
81
+ variant?: ButtonVariant
82
+ initialFocus?: boolean
83
+ disabled?: boolean
84
+ }
85
+
86
+ /** `choose` — uma escolha simples entre resultados explícitos. */
87
+ export interface ChooseOptions<TResult extends string = string>
88
+ extends Omit<DialogBase, 'action'> {
89
+ actions: readonly DialogChoice<TResult>[]
90
+ }
91
+
65
92
  type Pending =
66
93
  | ({ kind: 'alert'; id: number; resolve: () => void } & AlertOptions)
67
94
  | ({ kind: 'confirm'; id: number; resolve: (ok: boolean) => void } & ConfirmOptions)
68
95
  | ({ kind: 'prompt'; id: number; resolve: (value: string | null) => void } & PromptOptions)
96
+ | ({ kind: 'choose'; id: number; resolve: (result: string | null) => void } & ChooseOptions)
69
97
 
70
98
  let nextId = 0
71
99
  let listener: ((p: Pending) => void) | null = null
@@ -79,6 +107,33 @@ function requireHost(fn: string): Error | null {
79
107
  )
80
108
  }
81
109
 
110
+ function rejectAmbiguousContext(
111
+ fn: string,
112
+ options: ConfirmOptions | PromptOptions,
113
+ ): Error | null {
114
+ if (options.context === undefined || options.variant === undefined) return null
115
+ return new Error(
116
+ `${fn} não permite combinar context com uma variante semântica legada.`,
117
+ )
118
+ }
119
+
120
+ function validateChoose<TResult extends string>(options: ChooseOptions<TResult>): Error | null {
121
+ if (options.actions.length === 0) {
122
+ return new Error('dialog.choose() exige pelo menos uma ação.')
123
+ }
124
+ const focused = options.actions.filter((action) => action.initialFocus === true)
125
+ if (focused.length !== 1) {
126
+ return new Error('dialog.choose() exige uma única ação com initialFocus.')
127
+ }
128
+ if (focused[0]?.disabled === true) {
129
+ return new Error('dialog.choose() não permite initialFocus em uma ação desabilitada.')
130
+ }
131
+ if (new Set(options.actions.map((action) => action.result)).size !== options.actions.length) {
132
+ return new Error('dialog.choose() exige resultados únicos.')
133
+ }
134
+ return null
135
+ }
136
+
82
137
  export const dialog = {
83
138
  /** Avisa e espera o "OK". `Promise<void>` — só reconhecimento, sem decisão. */
84
139
  alert(options: AlertOptions): Promise<void> {
@@ -90,6 +145,8 @@ export const dialog = {
90
145
  },
91
146
  /** Pergunta sim/não. `Promise<boolean>` — fechar/ESC resolve `false`. */
92
147
  confirm(options: ConfirmOptions): Promise<boolean> {
148
+ const ambiguous = rejectAmbiguousContext('dialog.confirm()', options)
149
+ if (ambiguous !== null) return Promise.reject(ambiguous)
93
150
  const err = requireHost('dialog.confirm()')
94
151
  if (err !== null) return Promise.reject(err)
95
152
  return new Promise<boolean>((resolve) => {
@@ -98,12 +155,29 @@ export const dialog = {
98
155
  },
99
156
  /** Pede uma string. `Promise<string | null>` — fechar/ESC/Cancelar resolve `null`. */
100
157
  prompt(options: PromptOptions): Promise<string | null> {
158
+ const ambiguous = rejectAmbiguousContext('dialog.prompt()', options)
159
+ if (ambiguous !== null) return Promise.reject(ambiguous)
101
160
  const err = requireHost('dialog.prompt()')
102
161
  if (err !== null) return Promise.reject(err)
103
162
  return new Promise<string | null>((resolve) => {
104
163
  listener?.({ ...options, kind: 'prompt', id: nextId++, resolve })
105
164
  })
106
165
  },
166
+ /** Oferece resultados explícitos. Fechar/ESC sem escolher resolve `null`. */
167
+ choose<const TResult extends string>(options: ChooseOptions<TResult>): Promise<TResult | null> {
168
+ const invalid = validateChoose(options)
169
+ if (invalid !== null) return Promise.reject(invalid)
170
+ const err = requireHost('dialog.choose()')
171
+ if (err !== null) return Promise.reject(err)
172
+ return new Promise<TResult | null>((resolve) => {
173
+ listener?.({
174
+ ...options,
175
+ kind: 'choose',
176
+ id: nextId++,
177
+ resolve: (result) => resolve(result as TResult | null),
178
+ })
179
+ })
180
+ },
107
181
  }
108
182
 
109
183
  /** Alias retrocompatível — `confirm()` é `dialog.confirm()`. */
@@ -145,6 +219,7 @@ export function DialogHost(): React.ReactElement {
145
219
  const cancel = (p: Pending): void => {
146
220
  if (p.kind === 'alert') p.resolve()
147
221
  else if (p.kind === 'confirm') p.resolve(false)
222
+ else if (p.kind === 'prompt') p.resolve(null)
148
223
  else p.resolve(null)
149
224
  }
150
225
  const accept = (p: Pending, value: string): void => {
@@ -153,21 +228,28 @@ export function DialogHost(): React.ReactElement {
153
228
  else p.resolve(value)
154
229
  }
155
230
 
156
- const variant = current !== undefined && current.kind !== 'alert' ? current.variant : undefined
231
+ const variant = current !== undefined && (current.kind === 'confirm' || current.kind === 'prompt')
232
+ ? current.variant
233
+ : undefined
234
+ const context = current !== undefined && (current.kind === 'confirm' || current.kind === 'prompt')
235
+ ? current.context ?? (variant === 'destructive' ? 'danger' : 'primary')
236
+ : 'primary'
157
237
  const defaultAction = current?.kind === 'alert' ? 'OK' : 'Confirmar'
158
238
 
159
239
  return (
160
- <AlertDialog
240
+ <Dialog
241
+ mode="alert"
161
242
  open={current !== undefined}
162
243
  onOpenChange={(open) => {
163
244
  if (!open && current !== undefined) drop(current.id, cancel)
164
245
  }}
165
246
  >
166
247
  {current !== undefined && (
167
- <AlertDialogContent
248
+ <DialogContent
168
249
  data-slot="dialog"
169
250
  data-kind={current.kind}
170
- // Prompt: o foco vai pro input, não pro Cancelar (default do AlertDialog). Via
251
+ className={current.kind === 'choose' ? 'max-w-lg' : undefined}
252
+ // Prompt: o foco vai pro input, não pro Cancelar (default do Dialog). Via
171
253
  // querySelector (não ref) — o Input é function component sem forwardRef, e isto
172
254
  // funciona igual em React 18 e 19.
173
255
  onOpenAutoFocus={
@@ -182,14 +264,14 @@ export function DialogHost(): React.ReactElement {
182
264
  : undefined
183
265
  }
184
266
  >
185
- <AlertDialogHeader>
186
- {current.media !== undefined && <AlertDialogMedia>{current.media}</AlertDialogMedia>}
187
- <AlertDialogTitle>{current.title}</AlertDialogTitle>
267
+ <DialogHeader>
268
+ {current.media !== undefined && <DialogMedia>{current.media}</DialogMedia>}
269
+ <DialogTitle>{current.title}</DialogTitle>
188
270
  {current.description !== undefined && (
189
- <AlertDialogDescription>{current.description}</AlertDialogDescription>
271
+ <DialogDescription>{current.description}</DialogDescription>
190
272
  )}
191
- </AlertDialogHeader>
192
- {current.body !== undefined && <div data-slot="dialog-body">{current.body}</div>}
273
+ </DialogHeader>
274
+ {current.body !== undefined && <DialogBody>{current.body}</DialogBody>}
193
275
  {current.kind === 'prompt' && (
194
276
  <Input
195
277
  value={text}
@@ -203,22 +285,63 @@ export function DialogHost(): React.ReactElement {
203
285
  }}
204
286
  />
205
287
  )}
206
- <AlertDialogFooter>
207
- {current.kind !== 'alert' && (
208
- <AlertDialogCancel onClick={() => drop(current.id, cancel)}>
209
- {current.cancel ?? 'Cancelar'}
210
- </AlertDialogCancel>
288
+ <DialogFooter
289
+ className={
290
+ current.kind === 'choose'
291
+ ? 'grid-cols-[repeat(auto-fit,minmax(7rem,1fr))]'
292
+ : undefined
293
+ }
294
+ >
295
+ {current.kind === 'choose' ? (
296
+ current.actions.map((action, index) => {
297
+ const primary = index === current.actions.length - 1
298
+ return (
299
+ <DialogClose
300
+ key={action.result}
301
+ initialFocus={action.initialFocus}
302
+ asChild
303
+ >
304
+ <Button
305
+ context={action.context ?? (primary ? 'primary' : 'neutral')}
306
+ variant={action.variant ?? (primary ? 'solid' : 'ghost')}
307
+ disabled={action.disabled}
308
+ onClick={() => drop(current.id, (p) => {
309
+ if (p.kind === 'choose') p.resolve(action.result)
310
+ })}
311
+ >
312
+ {action.label}
313
+ </Button>
314
+ </DialogClose>
315
+ )
316
+ })
317
+ ) : (
318
+ <>
319
+ {current.kind !== 'alert' && (
320
+ <DialogClose initialFocus asChild>
321
+ <Button
322
+ context="neutral"
323
+ variant="ghost"
324
+ onClick={() => drop(current.id, cancel)}
325
+ >
326
+ {current.cancel ?? 'Cancelar'}
327
+ </Button>
328
+ </DialogClose>
329
+ )}
330
+ <DialogClose initialFocus={current.kind === 'alert'} asChild>
331
+ <Button
332
+ context={context}
333
+ variant="solid"
334
+ onClick={() => drop(current.id, (p) => accept(p, text))}
335
+ >
336
+ {current.action ?? defaultAction}
337
+ </Button>
338
+ </DialogClose>
339
+ </>
211
340
  )}
212
- <AlertDialogAction
213
- variant={variant === 'destructive' ? 'destructive' : 'default'}
214
- onClick={() => drop(current.id, (p) => accept(p, text))}
215
- >
216
- {current.action ?? defaultAction}
217
- </AlertDialogAction>
218
- </AlertDialogFooter>
219
- </AlertDialogContent>
341
+ </DialogFooter>
342
+ </DialogContent>
220
343
  )}
221
- </AlertDialog>
344
+ </Dialog>
222
345
  )
223
346
  }
224
347