@softize/opus 25.0.0 → 25.2.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 (46) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/bin/cli.mjs +5 -0
  3. package/bin/lib/assistant-schema.d.mts +55 -0
  4. package/bin/lib/assistant-schema.mjs +286 -0
  5. package/bin/lib/check.mjs +173 -0
  6. package/bin/lib/copy.mjs +2 -0
  7. package/bin/lib/gen-manifest.mjs +3 -0
  8. package/bin/lib/gen-runner.mjs +29 -1
  9. package/bin/lib/mcp.mjs +1 -1
  10. package/docs/adr/0052-floating-layer-coexists-with-modal-surfaces.md +668 -0
  11. package/docs/adr/0053-read-action-declares-the-resource-assistant.md +443 -0
  12. package/docs/protocol.md +54 -0
  13. package/docs/releasing.md +8 -2
  14. package/docs/stacking-scale.md +53 -0
  15. package/package.json +10 -1
  16. package/registry/skills/create-opus-action/SKILL.md +8 -3
  17. package/registry/skills/create-opus-action/references/contract-and-binding.md +24 -1
  18. package/registry/skills/create-opus-action/references/evaluations.md +3 -0
  19. package/registry/skills/test-opus-action/SKILL.md +4 -1
  20. package/registry/skills/test-opus-action/references/evaluations.md +2 -0
  21. package/registry/skills/test-opus-action/references/harness.md +14 -0
  22. package/src/ai/index.ts +3 -0
  23. package/src/ai/resource-context.ts +113 -0
  24. package/src/core/assistant.ts +545 -0
  25. package/src/core/index.ts +23 -0
  26. package/src/core/runtime.ts +136 -0
  27. package/src/core/types.ts +34 -0
  28. package/src/mcp/index.ts +3 -0
  29. package/src/ui/components/primitives/dialog.tsx +285 -26
  30. package/src/ui/components/primitives/drawer.tsx +104 -13
  31. package/src/ui/components/primitives/floating-layer.tsx +79 -0
  32. package/src/ui/components/primitives/menu.tsx +17 -2
  33. package/src/ui/components/primitives/popover.tsx +6 -1
  34. package/src/ui/components/primitives/tooltip.tsx +6 -1
  35. package/src/ui/docs/content/actions.md +97 -1
  36. package/src/ui/docs/content/ai.md +4 -2
  37. package/src/ui/docs/content/cli.md +2 -1
  38. package/src/ui/docs/content/dialog.md +3 -0
  39. package/src/ui/docs/content/drawer.md +3 -0
  40. package/src/ui/docs/content/floating-layer.md +178 -0
  41. package/src/ui/docs/content/mcp.md +3 -1
  42. package/src/ui/docs/registry.tsx +6 -0
  43. package/src/ui/lib/dismiss-on-iframe.ts +4 -1
  44. package/src/ui/lib/modal-layers.ts +547 -0
  45. package/src/ui/meta.ts +6 -0
  46. package/src/ui/react.tsx +4 -0
@@ -25,6 +25,17 @@ import { createPurgeHandler, entityTableName, type PurgeHandler } from './retent
25
25
  import type { DataProductConfig } from './data-product.ts'
26
26
  import { AuditEmitter } from './audit.ts'
27
27
  import { countReturnedRecords, hasInvalidEffect, resolveActionEffect } from './action-nature.ts'
28
+ import {
29
+ assertAssistantDeclaration,
30
+ projectAssistantContext,
31
+ publicAssistant,
32
+ resolveAssistantTitle,
33
+ type AssistantOmission,
34
+ type ReadResourceOptions,
35
+ type ResourceContext,
36
+ type RegisteredAssistant,
37
+ type ResourceReadResult,
38
+ } from './assistant.ts'
28
39
  import { error, isActionError, normalizeError } from './errors.ts'
29
40
  import { normalizeTraceContext } from './trace.ts'
30
41
  import { isSystemActor, markSystemActor } from './system-actor.ts'
@@ -78,6 +89,7 @@ import type {
78
89
  BoundAi,
79
90
  AiTool,
80
91
  AIConfig,
92
+ ActionAssistant,
81
93
  User,
82
94
  TraceContext,
83
95
  } from './types.ts'
