@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
@@ -0,0 +1,547 @@
1
+ import * as React from "react";
2
+
3
+ /**
4
+ * Registro das camadas flutuantes e pilha dos modais do Opus (ADR 0052).
5
+ *
6
+ * Uma camada flutuante, como o FAB e o painel de um assistente, continua utilizável enquanto um
7
+ * Dialog ou Drawer modal está aberto. O modo modal do Radix continua valendo; este módulo abre
8
+ * exceções nas bordas dele, e só enquanto o topo da pilha é um modal **de trabalho**:
9
+ *
10
+ * - o aperto do ponteiro que nasce num hospedeiro fica registrado, e o Dialog e o Drawer não fecham
11
+ * por ele, mesmo que o elemento apertado suma antes de o clique terminar;
12
+ * - um único ouvinte no `body`, em borbulha, impede que `focusin`, `focusout`, `wheel` e `touchmove`
13
+ * ligados ao hospedeiro cheguem ao documento, onde a trava de foco do Radix e a trava de rolagem
14
+ * os veriam;
15
+ * - o Esc age onde a pessoa está: na camada, chama o `onEscapeKeyDown` dela e não fecha o modal;
16
+ * - o Tab passa do último elemento do modal para a camada e da camada de volta ao modal;
17
+ * - quando o elemento focado da camada some, o foco volta ao modal;
18
+ * - o hospedeiro carrega `aria-live="off"`, que faz o `hideOthers` do Radix preservá-lo.
19
+ *
20
+ * Com um modal de resposta obrigatória (`mode="alert"`, `DialogHost`) ou um modal aberto de dentro
21
+ * da camada no topo, todas as exceções ficam suspensas e esse modal age sobre a camada como sobre o
22
+ * resto da página.
23
+ *
24
+ * O registro é um valor global do módulo, como o contexto do `DismissableLayer` do Radix: funciona
25
+ * com o `DialogHost`, fora do `OpusProvider` e nos exemplos da documentação, sem provider.
26
+ */
27
+
28
+ /** Papel de um modal na pilha (seção 6 da ADR 0052). */
29
+ export type ModalMark = "work" | "answer" | "internal";
30
+ export type ModalSurface = "dialog" | "drawer";
31
+ export type ModalSide = "top" | "right" | "bottom" | "left";
32
+
33
+ interface ModalEntry {
34
+ mark: ModalMark;
35
+ surface: ModalSurface;
36
+ side: ModalSide | undefined;
37
+ content: HTMLElement | null;
38
+ }
39
+
40
+ interface LayerRecord {
41
+ host: HTMLElement;
42
+ escape: (event: KeyboardEvent) => void;
43
+ dispose: () => void;
44
+ }
45
+
46
+ /** Marca o conteúdo e o véu de um modal portado para um hospedeiro: nunca contam como camada. */
47
+ export const LAYER_MODAL_ATTRIBUTE = "data-floating-layer-modal";
48
+
49
+ const layers: LayerRecord[] = [];
50
+ const stack: ModalEntry[] = [];
51
+ const layerPointerEvents = new WeakSet<Event>();
52
+ /** Camada do último aperto do ponteiro; decide onde a pessoa está quando o foco está no `body`. */
53
+ let pointerLayer: LayerRecord | null = null;
54
+ /**
55
+ * Filho direto do hospedeiro que contém o alvo desse aperto, como o painel inteiro. Quando ele sai
56
+ * do hospedeiro, o aperto deixa de dizer onde a pessoa está. O alvo em si não serve de âncora: o
57
+ * texto de uma resposta em streaming é trocado a cada pedaço, e a pessoa continua no painel.
58
+ */
59
+ let pointerAnchor: Element | null = null;
60
+ /** Último elemento focado dentro de uma camada, enquanto a pessoa não sai dela. */
61
+ let layerFocus: Element | null = null;
62
+ /** Último elemento focado fora das camadas: o destino da volta do foco (decisão 3 da seção 9). */
63
+ let outsideFocus: Element | null = null;
64
+ let restoreTimer: ReturnType<typeof setTimeout> | undefined;
65
+
66
+ function topEntry(): ModalEntry | undefined {
67
+ return stack[stack.length - 1];
68
+ }
69
+
70
+ function topWorkEntry(): ModalEntry | undefined {
71
+ for (let index = stack.length - 1; index >= 0; index -= 1) {
72
+ if (stack[index]!.mark === "work") return stack[index];
73
+ }
74
+ return undefined;
75
+ }
76
+
77
+ /**
78
+ * Há na pilha um modal que suspende as exceções: de resposta obrigatória ou interno à camada.
79
+ * Basta um, em qualquer posição: um Dialog aberto de dentro de um alerta fica no topo como modal
80
+ * de trabalho, mas o alerta continua exigindo resposta e cobrindo a camada (decisão 4 da seção 9).
81
+ */
82
+ function suspendingModalOpen(): boolean {
83
+ return stack.some((entry) => entry.mark !== "work");
84
+ }
85
+
86
+ /** As exceções da ADR só valem com uma camada montada, um modal de trabalho no topo e nenhum que as suspenda. */
87
+ export function layerExemptionsActive(): boolean {
88
+ return (
89
+ layers.length > 0 && topEntry()?.mark === "work" && !suspendingModalOpen()
90
+ );
91
+ }
92
+
93
+ function layerOf(node: unknown): LayerRecord | null {
94
+ if (typeof Node === "undefined" || !(node instanceof Node)) return null;
95
+ for (const layer of layers) {
96
+ if (!layer.host.contains(node)) continue;
97
+ const element = node instanceof Element ? node : node.parentElement;
98
+ // A subárvore de um modal aberto de dentro da camada é modal, não camada (seção 6).
99
+ if (element?.closest(`[${LAYER_MODAL_ATTRIBUTE}]`)) return null;
100
+ return layer;
101
+ }
102
+ return null;
103
+ }
104
+
105
+ function bodyHasFocus(): boolean {
106
+ const active = document.activeElement;
107
+ return (
108
+ active === null ||
109
+ active === document.body ||
110
+ active === document.documentElement
111
+ );
112
+ }
113
+
114
+ /**
115
+ * Onde a pessoa está: o foco decide primeiro; com o foco no `body`, decide o último aperto do
116
+ * ponteiro (seção 5).
117
+ */
118
+ function layerWherePersonIs(): LayerRecord | null {
119
+ if (!bodyHasFocus()) return layerOf(document.activeElement);
120
+ return pointerLayer !== null && layers.includes(pointerLayer)
121
+ ? pointerLayer
122
+ : null;
123
+ }
124
+
125
+ function syncHosts(): void {
126
+ const suspended = suspendingModalOpen();
127
+ const work = topWorkEntry();
128
+ for (const { host } of layers) {
129
+ // O `hideOthers` preserva todo `[aria-live]` e não desce nele. Retirar o atributo antes do
130
+ // `hideOthers` de um modal que suspende as exceções deixa esse modal esconder a camada.
131
+ if (suspended) host.removeAttribute("aria-live");
132
+ else host.setAttribute("aria-live", "off");
133
+ host.setAttribute("data-modal", work?.surface ?? "none");
134
+ if (work?.surface === "drawer" && work.side !== undefined) {
135
+ host.setAttribute("data-modal-side", work.side);
136
+ } else {
137
+ host.removeAttribute("data-modal-side");
138
+ }
139
+ }
140
+ }
141
+
142
+ /** Elementos alcançáveis pelo Tab dentro de `root`, na ordem do documento. */
143
+ function tabbables(root: HTMLElement): HTMLElement[] {
144
+ const nodes: HTMLElement[] = [];
145
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT, {
146
+ acceptNode(node) {
147
+ const element = node as HTMLElement;
148
+ if (element.hasAttribute(LAYER_MODAL_ATTRIBUTE) && element !== root) {
149
+ return NodeFilter.FILTER_REJECT;
150
+ }
151
+ const hiddenInput =
152
+ element.tagName === "INPUT" &&
153
+ (element as HTMLInputElement).type === "hidden";
154
+ if (
155
+ (element as HTMLButtonElement).disabled ||
156
+ element.hidden ||
157
+ hiddenInput
158
+ ) {
159
+ return NodeFilter.FILTER_SKIP;
160
+ }
161
+ return element.tabIndex >= 0
162
+ ? NodeFilter.FILTER_ACCEPT
163
+ : NodeFilter.FILTER_SKIP;
164
+ },
165
+ });
166
+ while (walker.nextNode()) nodes.push(walker.currentNode as HTMLElement);
167
+ return nodes.filter(
168
+ (element) =>
169
+ typeof element.checkVisibility !== "function" ||
170
+ element.checkVisibility({ checkVisibilityCSS: true }),
171
+ );
172
+ }
173
+
174
+ function focusFromKeyboard(element: HTMLElement): void {
175
+ element.focus();
176
+ // O Tab do navegador seleciona o texto do campo que recebe o foco; a passagem manual imita.
177
+ if (element instanceof HTMLInputElement && typeof element.select === "function") {
178
+ element.select();
179
+ }
180
+ }
181
+
182
+ /**
183
+ * Regiões do ciclo do Tab (decisão 2 da seção 9): o modal de trabalho do topo e as camadas, na
184
+ * ordem de montagem. Devolve o destino a partir da região `from`, ou `null` quando nenhuma outra
185
+ * região tem onde parar.
186
+ */
187
+ function tabTarget(from: number, forward: boolean): HTMLElement | null {
188
+ const content = topEntry()?.content;
189
+ if (content === null || content === undefined) return null;
190
+ const regions = [content, ...layers.map((layer) => layer.host)];
191
+ for (let step = 1; step < regions.length + 1; step += 1) {
192
+ const index =
193
+ (from + (forward ? step : -step) + regions.length * 2) % regions.length;
194
+ if (index === from) return null;
195
+ const candidates = tabbables(regions[index]!);
196
+ if (candidates.length === 0) continue;
197
+ return forward ? candidates[0]! : candidates[candidates.length - 1]!;
198
+ }
199
+ return null;
200
+ }
201
+
202
+ function isTabKey(event: KeyboardEvent | React.KeyboardEvent): boolean {
203
+ return (
204
+ event.key === "Tab" && !event.altKey && !event.ctrlKey && !event.metaKey
205
+ );
206
+ }
207
+
208
+ /**
209
+ * Tab no modal de trabalho do topo: do último elemento vai para a camada, e o Shift+Tab do
210
+ * primeiro vai para o último elemento da camada. Roda antes do laço do Radix, que não age quando
211
+ * o foco já saiu do elemento da borda.
212
+ */
213
+ export function handleModalTabKey(
214
+ event: React.KeyboardEvent<HTMLElement>,
215
+ ): void {
216
+ if (!isTabKey(event) || event.defaultPrevented || !layerExemptionsActive()) {
217
+ return;
218
+ }
219
+ const content = event.currentTarget;
220
+ if (topEntry()?.content !== content) return;
221
+ const candidates = tabbables(content);
222
+ const active = document.activeElement;
223
+ const atEdge =
224
+ candidates.length === 0
225
+ ? active === content
226
+ : event.shiftKey
227
+ ? active === candidates[0]
228
+ : active === candidates[candidates.length - 1];
229
+ if (!atEdge) return;
230
+ const target = tabTarget(0, !event.shiftKey);
231
+ if (target === null) return;
232
+ event.preventDefault();
233
+ focusFromKeyboard(target);
234
+ }
235
+
236
+ /** Para onde o foco volta quando sai da camada (decisão 3 da seção 9). */
237
+ function modalFocusTarget(): HTMLElement | null {
238
+ const content = topEntry()?.content;
239
+ if (content === null || content === undefined) return null;
240
+ return outsideFocus instanceof HTMLElement &&
241
+ outsideFocus.isConnected &&
242
+ content.contains(outsideFocus)
243
+ ? outsideFocus
244
+ : (tabbables(content)[0] ?? content);
245
+ }
246
+
247
+ function restoreFocusToModal(): void {
248
+ modalFocusTarget()?.focus({ preventScroll: true });
249
+ }
250
+
251
+ /**
252
+ * O foco está numa camada, fora de qualquer modal aberto de dentro dela. Um modal que fecha nesse
253
+ * momento não deve levar o foco ao gatilho dele: a pessoa está digitando na camada.
254
+ */
255
+ export function focusIsInLayer(): boolean {
256
+ return layerOf(document.activeElement) !== null;
257
+ }
258
+
259
+ /** O ouvinte único no `body` (seção 8): a trava do Radix não vê a passagem pela camada. */
260
+ function shieldDocumentListeners(event: Event): void {
261
+ if (!layerExemptionsActive()) return;
262
+ if (event.type === "focusout") {
263
+ const { relatedTarget } = event as FocusEvent;
264
+ if (layerOf(event.target) !== null || layerOf(relatedTarget) !== null) {
265
+ event.stopPropagation();
266
+ }
267
+ return;
268
+ }
269
+ if (layerOf(event.target) !== null) event.stopPropagation();
270
+ }
271
+
272
+ /** O filho direto de `host` que contém `node`. */
273
+ function hostChildOf(host: HTMLElement, node: Node): Element | null {
274
+ let current: Node | null = node;
275
+ while (current !== null && current.parentNode !== host) {
276
+ current = current.parentNode;
277
+ }
278
+ return current instanceof Element ? current : null;
279
+ }
280
+
281
+ function trackPointer(event: PointerEvent): void {
282
+ pointerLayer = layerOf(event.target);
283
+ pointerAnchor =
284
+ pointerLayer === null
285
+ ? null
286
+ : hostChildOf(pointerLayer.host, event.target as Node);
287
+ if (pointerLayer === null) layerFocus = null;
288
+ }
289
+
290
+ function trackFocus(event: FocusEvent): void {
291
+ if (layerOf(event.target) !== null) {
292
+ layerFocus = event.target as Element;
293
+ return;
294
+ }
295
+ layerFocus = null;
296
+ outsideFocus = event.target as Element;
297
+ }
298
+
299
+ const SHIELDED_EVENTS = ["focusin", "focusout", "wheel", "touchmove"] as const;
300
+
301
+ function installGlobalListeners(): void {
302
+ for (const type of SHIELDED_EVENTS) {
303
+ document.body.addEventListener(type, shieldDocumentListeners, {
304
+ passive: true,
305
+ });
306
+ }
307
+ document.addEventListener("pointerdown", trackPointer, true);
308
+ document.addEventListener("focusin", trackFocus, true);
309
+ }
310
+
311
+ function removeGlobalListeners(): void {
312
+ for (const type of SHIELDED_EVENTS) {
313
+ document.body.removeEventListener(type, shieldDocumentListeners);
314
+ }
315
+ document.removeEventListener("pointerdown", trackPointer, true);
316
+ document.removeEventListener("focusin", trackFocus, true);
317
+ pointerLayer = null;
318
+ pointerAnchor = null;
319
+ layerFocus = null;
320
+ outsideFocus = null;
321
+ clearTimeout(restoreTimer);
322
+ }
323
+
324
+ /**
325
+ * Registra um hospedeiro de camada. O registro mantém os ouvintes enquanto houver ao menos uma
326
+ * camada montada; sem camada, os modais se comportam exatamente como antes. A função devolvida
327
+ * desfaz o registro e retira o hospedeiro do DOM.
328
+ */
329
+ export function registerFloatingLayer(
330
+ host: HTMLElement,
331
+ escape: (event: KeyboardEvent) => void,
332
+ ): () => void {
333
+ const onPointerDown = (event: PointerEvent): void => {
334
+ // Pertença decidida no aperto: no fim do clique, o elemento apertado pode já ter sumido.
335
+ if (layerOf(event.target) !== null) layerPointerEvents.add(event);
336
+ };
337
+ // No documento, em borbulha: roda depois dos `onKeyDown` do React de dentro da camada, que
338
+ // podem tratar o Tab e marcá-lo com `preventDefault`.
339
+ const onKeyDown = (event: KeyboardEvent): void => {
340
+ if (!isTabKey(event) || event.defaultPrevented) return;
341
+ if (!layerExemptionsActive() || layerOf(document.activeElement) !== record) {
342
+ return;
343
+ }
344
+ const candidates = tabbables(host);
345
+ const edge = event.shiftKey
346
+ ? candidates[0]
347
+ : candidates[candidates.length - 1];
348
+ if (document.activeElement !== edge) return;
349
+ const target = tabTarget(layers.indexOf(record) + 1, !event.shiftKey);
350
+ if (target === null) return;
351
+ event.preventDefault();
352
+ focusFromKeyboard(target);
353
+ };
354
+ const onDocumentKeyDown = (event: KeyboardEvent): void => {
355
+ // Com um modal de trabalho aberto, a composição do modal já tratou o Esc e o marcou.
356
+ if (event.key !== "Escape" || event.defaultPrevented) return;
357
+ if (layerWherePersonIs() === record) escape(event);
358
+ };
359
+ const observer = new MutationObserver(() => {
360
+ if (pointerAnchor !== null && pointerAnchor.parentNode !== pointerLayer?.host) {
361
+ // O painel apertado saiu da camada, como quando é fechado por código e vira o FAB.
362
+ pointerAnchor = null;
363
+ pointerLayer = null;
364
+ }
365
+ // Decisão 3 da seção 9: o elemento focado da camada sumiu e o foco caiu no `body`.
366
+ if (!layerExemptionsActive() || layerFocus === null) return;
367
+ if (layerFocus.isConnected || !bodyHasFocus()) return;
368
+ layerFocus = null;
369
+ clearTimeout(restoreTimer);
370
+ // Uma tarefa depois: quem trocou o conteúdo da camada pode levar o foco a outro elemento dela.
371
+ restoreTimer = setTimeout(() => {
372
+ if (layerExemptionsActive() && bodyHasFocus()) restoreFocusToModal();
373
+ }, 0);
374
+ });
375
+
376
+ const record: LayerRecord = {
377
+ host,
378
+ escape,
379
+ dispose: () => {
380
+ host.removeEventListener("pointerdown", onPointerDown, true);
381
+ document.removeEventListener("keydown", onKeyDown);
382
+ document.removeEventListener("keydown", onDocumentKeyDown);
383
+ observer.disconnect();
384
+ },
385
+ };
386
+
387
+ if (layers.length === 0) installGlobalListeners();
388
+ layers.push(record);
389
+ host.addEventListener("pointerdown", onPointerDown, true);
390
+ document.addEventListener("keydown", onKeyDown);
391
+ document.addEventListener("keydown", onDocumentKeyDown);
392
+ observer.observe(host, { childList: true, subtree: true });
393
+ syncHosts();
394
+
395
+ return () => {
396
+ // A camada sai com o foco dentro dela e um modal aberto: sem isto, o foco cairia no `body`,
397
+ // porque o observador desligado abaixo não chega a ver a remoção.
398
+ const focusTarget =
399
+ layerExemptionsActive() && host.contains(document.activeElement)
400
+ ? modalFocusTarget()
401
+ : null;
402
+ record.dispose();
403
+ const index = layers.indexOf(record);
404
+ if (index !== -1) layers.splice(index, 1);
405
+ if (pointerLayer === record) {
406
+ pointerLayer = null;
407
+ pointerAnchor = null;
408
+ }
409
+ host.remove();
410
+ focusTarget?.focus({ preventScroll: true });
411
+ if (layers.length === 0) removeGlobalListeners();
412
+ else syncHosts();
413
+ };
414
+ }
415
+
416
+ /** Evento de “fora” (clique ou foco) que nasceu numa camada enquanto as exceções valem. */
417
+ export function isLayerInteraction(event: {
418
+ detail: { originalEvent: Event };
419
+ }): boolean {
420
+ if (!layerExemptionsActive()) return false;
421
+ const original = event.detail.originalEvent;
422
+ if (layerPointerEvents.has(original)) return true;
423
+ return original.type === "focusin" && layerOf(original.target) !== null;
424
+ }
425
+
426
+ /**
427
+ * Esc no modal de trabalho do topo com a pessoa na camada: nenhum modal fecha e a camada recebe a
428
+ * tecla uma única vez. O `preventDefault` impede o Radix de fechar e marca o evento para o ouvinte
429
+ * da própria camada, que ignora eventos já tratados.
430
+ */
431
+ export function routeEscapeToLayer(event: KeyboardEvent): boolean {
432
+ if (!layerExemptionsActive()) return false;
433
+ const layer = layerWherePersonIs();
434
+ if (layer === null) return false;
435
+ event.preventDefault();
436
+ layer.escape(event);
437
+ return true;
438
+ }
439
+
440
+ /**
441
+ * Compõe uma isenção da camada antes de um handler de dispensa do modal. A isenção vem primeiro e
442
+ * interrompe a cadeia, como o `DismissableLayer.Branch` do Radix: um evento nascido na camada é de
443
+ * dentro do modal e não chega ao handler do consumidor.
444
+ */
445
+ export function exemptLayerFirst<E extends { preventDefault(): void }>(
446
+ exempt: (event: E) => boolean,
447
+ handler: ((event: E) => void) | undefined,
448
+ ): (event: E) => void {
449
+ return (event) => {
450
+ if (exempt(event)) {
451
+ event.preventDefault();
452
+ return;
453
+ }
454
+ handler?.(event);
455
+ };
456
+ }
457
+
458
+ /**
459
+ * Registra um modal na pilha enquanto ele está aberto. O registro e a retirada acontecem em
460
+ * efeito de layout: antes do `hideOthers` do modal que abre (efeito comum, num commit posterior,
461
+ * porque o portal do Radix monta o conteúdo depois) e quando o modal **fecha**, não quando
462
+ * desmonta, já que o `Presence` o mantém montado durante a animação de saída.
463
+ */
464
+ export function useModalLayerEntry({
465
+ open,
466
+ modal,
467
+ mark,
468
+ surface,
469
+ side,
470
+ }: {
471
+ open: boolean;
472
+ modal: boolean;
473
+ mark: ModalMark;
474
+ surface: ModalSurface;
475
+ side?: ModalSide;
476
+ }): (node: HTMLElement | null) => void {
477
+ const entry = React.useRef<ModalEntry | null>(null);
478
+ entry.current ??= { mark, surface, side, content: null };
479
+ const current = entry.current;
480
+
481
+ React.useLayoutEffect(() => {
482
+ if (
483
+ current.mark === mark &&
484
+ current.surface === surface &&
485
+ current.side === side
486
+ ) {
487
+ return;
488
+ }
489
+ current.mark = mark;
490
+ current.surface = surface;
491
+ current.side = side;
492
+ if (stack.includes(current)) syncHosts();
493
+ }, [current, mark, surface, side]);
494
+
495
+ React.useLayoutEffect(() => {
496
+ if (!open || !modal) return;
497
+ stack.push(current);
498
+ syncHosts();
499
+ return () => {
500
+ const index = stack.indexOf(current);
501
+ if (index !== -1) stack.splice(index, 1);
502
+ syncHosts();
503
+ };
504
+ }, [current, open, modal]);
505
+
506
+ return React.useCallback(
507
+ (node: HTMLElement | null) => {
508
+ current.content = node;
509
+ },
510
+ [current],
511
+ );
512
+ }
513
+
514
+ interface LayerPortal {
515
+ /** Onde os primitives com portal montam o conteúdo; `undefined` é o `body`. */
516
+ container: HTMLElement | undefined;
517
+ /** Faixa de empilhamento herdada pelos portais abertos de dentro de um modal de resposta. */
518
+ zIndexClass: string | undefined;
519
+ /**
520
+ * Os portais saem de dentro de um modal portado para uma camada. Eles vão para o hospedeiro como
521
+ * irmãos desse modal e precisam da mesma marca para nunca contar como camada (seção 6).
522
+ */
523
+ modalSubtree: boolean;
524
+ }
525
+
526
+ const LayerPortalContext = React.createContext<LayerPortal>({
527
+ container: undefined,
528
+ zIndexClass: undefined,
529
+ modalSubtree: false,
530
+ });
531
+
532
+ /** Atributo que marca o conteúdo de um portal aberto de dentro de um modal interno à camada. */
533
+ export function layerModalMarker(
534
+ portal: LayerPortal,
535
+ ): Record<string, true | undefined> {
536
+ return { [LAYER_MODAL_ATTRIBUTE]: portal.modalSubtree || undefined };
537
+ }
538
+
539
+ /** Contêiner e faixa dos portais abertos daqui (seção 3 da ADR 0052). */
540
+ export function useLayerPortal(): LayerPortal {
541
+ return React.useContext(LayerPortalContext);
542
+ }
543
+
544
+ export const LayerPortalProvider = LayerPortalContext.Provider;
545
+
546
+ /** Faixa de resposta obrigatória: acima da camada flutuante (decisão 4 da seção 9). */
547
+ export const ANSWER_BAND_CLASS = "z-70";
package/src/ui/meta.ts CHANGED
@@ -257,6 +257,12 @@ export const componentMeta = {
257
257
  whenToUse:
258
258
  "Esconda um bloco secundário atrás de um gatilho, mantendo o restante da tela visível. Componha Collapsible > (CollapsibleTrigger + CollapsibleContent); o gatilho já renderiza o `<button>`. Use `defaultOpen` no modo não controlado e `open` com `onOpenChange` quando outra parte da tela precisar conhecer o estado; `disabled` trava o gatilho. Para vários painéis recolhíveis, use Accordion; para alternar entre painéis, Tabs; para ações ancoradas, Menu.",
259
259
  },
260
+ "floating-layer": {
261
+ name: "floating-layer",
262
+ ancestry: "opus",
263
+ whenToUse:
264
+ "Mantenha utilizável, por cima de qualquer Dialog ou Drawer aberto, uma região que acompanha a aplicação inteira, como o botão e o painel de um assistente. O modal continua aberto e intacto atrás; a camada recebe clique, foco, digitação, rolagem e Esc. Diálogos que exigem resposta, como `Dialog mode=\"alert\"` e `dialog.confirm`, continuam cobrindo a camada até a resposta. Cada filho se posiciona contra a janela, e o hospedeiro publica em `data-modal` e `data-modal-side` o Dialog ou Drawer aberto. Para ferramentas sobre uma única superfície, use Dock; para conteúdo preso a um gatilho, Popover.",
265
+ },
260
266
  drawer: {
261
267
  name: "drawer",
262
268
  ancestry: "opus",
package/src/ui/react.tsx CHANGED
@@ -139,6 +139,10 @@ export type {
139
139
  DialogMode,
140
140
  } from "./components/primitives/dialog.tsx";
141
141
 
142
+ // A camada que convive com Dialog e Drawer modais (ADR 0052).
143
+ export { FloatingLayer } from "./components/primitives/floating-layer.tsx";
144
+ export type { FloatingLayerProps } from "./components/primitives/floating-layer.tsx";
145
+
142
146
  // O pacote de anatomia que Surface, Dialog, Drawer e Pane declaram no root (ADR 0023). As
143
147
  // tabelas e o contexto continuam internos; o consumidor escolhe entre os dois nomes.
144
148
  export type { SurfaceLayout } from "./components/patterns/surface-anatomy.ts";