@@ -321,6 +333,8 @@ export class Runtime {
321
333
  private aiToolsCache: AiTool[] | null = null
322
334
 
323
335
  private readonly actions = new Map<string, ActionDef>()
336
+ /** A action dona de cada tipo de recurso com assistente (ADR 0053). */
337
+ private readonly assistantOwners = new Map<string, ActionDef & { assistant: ActionAssistant }>()
324
338
  private readonly reactions = new Map<string, ReactionDef<any>>()
325
339
  private readonly schedules = new Map<string, ScheduleDef>()
326
340
  private readonly dataProducts = new Map<string, DataProductConfig>()
@@ -532,11 +546,33 @@ export class Runtime {
532
546
  'Use simple ou form para escrever (ADR 0041).',
533
547
  })
534
548
  }
549
+ this.claimAssistant(action)
535
550
  this.actions.set(action.name, action)
536
551
  if (this.config.dev) this.warnIgnoredExecutionFields(action)
537
552
  }
538
553
  }
539
554
 
555
+ /**
556
+ * Valida a declaração do assistente e reserva o tipo de recurso para a action (ADR 0053). Duas
557
+ * actions donas do mesmo recurso são recusadas com `runtime.assistant_duplicate_resource`.
558
+ */
559
+ private claimAssistant(action: ActionDef): void {
560
+ assertAssistantDeclaration(action)
561
+ const assistant = (action as { assistant?: ActionAssistant }).assistant
562
+ if (assistant === undefined) return
563
+ const owner = this.assistantOwners.get(assistant.resource)
564
+ if (owner !== undefined) {
565
+ throw error({
566
+ code: 'runtime.assistant_duplicate_resource',
567
+ category: 'internal',
568
+ message:
569
+ `O recurso "${assistant.resource}" já tem a action dona "${owner.name}"; ` +
570
+ `"${action.name}" não pode declarar o mesmo resource (ADR 0053).`,
571
+ })
572
+ }
573
+ this.assistantOwners.set(assistant.resource, action as ActionDef & { assistant: ActionAssistant })
574
+ }
575
+
540
576
  /**
541
577
  * `emits` e `background` pertencem ao binding de qualquer kind pelo tipo, mas o runtime e o
542
578
  * manifest só os aplicam em `simple` e `form`. Restringir o tipo quebraria bindings
@@ -726,6 +762,103 @@ export class Runtime {
726
762
  return this.executeAction(action, input, ctxBase, 'request')
727
763
  }
728
764
 
765
+ /**
766
+ * Lê um recurso pela action dona do tipo (ADR 0053). Executa a action com `{ id }` pelo mesmo
767
+ * `execute` de qualquer chamada: autorização, loaders, auditoria, marca de dado pessoal e span são
768
+ * os da action. Em caso de sucesso, devolve o descritor e, com `{ context: true }`, a projeção
769
+ * limitada que o host pode entregar ao modelo. Em caso de falha, devolve o erro da action. Um tipo
770
+ * sem action dona devolve `runtime.assistant_resource_unknown`, sem executar nada.
771
+ */
772
+ async readResource(
773
+ resource: string,
774
+ id: string,
775
+ ctxBase: ContextBase,
776
+ options: ReadResourceOptions = {},
777
+ ): Promise<ResourceReadResult> {
778
+ const owner = this.assistantOwners.get(resource)
779
+ if (owner === undefined) {
780
+ return {
781
+ ok: false,
782
+ error: error({
783
+ code: 'runtime.assistant_resource_unknown',
784
+ category: 'validation',
785
+ // O tipo veio de fora: a mensagem é fixa, e o valor recebido fica em `meta`.
786
+ message: 'O tipo de recurso informado não tem action dona.',
787
+ meta: { resource },
788
+ }),
789
+ }
790
+ }
791
+ const result = await this.execute(owner.name, { id }, ctxBase)
792
+ if (!result.ok) return { ok: false, error: result.error, meta: result.meta }
793
+
794
+ const { assistant } = owner
795
+ let title: string | null
796
+ let omitted: AssistantOmission[]
797
+ let context: ResourceContext | undefined
798
+ try {
799
+ if (options.context === true) {
800
+ // A projeção já resolve o título; sem contexto pedido, basta o título, sem pagar o corte.
801
+ const projection = projectAssistantContext(assistant, result.data)
802
+ title = projection.title
803
+ omitted = projection.omitted
804
+ context = {
805
+ data: projection.data,
806
+ truncated: projection.truncated,
807
+ omitted: projection.omitted,
808
+ personalData: owner.personalData === true,
809
+ }
810
+ } else {
811
+ ;({ title, omitted } = resolveAssistantTitle(assistant, result.data))
812
+ }
813
+ } catch (thrown) {
814
+ // A action executou e foi auditada; o que falhou foi transformar a saída em dado ao modelo
815
+ // (por exemplo, `BigInt` ou referência circular, que o JSON não representa).
816
+ this.log.error('A saída da action dona não pôde ser projetada para o assistente.', {
817
+ code: 'runtime.assistant_projection_failed',
818
+ action: owner.name,
819
+ resource,
820
+ error: thrown instanceof Error ? thrown.message : String(thrown),
821
+ })
822
+ return {
823
+ ok: false,
824
+ error: error({
825
+ code: 'runtime.assistant_projection_failed',
826
+ category: 'internal',
827
+ message: 'Não foi possível preparar os dados deste recurso para o assistente.',
828
+ cause: thrown,
829
+ }),
830
+ meta: result.meta,
831
+ }
832
+ }
833
+ const missing = omitted.filter((entry) => entry.reason === 'missing').map((entry) => entry.path)
834
+ if (missing.length > 0) {
835
+ this.log.warn(
836
+ `A saída de "${owner.name}" não trouxe ${missing.map((path) => `\`${path}\``).join(', ')}, ` +
837
+ 'declarado no assistente, como texto ou valor esperado; o recurso foi lido sem esses campos.',
838
+ { code: 'runtime.assistant_path_missing', action: owner.name, resource, paths: missing },
839
+ )
840
+ }
841
+ return {
842
+ ok: true,
843
+ resource: { kind: resource, id, title, label: assistant.label, skill: assistant.skill },
844
+ ...(context !== undefined ? { context } : {}),
845
+ meta: result.meta,
846
+ }
847
+ }
848
+
849
+ /**
850
+ * As declarações de assistente registradas, com a action dona de cada uma (ADR 0053). Serve ao
851
+ * consumidor para montar rotas e telas a partir da declaração e para conferir as chaves de
852
+ * procedimento contra o registro que ele mantém.
853
+ */
854
+ assistants(): RegisteredAssistant[] {
855
+ return [...this.assistantOwners.values()].map((owner) => ({
856
+ action: owner.name,
857
+ ...publicAssistant(owner.assistant),
858
+ personalData: owner.personalData === true,
859
+ }))
860
+ }
861
+
729
862
  /**
730
863
  * Executa no worker um envelope criado por `execute()`. O consumer fornece
731
864
  * auth/can reidratados; identidade, provenance e trace vêm do envelope.
@@ -1026,6 +1159,9 @@ export class Runtime {
1026
1159
  metadata: {
1027
1160
  ...(effect !== undefined ? { effect } : {}),
1028
1161
  ...(action.personalData !== undefined ? { personalData: action.personalData } : {}),
1162
+ ...(action.kind === 'view' && action.assistant !== undefined
1163
+ ? { assistant: publicAssistant(action.assistant) }
1164
+ : {}),
1029
1165
  dataProducts,
1030
1166
  dataProductLabels: Object.fromEntries(
1031
1167
  dataProducts.map((id) => [id, this.dataProducts.get(id)?.label ?? id]),
package/src/core/types.ts CHANGED
@@ -21,6 +21,7 @@
21
21
  * 13. RuntimeConfig (§14)
22
22
  */
23
23
 
24
+ import type { AssistantDeclaration } from './assistant.ts'
24
25
  import type { SoftDeleteEntity } from './retention.ts'
25
26
 
26
27
  // =============================================================================
@@ -548,6 +549,30 @@ export interface AIConfig {
548
549
  requiresConfirmation?: boolean
549
550
  }
550
551
 
552
+ /**
553
+ * O assistente do recurso que uma action `view` lê (ADR 0053). A action que o declara é a action
554
+ * dona do tipo de recurso: é por ela que o recurso é lido e que o acesso a ele é conferido. A
555
+ * declaração é serializável e não carrega função, consulta nem regra de acesso.
556
+ */
557
+ export interface ActionAssistant {
558
+ /** O tipo de recurso de que a action é dona, em camelCase e único no runtime (`customer`). */
559
+ resource: string
560
+ /** A chave do procedimento que a IA segue ao tratar do recurso, em minúsculas com hífen. */
561
+ skill: string
562
+ /** O nome do assistente para pessoas (`Assistente do cliente`). */
563
+ label: string
564
+ /** O caminho, na saída, do campo que nomeia o recurso para pessoas (`name`). */
565
+ title: string
566
+ /**
567
+ * Os caminhos, na saída, dos campos que o host pode entregar ao modelo como dado do recurso. O
568
+ * ponto desce em objetos (`leads.totalCount`); um caminho que passa por uma lista se aplica a cada
569
+ * item.
570
+ */
571
+ context: readonly string[]
572
+ /** Teto, em caracteres, do JSON de `context`. Padrão de 4.000, mínimo de 1.000, máximo de 16.000. */
573
+ maxContextChars?: number
574
+ }
575
+
551
576
  export interface Example {
552
577
  name: string
553
578
  description?: string
@@ -943,6 +968,12 @@ export interface ViewAction<In, Out, ParsedIn = In> extends ActionBase<In, Out,
943
968
  /** Entidades relacionadas projetáveis. View simples não projeta nada — omita. */
944
969
  projection?: string[]
945
970
  expand?: Record<string, ExpandSpec>
971
+ /**
972
+ * Torna esta action a dona de um tipo de recurso e declara o assistente dele (ADR 0053). Exige
973
+ * `ai` habilitado, `personalData` declarado e uma entrada em que `id`, do tipo texto, é o único
974
+ * campo obrigatório. O runtime lê o recurso por `runtime.readResource`.
975
+ */
976
+ assistant?: ActionAssistant
946
977
  }
947
978
 
948
979
  /**
@@ -1341,6 +1372,9 @@ export interface AiTool {
1341
1372
  effect?: ActionEffect
1342
1373
  /** A action declara devolver dado pessoal; ausente quando não declarou. */
1343
1374
  personalData?: boolean
1375
+ /** O assistente do recurso de que a action é dona (ADR 0053), com o teto resolvido; ausente
1376
+ * quando não declara. */
1377
+ assistant?: AssistantDeclaration
1344
1378
  }
1345
1379
  }
1346
1380
 
package/src/mcp/index.ts CHANGED
@@ -58,6 +58,9 @@ export function createOpusMcpServer(runtime: Runtime, opts: OpusMcpOptions = {})
58
58
  ...(t.metadata?.personalData !== undefined
59
59
  ? { 'com.softize.opus/personal-data': t.metadata.personalData }
60
60
  : {}),
61
+ // O assistente do recurso de que a action é dona (ADR 0053): o host conhece o assistente
62
+ // pelo catálogo que já consulta.
63
+ ...(t.metadata?.assistant !== undefined ? { 'com.softize.opus/assistant': t.metadata.assistant } : {}),
61
64
  },
62
65
  })),
63
66
  }))
@@ -19,16 +19,34 @@ import {
19
19
  type StructuralMediaProps,
20
20
  } from "../patterns/structural-media.tsx";
21
21
  import { CloseButton, type CloseButtonSize } from "./close-button.tsx";
22
+ import {
23
+ ANSWER_BAND_CLASS,
24
+ LAYER_MODAL_ATTRIBUTE,
25
+ LayerPortalProvider,
26
+ exemptLayerFirst,
27
+ focusIsInLayer,
28
+ handleModalTabKey,
29
+ isLayerInteraction,
30
+ routeEscapeToLayer,
31
+ useLayerPortal,
32
+ useModalLayerEntry,
33
+ type ModalMark,
34
+ } from "../../lib/modal-layers.ts";
22
35
 
23
36
  export type DialogMode = "default" | "alert";
24
37
 
25
38
  interface DialogContextValue {
26
39
  mode: DialogMode;
27
40
  onResult?: (result: string) => void;
41
+ /** Estado de aberto, que a pilha de modais da camada flutuante acompanha (ADR 0052). */
42
+ open: boolean;
43
+ modal: boolean;
28
44
  }
29
45
 
30
46
  const DialogContext = React.createContext<DialogContextValue>({
31
47
  mode: "default",
48
+ open: false,
49
+ modal: true,
32
50
  });
33
51
 
34
52
  interface DialogContentContextValue {
@@ -67,6 +85,139 @@ function useDialogContext(): DialogContextValue {
67
85
  return React.useContext(DialogContext);
68
86
  }
69
87
 
88
+ /**
89
+ * Estado de aberto controlado ou não, como o do Radix, mas visível ao Opus: a pilha de modais da
90
+ * camada flutuante precisa saber quando o modal fecha, e não só quando desmonta (ADR 0052).
91
+ */
92
+ export function useModalOpenState({
93
+ open: openProp,
94
+ defaultOpen,
95
+ onOpenChange,
96
+ }: {
97
+ open?: boolean;
98
+ defaultOpen?: boolean;
99
+ onOpenChange?: (open: boolean) => void;
100
+ }): [boolean, (open: boolean) => void] {
101
+ const [uncontrolled, setUncontrolled] = React.useState(defaultOpen ?? false);
102
+ const controlled = openProp !== undefined;
103
+ const open = controlled ? openProp : uncontrolled;
104
+ const onOpenChangeRef = React.useRef(onOpenChange);
105
+ onOpenChangeRef.current = onOpenChange;
106
+ const setOpen = React.useCallback(
107
+ (next: boolean) => {
108
+ if (!controlled) setUncontrolled(next);
109
+ onOpenChangeRef.current?.(next);
110
+ },
111
+ [controlled],
112
+ );
113
+ return [open, setOpen];
114
+ }
115
+
116
+ interface OutsideEvent {
117
+ defaultPrevented: boolean;
118
+ preventDefault(): void;
119
+ detail: { originalEvent: Event };
120
+ }
121
+
122
+ /**
123
+ * Isenções da camada flutuante compostas antes dos handlers de dispensa do consumidor e do
124
+ * `closeDisabled`, para Dialog e Drawer (seção 8 da ADR 0052). Fora de um modal de trabalho,
125
+ * devolve só a composição de sempre.
126
+ */
127
+ export function composeModalDismissHandlers<
128
+ Escape extends KeyboardEvent,
129
+ Pointer extends OutsideEvent,
130
+ Interact extends OutsideEvent,
131
+ CloseFocus extends Event,
132
+ >({
133
+ mark,
134
+ closeDisabled,
135
+ onEscapeKeyDown,
136
+ onPointerDownOutside,
137
+ onInteractOutside,
138
+ onCloseAutoFocus,
139
+ }: {
140
+ mark: ModalMark;
141
+ closeDisabled: boolean;
142
+ onEscapeKeyDown: ((event: Escape) => void) | undefined;
143
+ onPointerDownOutside: ((event: Pointer) => void) | undefined;
144
+ onInteractOutside: ((event: Interact) => void) | undefined;
145
+ onCloseAutoFocus: ((event: CloseFocus) => void) | undefined;
146
+ }): {
147
+ onEscapeKeyDown: ((event: Escape) => void) | undefined;
148
+ onPointerDownOutside: ((event: Pointer) => void) | undefined;
149
+ onInteractOutside: ((event: Interact) => void) | undefined;
150
+ onCloseAutoFocus: (event: CloseFocus) => void;
151
+ } {
152
+ const escape = preventDismissWhen(closeDisabled, onEscapeKeyDown);
153
+ const interact = preventDismissWhen(closeDisabled, onInteractOutside);
154
+ // O modal que fecha com a pessoa na camada não leva o foco ao gatilho dele: o foco fica onde
155
+ // ela está digitando. O handler do consumidor roda antes e pode decidir outra coisa.
156
+ const closeAutoFocus = (event: CloseFocus): void => {
157
+ onCloseAutoFocus?.(event);
158
+ if (!event.defaultPrevented && focusIsInLayer()) event.preventDefault();
159
+ };
160
+ if (mark !== "work") {
161
+ return {
162
+ onEscapeKeyDown: escape,
163
+ onPointerDownOutside,
164
+ onInteractOutside: interact,
165
+ onCloseAutoFocus: closeAutoFocus,
166
+ };
167
+ }
168
+ return {
169
+ onEscapeKeyDown: exemptLayerFirst(routeEscapeToLayer, escape),
170
+ onPointerDownOutside: exemptLayerFirst(
171
+ isLayerInteraction,
172
+ onPointerDownOutside,
173
+ ),
174
+ onInteractOutside: exemptLayerFirst(isLayerInteraction, interact),
175
+ onCloseAutoFocus: closeAutoFocus,
176
+ };
177
+ }
178
+
179
+ type RefCleanup = () => void;
180
+
181
+ function setRef<T>(
182
+ ref: React.Ref<T> | undefined,
183
+ value: T | null,
184
+ ): RefCleanup | void {
185
+ if (typeof ref === "function") {
186
+ const cleanup = ref(value);
187
+ return typeof cleanup === "function" ? cleanup : undefined;
188
+ }
189
+ if (ref !== null && ref !== undefined) {
190
+ (ref as React.RefObject<T | null>).current = value;
191
+ }
192
+ }
193
+
194
+ /**
195
+ * Compõe a ref do consumidor com a do Opus sem perder nenhuma das duas, como o `composeRefs` do
196
+ * Radix: quando alguma callback ref devolve limpeza (React 19), a composição também devolve, e o
197
+ * React passa a chamá-la em vez de chamar as refs com `null`. As refs sem limpeza recebem `null`
198
+ * nesse momento.
199
+ */
200
+ export function useComposedRef<T>(
201
+ ...refs: Array<React.Ref<T> | undefined>
202
+ ): (node: T | null) => RefCleanup | undefined {
203
+ return React.useCallback(
204
+ (node: T | null) => {
205
+ const cleanups = refs.map((ref) => setRef(ref, node));
206
+ if (!cleanups.some((cleanup) => typeof cleanup === "function")) {
207
+ return undefined;
208
+ }
209
+ return () => {
210
+ cleanups.forEach((cleanup, index) => {
211
+ if (typeof cleanup === "function") cleanup();
212
+ else setRef(refs[index], null);
213
+ });
214
+ };
215
+ },
216
+ // eslint-disable-next-line react-hooks/exhaustive-deps
217
+ refs,
218
+ );
219
+ }
220
+
70
221
  export interface DialogProps extends React.ComponentProps<
71
222
  typeof DialogPrimitive.Root
72
223
  > {
@@ -80,16 +231,40 @@ export function Dialog({
80
231
  mode = "default",
81
232
  onResult,
82
233
  modal,
234
+ open: openProp,
235
+ defaultOpen,
236
+ onOpenChange,
83
237
  ...props
84
238
  }: DialogProps) {
85
- const context = React.useMemo(() => ({ mode, onResult }), [mode, onResult]);
239
+ const [open, setOpen] = useModalOpenState({
240
+ open: openProp,
241
+ defaultOpen,
242
+ onOpenChange,
243
+ });
244
+ // O `AlertDialog` do Radix força o modo modal e ignora `modal`.
245
+ const isModal = mode === "alert" || modal !== false;
246
+ const context = React.useMemo(
247
+ () => ({ mode, onResult, open, modal: isModal }),
248
+ [mode, onResult, open, isModal],
249
+ );
86
250
 
87
251
  return (
88
252
  <DialogContext.Provider value={context}>
89
253
  {mode === "alert" ? (
90
- <AlertDialogPrimitive.Root data-slot="dialog" {...props} />
254
+ <AlertDialogPrimitive.Root
255
+ data-slot="dialog"
256
+ open={open}
257
+ onOpenChange={setOpen}
258
+ {...props}
259
+ />
91
260
  ) : (
92
- <DialogPrimitive.Root data-slot="dialog" modal={modal} {...props} />
261
+ <DialogPrimitive.Root
262
+ data-slot="dialog"
263
+ modal={modal}
264
+ open={open}
265
+ onOpenChange={setOpen}
266
+ {...props}
267
+ />
93
268
  )}
94
269
  </DialogContext.Provider>
95
270
  );
@@ -107,13 +282,25 @@ export function DialogTrigger({
107
282
  }
108
283
 
109
284
  export function DialogPortal({
285
+ container,
110
286
  ...props
111
287
  }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
112
288
  const { mode } = useDialogContext();
289
+ // Aberto de dentro de uma camada flutuante, o modal vai para o hospedeiro dela (ADR 0052).
290
+ const layer = useLayerPortal();
291
+ const target = container ?? layer.container;
113
292
  return mode === "alert" ? (
114
- <AlertDialogPrimitive.Portal data-slot="dialog-portal" {...props} />
293
+ <AlertDialogPrimitive.Portal
294
+ data-slot="dialog-portal"
295
+ container={target}
296
+ {...props}
297
+ />
115
298
  ) : (
116
- <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
299
+ <DialogPrimitive.Portal
300
+ data-slot="dialog-portal"
301
+ container={target}
302
+ {...props}
303
+ />
117
304
  );
118
305
  }
119
306
 
@@ -163,6 +350,13 @@ export function DialogClose({
163
350
  );
164
351
  }
165
352
 
353
+ /**
354
+ * Alerta em animação de saída deixa de receber clique: o Dialog que a resposta abre em seguida fica
355
+ * na faixa de baixo e seria coberto pelo véu do alerta até a animação terminar. O `!` vence o
356
+ * `pointer-events` que o Radix grava no estilo do véu e do conteúdo.
357
+ */
358
+ const answerClosingClasses = "data-[state=closed]:pointer-events-none!";
359
+
166
360
  /** Véu compartilhado por Dialog e Drawer: mesmo escurecimento e animação. */
167
361
  export const overlayClasses =
168
362
  "fixed inset-0 z-50 bg-black/30 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0";
@@ -172,17 +366,25 @@ export function DialogOverlay({
172
366
  ...props
173
367
  }: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
174
368
  const { mode } = useDialogContext();
175
- const overlay = cn(overlayClasses, className);
369
+ const layer = useLayerPortal();
370
+ const internal = layer.container !== undefined || undefined;
176
371
  return mode === "alert" ? (
177
372
  <AlertDialogPrimitive.Overlay
178
373
  data-slot="dialog-overlay"
179
- className={overlay}
374
+ {...{ [LAYER_MODAL_ATTRIBUTE]: internal }}
375
+ className={cn(
376
+ overlayClasses,
377
+ ANSWER_BAND_CLASS,
378
+ answerClosingClasses,
379
+ className,
380
+ )}
180
381
  {...props}
181
382
  />
182
383
  ) : (
183
384
  <DialogPrimitive.Overlay
184
385
  data-slot="dialog-overlay"
185
- className={overlay}
386
+ {...{ [LAYER_MODAL_ATTRIBUTE]: internal }}
387
+ className={cn(overlayClasses, layer.zIndexClass, className)}
186
388
  {...props}
187
389
  />
188
390
  );
@@ -211,7 +413,11 @@ export function DialogContent({
211
413
  layout = "sectioned",
212
414
  "aria-describedby": ariaDescribedBy,
213
415
  onEscapeKeyDown,
416
+ onPointerDownOutside,
214
417
  onInteractOutside,
418
+ onCloseAutoFocus,
419
+ onKeyDown,
420
+ ref,
215
421
  ...props
216
422
  }: React.ComponentProps<typeof DialogPrimitive.Content> & {
217
423
  /** Mostra a ação “Fechar” no modo padrão. O modo `alert` exige uma resposta identificável. */
@@ -226,7 +432,32 @@ export function DialogContent({
226
432
  /** Anatomia da superfície: `sectioned` (default) ou `continuous` — ADR 0023. */
227
433
  layout?: SurfaceLayout;
228
434
  }) {
229
- const { mode } = useDialogContext();
435
+ const { mode, open, modal } = useDialogContext();
436
+ const layer = useLayerPortal();
437
+ const internal = layer.container !== undefined;
438
+ // Seção 6 da ADR 0052: o alerta é resposta obrigatória; o modal portado para uma camada é
439
+ // interno a ela; os demais são de trabalho e convivem com a camada.
440
+ const mark: ModalMark =
441
+ mode === "alert" ? "answer" : internal ? "internal" : "work";
442
+ const entryRef = useModalLayerEntry({ open, modal, mark, surface: "dialog" });
443
+ const composedRef = useComposedRef(ref, entryRef);
444
+ const dismiss = composeModalDismissHandlers({
445
+ mark,
446
+ closeDisabled,
447
+ onEscapeKeyDown,
448
+ onPointerDownOutside,
449
+ onInteractOutside,
450
+ onCloseAutoFocus,
451
+ });
452
+ const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>): void => {
453
+ onKeyDown?.(event);
454
+ handleModalTabKey(event);
455
+ };
456
+ // Os portais abertos daqui herdam a marca de modal quando este Dialog mora numa camada.
457
+ const nestedPortal = React.useMemo(
458
+ () => ({ ...layer, modalSubtree: layer.modalSubtree || internal }),
459
+ [layer, internal],
460
+ );
230
461
  const showClose = showCloseButton ?? mode !== "alert";
231
462
  const contentClasses =
232
463
  "fixed left-[50%] top-[50%] z-50 w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] overflow-clip rounded-xl border border-transparent bg-background text-foreground shadow-xl duration-200 outline-none [--surface-background:var(--background)] data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 dark:border-border/70";
@@ -236,25 +467,45 @@ export function DialogContent({
236
467
  <DialogPortal>
237
468
  <DialogOverlay />
238
469
  <AlertDialogPrimitive.Content
470
+ ref={composedRef}
239
471
  data-slot="dialog-content"
240
472
  data-mode="alert"
241
- className={cn(contentClasses, "grid max-w-xs gap-4 p-6", className)}
473
+ {...{ [LAYER_MODAL_ATTRIBUTE]: internal || undefined }}
474
+ className={cn(
475
+ contentClasses,
476
+ ANSWER_BAND_CLASS,
477
+ answerClosingClasses,
478
+ "grid max-w-xs gap-4 p-6",
479
+ className,
480
+ )}
242
481
  aria-describedby={ariaDescribedBy}
243
- onEscapeKeyDown={preventDismissWhen(closeDisabled, onEscapeKeyDown)}
482
+ // O `AlertDialog` do Radix cancela sozinho o clique fora; só o Esc é composto.
483
+ onEscapeKeyDown={dismiss.onEscapeKeyDown}
484
+ onCloseAutoFocus={dismiss.onCloseAutoFocus}
485
+ onKeyDown={onKeyDown}
244
486
  {...props}
245
487
  >
246
488
  {/* Sem anatomia seccionada (exceção da ADR 0023), mas a altura continua sendo
247
489
  decisão do diálogo: a cadeia `fill` da página não atravessa o portal. */}
248
490
  <FillBoundary>
249
- <DialogContentContext.Provider
491
+ {/* Popover, Menu e Tooltip abertos daqui sobem para a faixa do alerta. */}
492
+ <LayerPortalProvider
250
493
  value={{
251
- showCloseButton: false,
252
- closeDisabled: true,
253
- closeSize,
494
+ container: layer.container,
495
+ zIndexClass: ANSWER_BAND_CLASS,
496
+ modalSubtree: internal,
254
497
  }}
255
498
  >
256
- {children}
257
- </DialogContentContext.Provider>
499
+ <DialogContentContext.Provider
500
+ value={{
501
+ showCloseButton: false,
502
+ closeDisabled: true,
503
+ closeSize,
504
+ }}
505
+ >
506
+ {children}
507
+ </DialogContentContext.Provider>
508
+ </LayerPortalProvider>
258
509
  </FillBoundary>
259
510
  </AlertDialogPrimitive.Content>
260
511
  </DialogPortal>
@@ -265,26 +516,34 @@ export function DialogContent({
265
516
  <DialogPortal>
266
517
  <DialogOverlay />
267
518
  <DialogPrimitive.Content
519
+ ref={composedRef}
268
520
  data-slot="dialog-content"
269
521
  data-mode="default"
270
522
  data-layout={layout}
523
+ {...{ [LAYER_MODAL_ATTRIBUTE]: internal || undefined }}
271
524
  className={cn(
272
525
  contentClasses,
273
526
  "flex max-h-[85vh] max-w-lg flex-col",
527
+ layer.zIndexClass,
274
528
  className,
275
529
  )}
276
530
  aria-describedby={ariaDescribedBy}
277
- onEscapeKeyDown={preventDismissWhen(closeDisabled, onEscapeKeyDown)}
278
- onInteractOutside={preventDismissWhen(closeDisabled, onInteractOutside)}
531
+ onEscapeKeyDown={dismiss.onEscapeKeyDown}
532
+ onPointerDownOutside={dismiss.onPointerDownOutside}
533
+ onInteractOutside={dismiss.onInteractOutside}
534
+ onCloseAutoFocus={dismiss.onCloseAutoFocus}
535
+ onKeyDown={handleKeyDown}
279
536
  {...props}
280
537
  >
281
- <SurfaceLayoutProvider value={layout}>
282
- <DialogContentContext.Provider
283
- value={{ showCloseButton: showClose, closeDisabled, closeSize }}
284
- >
285
- {children}
286
- </DialogContentContext.Provider>
287
- </SurfaceLayoutProvider>
538
+ <LayerPortalProvider value={nestedPortal}>
539
+ <SurfaceLayoutProvider value={layout}>
540
+ <DialogContentContext.Provider
541
+ value={{ showCloseButton: showClose, closeDisabled, closeSize }}
542
+ >
543
+ {children}
544
+ </DialogContentContext.Provider>
545
+ </SurfaceLayoutProvider>
546
+ </LayerPortalProvider>
288
547
  </DialogPrimitive.Content>
289
548
  </DialogPortal>
290
549
  );