@softize/opus 25.0.0 → 25.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,668 @@
1
+ # ADR 0052 — Uma camada flutuante convive com Dialog e Drawer modais
2
+
3
+ - **Status:** aceita.
4
+ - **Data:** 2026-10-03.
5
+ - **Complementa:** ADR 0023, quanto a Dialog e Drawer como superfícies modais, e ADR 0017, quanto à
6
+ largura mínima suportada.
7
+ - **Relacionadas:** ADR 0007, porque o Toast já convive com o modal, mas só em parte; na plataforma
8
+ Grand Brasil, ADR 0087, que define o FAB e o painel do Copilot global.
9
+
10
+ ## Contexto
11
+
12
+ ### O pedido
13
+
14
+ Na plataforma Grand Brasil, o Copilot global aparece como um botão flutuante (FAB) no canto inferior
15
+ direito. Aberto, ele vira um painel de `28rem` de largura sobreposto no mesmo canto. Quem está
16
+ preenchendo um formulário num Dialog ou num Drawer quer perguntar ao Copilot sobre o que está vendo
17
+ sem perder o que já digitou.
18
+
19
+ A decisão de produto já foi tomada: o FAB e o painel ficam visíveis e utilizáveis por cima de
20
+ qualquer Dialog ou Drawer, e o modal continua aberto e intacto atrás. A outra saída, fechar o modal
21
+ quando a pessoa aciona o FAB, foi descartada porque jogaria fora o preenchimento.
22
+
23
+ Hoje isso não funciona, e a causa não está na Grand Brasil. Está no modo modal que o Opus usa.
24
+
25
+ ### O que o modo modal faz hoje
26
+
27
+ O `Dialog` e o `Drawer` do Opus são o `Dialog` do Radix no modo modal, que é o padrão. O Drawer não
28
+ usa vaul: é o mesmo `Dialog` do Radix, importado de `radix-ui`, com outra moldura. Enquanto um deles
29
+ está aberto, o Radix faz cinco coisas:
30
+
31
+ 1. **Prende o foco.** Um `FocusScope` com trava devolve ao último elemento focado dentro do modal
32
+ qualquer foco que vá para fora dele, e o Tab dá a volta dentro do modal.
33
+ 2. **Bloqueia o ponteiro fora.** O `DismissableLayer` do modal grava `pointer-events: none` no
34
+ `body`; só o modal e os `DismissableLayer` abertos depois dele voltam a receber clique. O véu
35
+ (`z-50`, ocupando a tela inteira) fica por cima do resto.
36
+ 3. **Trata clique fora e Esc como pedido de fechar.** Apertar o botão principal fora do modal fecha
37
+ quando o clique se completa; o botão direito não fecha. O Esc é ouvido no documento inteiro, e
38
+ quem responde é o `DismissableLayer` aberto mais recentemente, onde quer que o foco esteja. Foco
39
+ que sai do modal nunca o fecha: o próprio Radix cancela esse caso.
40
+ 4. **Trava a rolagem fora.** O `react-remove-scroll` cancela roda do mouse e arrasto de toque que
41
+ não nasçam dentro do modal.
42
+ 5. **Esconde o resto do leitor de tela.** O `hideOthers`, do pacote `aria-hidden`, percorre o `body`
43
+ e marca com `aria-hidden="true"` todo elemento fora do caminho até o modal. Um filho do `body`
44
+ que não contém o modal é marcado inteiro. Há uma exceção embutida: elementos com `aria-live`
45
+ presentes naquele momento também ficam visíveis, e o caminho até eles é preservado; nos
46
+ ancestrais desse caminho, só os irmãos que estão fora dele são marcados. O conteúdo do modal não
47
+ recebe `aria-modal`.
48
+
49
+ O `hideOthers` não é exclusivo do `Dialog`. O `Menu` do Opus é o `DropdownMenu` do Radix, que é modal
50
+ por padrão e também chama `hideOthers` ao abrir, assim como as confirmações do `DialogHost`.
51
+
52
+ Na Grand Brasil, o FAB e o painel ficam dentro do `#root`. Com um modal aberto, os dois ficam
53
+ escondidos do leitor de tela, sem ponteiro e abaixo do véu. Mesmo que a pessoa alcançasse a caixa
54
+ de mensagem, a trava devolveria o foco ao modal, e a caixa não receberia digitação.
55
+
56
+ ### As exceções que existem
57
+
58
+ Antes de escolher um caminho, convém saber exatamente o que o Radix já permite. Cada item abaixo
59
+ foi conferido no código instalado (seção “Premissas verificadas”).
60
+
61
+ - **Exceções públicas, só para o fechamento.** `onEscapeKeyDown`, `onPointerDownOutside`,
62
+ `onFocusOutside` e `onInteractOutside` aceitam `preventDefault` e impedem que o modal feche. O
63
+ `DialogContent` e o `DrawerContent` do Opus repassam esses handlers e compõem `closeDisabled` sobre
64
+ o Esc e o clique fora. Eles não liberam foco, ponteiro, rolagem nem leitor de tela. E nenhum
65
+ pattern construído sobre esses componentes os repassa: `ActionFormDialog`, `ActionListDialog`,
66
+ `Presentation`, `ActionTrigger`, os diálogos de `ActionList`, as confirmações do `DialogHost` e o
67
+ `CommandDialog` não expõem esses handlers.
68
+ - **Peças internas do Radix.** `FocusScope` e `DismissableLayer` (com o `Branch`) saem em
69
+ `radix-ui/internal` e como pacotes próprios no npm, mas ficam fora da documentação pública do
70
+ Radix. O `Branch` faz com que clique e foco num elemento deixem de contar como “fora”; não muda
71
+ ponteiro, rolagem, leitor de tela nem a trava de foco. Um `FocusScope` montado depois do modal
72
+ pausa a trava do modal, mas a pilha de escopos do Radix só a retoma quando esse escopo desmonta.
73
+ Uma camada sempre montada, como o FAB, deixaria o modal sem trava e sem a volta do Tab durante
74
+ toda a vida dela: o Tab sairia do modal para a página escondida.
75
+ - **`modal={false}`.** Desliga de uma vez a trava, o bloqueio de ponteiro, o véu (o Radix deixa de
76
+ renderizar o `Overlay`), a trava de rolagem e o `aria-hidden`. A volta do Tab continua, e o
77
+ diálogo passa a fechar também quando o foco sai dele. O `mode="alert"` usa o `AlertDialog` do
78
+ Radix, que força o modo modal e ignora essa opção.
79
+ - **As bibliotecas por baixo aceitam exceções; o Radix não as repassa.** O `hideOthers` aceita uma
80
+ lista de elementos a manter visíveis, e o `react-remove-scroll` aceita áreas extras (`shards`),
81
+ mas o `Dialog` do Radix passa só o próprio conteúdo nos dois casos.
82
+
83
+ ### O precedente do Toaster
84
+
85
+ O Toaster (Sonner) já aparece por cima de um modal: tem `z-index` altíssimo, e sua região tem
86
+ `aria-live`, então o `hideOthers` não o esconde e os avisos continuam sendo anunciados. Mas ele não
87
+ declara `pointer-events: auto` e herda o `none` do `body`. Pela leitura do código, as ações de um
88
+ aviso (ADR 0007) não respondem a clique enquanto um modal está aberto; isso ainda precisa ser
89
+ confirmado no navegador. Por teclado, a trava não deixa o foco chegar a ele. O Toaster convive com
90
+ o modal para ser lido, não para ser usado. É um precedente parcial, e o mesmo problema desta ADR.
91
+
92
+ ### Forças em tensão
93
+
94
+ - **A acessibilidade do modal.** Quem usa teclado ou leitor de tela depende da trava de foco, do Esc
95
+ e do resto da página escondido para saber onde está e não se perder atrás do véu. Essa garantia
96
+ não pode piorar para quem nunca usa a camada.
97
+ - **Uma camada de assistência utilizável.** O painel precisa receber clique, foco, digitação, rolagem
98
+ e Esc sem fechar o modal nem ser puxado de volta por ele, e precisa continuar audível para o
99
+ leitor de tela.
100
+ - **A regra precisa valer em todo lugar.** Se a convivência depender de cada consumidor repassar
101
+ handlers, os patterns, que não os repassam, ficam de fora, e cada tela nova pode esquecer.
102
+ - **A dependência do Radix.** Quanto mais a solução depender de detalhes não documentados do Radix e
103
+ das bibliotecas por baixo, mais uma atualização pode quebrá-la sem aviso.
104
+ - **Reversibilidade.** A API pública deve sobreviver a uma troca de implementação, porque a parte
105
+ mais arriscada, o foco, ainda precisa ser provada no navegador.
106
+
107
+ ## Decisão
108
+
109
+ ### 1. O que é a camada flutuante
110
+
111
+ Uma **camada flutuante** é uma região da aplicação que fica acima do conteúdo e dos modais de
112
+ trabalho (Dialog e Drawer) e continua utilizável enquanto eles estão abertos. O FAB e o painel do
113
+ Copilot são o primeiro caso.
114
+
115
+ Ela não é um popover, um menu nem um tooltip, que são transitórios e presos a um gatilho. Também não
116
+ é o `Dock`, que flutua sobre uma única superfície, fica dentro dela e some com ela. E não é um modal:
117
+ não bloqueia nada. O nome repete o termo do pedido da Grand Brasil (TODO, seção 18). O comentário de
118
+ `src/ui/lib/dismiss-on-iframe.ts` usa “camada flutuante” no sentido solto de popover e menu, e deve
119
+ ser reescrito junto com a implementação para não confundir os dois conceitos.
120
+
121
+ O princípio durável é este: **o Opus é dono da convivência.** Um modal do Opus trata a camada como
122
+ parte de dentro quanto a foco, clique, Esc, rolagem e leitor de tela. Quem usa Dialog, Drawer ou
123
+ qualquer pattern construído sobre eles não repassa nada. O consumidor só declara a camada.
124
+
125
+ ### 2. A API pública
126
+
127
+ ```tsx
128
+ import { FloatingLayer } from '@softize/opus/ui/react'
129
+
130
+ <FloatingLayer onEscapeKeyDown={handleEscape}>
131
+ {copilotOpen ? <CopilotPanel /> : <CopilotFab />}
132
+ </FloatingLayer>
133
+ ```
134
+
135
+ ```ts
136
+ interface FloatingLayerProps {
137
+ children: React.ReactNode
138
+ /** Classes do hospedeiro. A posição de cada filho continua sendo do consumidor. */
139
+ className?: string
140
+ /**
141
+ * Esc enquanto a pessoa está na camada (seção 5), com ou sem modal aberto. É chamado uma vez por
142
+ * tecla. Nenhum modal fecha nesse caso, e o Opus não fecha a camada sozinho, porque o estado de
143
+ * aberto pertence ao consumidor.
144
+ */
145
+ onEscapeKeyDown?: (event: KeyboardEvent) => void
146
+ }
147
+ ```
148
+
149
+ - `FloatingLayer` porta seus filhos para um hospedeiro estável no `body`:
150
+ `div[data-slot="floating-layer"]`, fixo, ocupando a tela inteira, com `pointer-events: none` no
151
+ hospedeiro e `auto` nos filhos diretos. O consumidor posiciona cada filho com `absolute` contra a
152
+ viewport.
153
+ - O hospedeiro publica qual modal de trabalho está no topo da pilha: `data-modal` vale `none`,
154
+ `dialog` ou `drawer`, e `data-modal-side` traz o lado do Drawer (`left`, `right`, `top` ou
155
+ `bottom`). É o sinal público para o consumidor adaptar a posição dos filhos (seção 7). Com um
156
+ modal de resposta obrigatória ou um modal interno à camada no topo, o sinal continua descrevendo
157
+ o modal de trabalho mais alto da pilha, ou `none` se não houver nenhum; a camada está coberta
158
+ nesse intervalo, e mudar sua posição só para ele faria o painel pular duas vezes.
159
+ - O hospedeiro não tem papel ARIA. A semântica vem dos filhos: na Grand Brasil, `aside` com
160
+ `aria-label="Copilot"` e o botão “Abrir o Copilot”.
161
+ - Podem existir várias camadas ao mesmo tempo. A ordem de montagem desempata o empilhamento entre
162
+ elas.
163
+ - O componente mora em `src/ui/components/primitives/floating-layer.tsx`. O registro das camadas e a
164
+ pilha dos modais do Opus moram em `src/ui/lib/modal-layers.ts`, consumido por `dialog.tsx`,
165
+ `drawer.tsx` e pelos primitives que usam portal. A exportação sai por `src/ui/react.tsx`.
166
+ - Não há provider novo. O registro tem um valor padrão global, como o próprio Radix faz com o
167
+ contexto do `DismissableLayer`, para funcionar com o `DialogHost`, fora do `OpusProvider` e nos
168
+ exemplos da documentação.
169
+
170
+ ### 3. Portais dentro da camada
171
+
172
+ Popover, Menu, Tooltip, o que é construído sobre eles (como Select) e Dialog ou Drawer abertos de
173
+ dentro da camada passam a portar para o hospedeiro da camada em vez do `body`. Os primitives que usam
174
+ portal leem o contêiner de um contexto que a `FloatingLayer` fornece; fora dela, nada muda.
175
+
176
+ Sem isso, o menu de “nova conversa” ou a explicação de uma resposta do Copilot abririam no `body`,
177
+ na faixa dos modais, e ficariam por baixo do próprio painel que os abriu. Portados para o
178
+ hospedeiro, eles ficam acima do painel e contam como parte da camada para foco, rolagem e leitor de
179
+ tela.
180
+
181
+ Um modal aberto de dentro da camada é modal para tudo, inclusive para a própria camada. Ele entra na
182
+ pilha marcado como **não isento**: enquanto estiver no topo, nenhuma exceção desta ADR fica ativa
183
+ (seção 6), e a subárvore dele nunca conta como “dentro da camada”.
184
+
185
+ ### 4. Empilhamento
186
+
187
+ Do fundo para a frente, a ordem passa a ser:
188
+
189
+ 1. conteúdo da página;
190
+ 2. faixa modal: Dialog, Drawer, seus véus e os popovers abertos fora da camada, todos em `z-50`,
191
+ desempatados pela ordem no DOM, como hoje;
192
+ 3. faixa da camada flutuante;
193
+ 4. faixa de resposta obrigatória: `mode="alert"` e o `DialogHost` (decisão 4 da seção 9);
194
+ 5. Toaster, com o `z-index` do Sonner.
195
+
196
+ Os valores de `z-index` de cada faixa são detalhe de implementação. Eles ficam documentados numa
197
+ escala própria em `packages/opus/docs`, ao lado de `elevation-scale.md`.
198
+
199
+ ### 5. O comportamento enquanto um modal convive com a camada
200
+
201
+ | Aspecto | Hoje | Com a camada |
202
+ | --- | --- | --- |
203
+ | Ponteiro | Só o modal recebe clique. | A camada também recebe. A página atrás continua coberta pelo véu. |
204
+ | Clique na camada | Fecha o modal. | Não fecha nenhum modal, mesmo que o elemento clicado suma no próprio clique. |
205
+ | Clique no modal | — | O Opus não impõe nada à camada; fechá-la é decisão do consumidor. |
206
+ | Clique no véu | Fecha o modal. | Fecha o modal, como hoje, respeitando `closeDisabled`. |
207
+ | Esc | Fecha o `DismissableLayer` mais recente. | Age onde a pessoa está (abaixo). |
208
+ | Foco | Preso no modal. | Modal e camada formam a região permitida (abaixo). |
209
+ | Rolagem | Só dentro do modal. | Dentro do modal e dentro da camada. A página continua travada. |
210
+ | Leitor de tela | Só o modal fica visível. | O modal e a camada ficam visíveis; o resto continua escondido. |
211
+
212
+ Sem nenhum modal aberto, a camada é DOM comum: nenhuma das exceções acima fica ativa, e só o
213
+ `onEscapeKeyDown` da camada continua valendo.
214
+
215
+ **Onde a pessoa está.** O foco decide primeiro: com o foco num elemento do modal, a pessoa está no
216
+ modal; com o foco num elemento da camada, está na camada; com o foco em qualquer outro elemento da
217
+ página, não está na camada. O ponteiro decide só quando o foco está no `body`: o último aperto
218
+ dentro do hospedeiro põe a pessoa na camada, e o último aperto fora dele a tira. Assim, clicar
219
+ numa área sem foco do painel, como o texto de uma resposta, conta como estar na camada. E clicar num botão do painel que não recebe foco (barras de ferramentas e listas do `cmdk`
220
+ impedem o foco no aperto) deixa o foco no campo do modal: se a pessoa volta a digitar ali e aperta
221
+ Esc, o modal fecha, como esperado.
222
+
223
+ **Esc age onde a pessoa está.** Na camada, nenhum modal fecha e o Opus chama o `onEscapeKeyDown` da
224
+ camada uma vez. No modal, o Esc fecha o modal, como hoje. A alternativa de fechar primeiro o que
225
+ está visualmente por cima foi descartada: quem preenche o modal e aperta Esc quer sair do modal, não
226
+ do painel que nem está usando.
227
+
228
+ Antes dessa regra vale a do Radix: um `DismissableLayer` aberto depois do modal consome o Esc, onde
229
+ quer que a pessoa esteja. Isso dá três exceções conhecidas e aceitas:
230
+
231
+ - um popover ou menu aberto dentro da camada fecha primeiro, e só o Esc seguinte chega à camada;
232
+ - um popover aberto dentro do modal continua no topo e consome o Esc mesmo com a pessoa na camada;
233
+ - o tooltip do FAB, aberto por passagem do mouse, consome o Esc mesmo com a pessoa no modal.
234
+
235
+ Para impedir o Radix de fechar o modal, o Opus precisa chamar `preventDefault` no evento de teclado.
236
+ Por isso o evento nativo chega aos elementos da camada já com `defaultPrevented`. A camada trata o
237
+ Esc só pelo `onEscapeKeyDown`; um `onKeyDown` próprio para Esc no painel trataria a tecla duas vezes.
238
+
239
+ **Foco.** O foco pode ir para a camada por clique ou por chamada de código (o Copilot focando a
240
+ caixa de mensagem ao abrir) sem ser puxado de volta. Foco que vai para qualquer outro lugar volta ao
241
+ último elemento focado no modal, como hoje. `onBlur` e `onFocus` dos campos do modal e da camada
242
+ continuam disparando normalmente. Dentro da camada, o Tab segue a ordem dela. Como o teclado chega à
243
+ camada e para onde o foco volta quando a camada some estão nas decisões 2 e 3 da seção 9.
244
+
245
+ **Leitor de tela.** O Opus não adiciona `aria-modal` ao conteúdo dos modais, que hoje não o tem.
246
+ Leitores que respeitam `aria-modal` passariam a ignorar a camada. A camada continua visível também
247
+ quando um menu modal abre e fecha sobre o modal, e quando o painel contém uma região `aria-live`
248
+ própria, como a do `Ask` ou do `StateSurface`.
249
+
250
+ ### 6. Quando as exceções ficam ativas
251
+
252
+ Cada modal do Opus se registra numa pilha ao abrir, com uma de três marcas:
253
+
254
+ - **de trabalho:** Dialog e Drawer portados fora de qualquer hospedeiro de camada;
255
+ - **de resposta obrigatória:** `mode="alert"` e as confirmações do `DialogHost`;
256
+ - **interno à camada:** qualquer modal portado para um hospedeiro (seção 3).
257
+
258
+ As exceções desta ADR (clique, Esc, foco, rolagem e leitor de tela) ficam ativas **somente quando o
259
+ topo da pilha é um modal de trabalho**. Com um modal de resposta obrigatória ou um modal interno à
260
+ camada no topo, todas ficam suspensas: a trava, o véu, o Esc e o `aria-hidden` desse modal agem
261
+ sobre a camada como sobre o resto da página. Isso evita dois defeitos:
262
+
263
+ - um modal aberto de dentro da camada teria a trava cega, o véu sem efeito e o Esc desviado para a
264
+ camada, porque estaria dentro do hospedeiro isento;
265
+ - com um alerta no topo, um foco programático do Copilot escaparia da trava do alerta.
266
+
267
+ Mesmo com as exceções ativas, a subárvore de um modal interno à camada nunca conta como dentro da
268
+ camada.
269
+
270
+ Com modais de trabalho empilhados (Dialog sobre Drawer, Drawer sobre Drawer), a isenção vale para
271
+ todos; a trava de foco e o Esc continuam agindo só no modal do topo, como hoje. Fechar o modal do
272
+ topo devolve o controle ao de baixo, e a camada continua isenta.
273
+
274
+ ### 7. Telas pequenas e sobreposição
275
+
276
+ A ADR 0017 suporta a interface a partir de `64rem`. O painel de `28rem`, ancorado à direita e com até
277
+ `44rem` de altura, cobre áreas que a pessoa precisa ver:
278
+
279
+ - **Drawer à direita, o caso mais comum.** O `DrawerContent` padrão tem `side="right"` e até `24rem`
280
+ de largura; na Grand Brasil há Drawers de `24rem`, `28rem` e `56rem`. O painel cobre os dois
281
+ primeiros na largura inteira e quase toda a altura, e metade do de `56rem`. Com o painel aberto,
282
+ perguntar sobre o que está no Drawer fica, na prática, impossível.
283
+ - **Drawer com assistência contextual aberta.** A `Presentation` em Drawer e o Drawer de Clientes da
284
+ Grand Brasil passam a `min(80rem, 100% - 2rem)` de largura quando o painel de assistência do
285
+ recurso está aberto. Nessa largura não sobra lado livre para o painel do Copilot, e nenhuma das
286
+ opções da decisão 5 da seção 9 evita a sobreposição; a pessoa já tem, ali, uma conversa sobre o
287
+ recurso.
288
+ - **Drawer à direita com o painel fechado.** O FAB fica sobre o canto do rodapé, onde ficam as ações
289
+ principais, alinhadas à direita.
290
+ - **Dialog centralizado.** A `64rem`, o painel cobre cerca de `12rem` de um Dialog de `32rem`. Numa
291
+ tela de `90rem`, os dois cabem lado a lado.
292
+
293
+ O Opus não desloca nem redimensiona modais por causa da camada nesta versão. Ele publica
294
+ `data-modal` e `data-modal-side` no hospedeiro para que o consumidor reposicione o que precisar. A
295
+ resposta da Grand Brasil está na decisão 5 da seção 9.
296
+
297
+ ### 8. A materialização escolhida
298
+
299
+ Esta seção pode ser trocada sem mudar a API pública. A decisão é **manter o modo modal do Radix e
300
+ abrir exceções nas bordas**, com a reserva descrita no fim desta seção se o protótipo reprovar,
301
+ todas ligadas e desligadas pela regra da seção 6:
302
+
303
+ - **Pertença decidida no aperto do ponteiro.** O Dialog fecha só quando o clique se completa: no
304
+ aperto, o Radix guarda o evento e registra um ouvinte de `click` no documento; o handler
305
+ `onPointerDownOutside` recebe o evento do aperto. Até lá, o elemento apertado pode ter sumido. O
306
+ FAB some no próprio clique, assim como o botão de fechar o painel e os itens de um popover, e o
307
+ React 19 aplica essas mudanças numa microtarefa que roda antes do ouvinte do Radix. Perguntar ao
308
+ DOM, no fim do clique, se o alvo está dentro do hospedeiro daria “não”, e o modal fecharia. Por
309
+ isso o hospedeiro registra, em fase de captura, cada aperto que começa nele (num `WeakSet` de
310
+ eventos), e o `DialogContent` e o `DrawerContent` compõem `onPointerDownOutside` e
311
+ `onInteractOutside` consultando esse registro pelo `detail.originalEvent`. A isenção da camada
312
+ vem primeiro na composição e interrompe a cadeia, como o `DismissableLayer.Branch` do Radix: um
313
+ evento nascido na camada é de dentro do modal e não chega ao handler do consumidor. Para os
314
+ demais eventos, a ordem é handler do consumidor e, por fim, `closeDisabled`. O mesmo vale para o
315
+ `onEscapeKeyDown`, para que um `preventDefault` do consumidor não engula o Esc que pertence à
316
+ camada. Como a composição mora nesses dois componentes, os patterns ganham a isenção sem mudança.
317
+ - **Esc.** O `onEscapeKeyDown` composto consulta onde a pessoa está (seção 5). Na camada, chama
318
+ `preventDefault` e o `onEscapeKeyDown` da camada. Sem modal, a própria `FloatingLayer` ouve o Esc
319
+ no documento, em borbulha, e usa o mesmo rastreador de onde a pessoa está. Ouvir só no
320
+ hospedeiro não bastaria: depois de um clique no texto de uma resposta, o foco fica no `body`, e o
321
+ evento de teclado nunca passa pelo hospedeiro. Esse ouvinte ignora eventos já marcados com
322
+ `defaultPrevented`, que um `DismissableLayer` de dentro da camada ou a composição do modal já
323
+ trataram. Assim, o `onEscapeKeyDown` é chamado uma única vez por tecla, com ou sem modal.
324
+ - **Ponteiro e empilhamento:** o hospedeiro declara seus próprios `pointer-events` e fica na faixa
325
+ da camada.
326
+ - **Foco e rolagem, num único ouvinte no `body`.** A trava do Radix ouve `focusin` e `focusout`, e o
327
+ `react-remove-scroll` ouve `wheel` e `touchmove`, todos no documento, em fase de borbulha. O
328
+ registro mantém um ouvinte nativo no `body`, também em borbulha, que impede que chegue ao
329
+ documento: o `focusin` cujo alvo está no hospedeiro; o `focusout` cujo alvo ou destino está no
330
+ hospedeiro; e o `wheel` e o `touchmove` cujo alvo está no hospedeiro. A trava não vê a passagem e
331
+ não puxa o foco de volta; todo o resto da trava continua sendo do Radix. O ouvinte fica no `body`,
332
+ e não no conteúdo do modal, porque o React escuta eventos no contêiner de cada portal, que para os
333
+ modais é o próprio `body`. Parar a propagação antes dele engoliria o `onBlur` e o `onFocus` dos
334
+ campos do modal. No mesmo nó, o ouvinte do React continua rodando.
335
+ - **Rolagem encadeada no toque.** Com o `touchmove` liberado, um arrasto que chegue ao fim da área
336
+ rolável do painel pode passar a rolar a página. As áreas roláveis da camada, como o histórico da
337
+ conversa dentro do painel, usam `overscroll-behavior: contain`; a propriedade só age no contêiner
338
+ que rola, então aplicá-la é responsabilidade do consumidor, e a documentação da camada diz isso. A
339
+ ADR 0017 trata a interface como desktop, mas a regra é barata.
340
+ - **Leitor de tela.** O hospedeiro carrega `aria-live="off"` o tempo todo, exceto enquanto um modal
341
+ de resposta obrigatória ou interno à camada está no topo. É o valor padrão do atributo: não
342
+ transforma a camada numa região que anuncia mudanças, e regiões `aria-live` de dentro dela mantêm
343
+ o próprio comportamento. Mas o `hideOthers` preserva todo elemento com `aria-live`, a cada
344
+ chamada, e não desce para dentro dele. A camada fica visível diante do `Dialog`, do `Drawer` e de
345
+ um menu modal aberto sobre eles, e as regiões `aria-live` do painel não fazem o `hideOthers`
346
+ esconder partes dele. Quando um modal que suspende as exceções entra no topo, o registro retira o
347
+ atributo num efeito de layout, que roda antes do `hideOthers` desse modal. Ele devolve o atributo,
348
+ também num efeito de layout, quando esse modal **sai do topo**, isto é, quando fecha, e não quando
349
+ desmonta. A diferença importa: o `Presence` do Radix mantém o modal montado durante a animação de
350
+ saída. Se uma confirmação do `DialogHost` abre um Dialog na resposta, o `hideOthers` do Dialog
351
+ roda com a confirmação ainda montada; com o atributo devolvido só na desmontagem, o hospedeiro
352
+ seria contado de novo e ficaria escondido durante todo o Dialog. Limite conhecido: enquanto esse
353
+ modal está aberto, as regiões `aria-live` de dentro do painel continuam audíveis, como os avisos do
354
+ Toaster.
355
+
356
+ Efeitos colaterais aceitos:
357
+
358
+ - Enquanto as exceções estão ativas, ouvintes de foco no documento não veem o foco entrar na camada
359
+ nem andar dentro dela. Um popover aberto dentro do modal não fecha quando o foco vai para a
360
+ camada. Um popover aberto dentro da camada não fecha quando o foco passa para outro ponto da
361
+ camada; sem modal aberto, ele fecha. O comportamento desses popovers muda, portanto, conforme haja
362
+ ou não um modal de trabalho aberto.
363
+ - Modais que não entram na pilha do Opus, como um `Dialog` do Radix usado diretamente ou o de uma
364
+ biblioteca de terceiros, não escondem mais a camada do leitor de tela, porque o `aria-live` do
365
+ hospedeiro a protege de qualquer `hideOthers`. A trava e o véu deles continuam valendo.
366
+
367
+ Essas exceções dependem de detalhes de implementação do Radix, do React, do `react-remove-scroll` e
368
+ do `aria-hidden`: a fase dos ouvintes, o contêiner onde o React escuta, o momento do fechamento por
369
+ clique e a preservação de `[aria-live]`. Testes de premissa (seção “Verificação”) falham se uma
370
+ atualização mudar algum desses detalhes. Se a preservação de `[aria-live]` deixar de valer, a reserva
371
+ para o leitor de tela é um `MutationObserver` que reaplica a visibilidade do hospedeiro enquanto o
372
+ topo da pilha for um modal de trabalho.
373
+
374
+ **Alternativa de reserva para o conjunto:** usar o `Dialog` do Radix no modo não modal e fazer o Opus
375
+ refazer a trava, o véu, a trava de rolagem, o `aria-hidden` e a pilha de modais, com `aria-hidden` e
376
+ `react-remove-scroll` como dependências diretas. A API pública é a mesma.
377
+
378
+ A escolha entre as duas se fecha num protótipo em navegador real, com os critérios de teclado da
379
+ seção “Verificação”, antes de o código entrar no pacote (decisão 1 da seção 9).
380
+
381
+ **Nota de implementação (2026-10-03).** O protótipo da blindagem passou nos critérios de teclado em
382
+ Chromium (`tests/browser/floating-layer-focus.test.tsx`), e a reserva não foi usada. A implementação
383
+ fechou cinco pontos que esta seção deixava em aberto, sem mudar a API pública:
384
+
385
+ - **Ciclo do Tab (decisão 2).** O `DialogContent` e o `DrawerContent` compõem um `onKeyDown` que,
386
+ com as exceções ativas e o foco no último elemento do modal do topo, leva o Tab ao primeiro
387
+ elemento da camada; o Shift+Tab no primeiro elemento faz o caminho inverso. O hospedeiro ouve o
388
+ `keydown` e faz o mesmo nas bordas da camada. Com várias camadas, o ciclo segue a ordem de
389
+ montagem. O laço do Radix continua valendo quando a camada não tem onde parar.
390
+ - **Volta do foco (decisão 3).** Um `MutationObserver` em cada hospedeiro percebe quando o último
391
+ elemento focado da camada sai do DOM com o foco no `body`. Uma tarefa depois, se o foco continuar
392
+ no `body`, o registro o devolve ao último elemento focado fora das camadas, quando ele ainda está
393
+ no modal do topo, ou ao primeiro elemento focável do modal. A espera de uma tarefa deixa o
394
+ consumidor levar o foco a outro elemento da camada, como a caixa de mensagem que substitui o FAB.
395
+ - **Estado de aberto.** `Dialog` e `Drawer` passam a controlar o estado de aberto e o entregam ao
396
+ Radix sempre como controlado, porque a pilha precisa saber quando o modal fecha, e não quando o
397
+ `Presence` o desmonta. Um modal com `modal={false}` não entra na pilha.
398
+ - **Faixa da resposta obrigatória.** Popover, Menu e Tooltip abertos de dentro de um alerta herdam a
399
+ faixa dele, pelo mesmo contexto que leva os portais ao hospedeiro; sem isso, ficariam abaixo do
400
+ véu do próprio alerta. A escala está em `docs/stacking-scale.md`.
401
+ - **Alerta aberto de dentro da camada.** Vai para o hospedeiro, como qualquer modal aberto de lá, e
402
+ entra na pilha como resposta obrigatória. As duas marcas suspendem as exceções; a subárvore dele
403
+ é marcada como modal e nunca conta como camada.
404
+
405
+ **Nota de revisão (2026-10-03).** Duas revisões independentes da implementação apontaram bordas que
406
+ a seção 6 e as decisões 3 e 4 não cobriam por completo. A correção refina a regra sem mudar a API:
407
+
408
+ - **Suspensão em qualquer posição da pilha.** A seção 6 diz que as exceções valem só com um modal de
409
+ trabalho no topo. Um Dialog aberto de dentro de um alerta fica no topo como modal de trabalho,
410
+ mas o alerta continua aberto e cobrindo a camada. As exceções passam a exigir também que nenhum
411
+ modal de resposta obrigatória ou interno à camada esteja aberto, em qualquer posição da pilha; o
412
+ `aria-live` do hospedeiro segue a mesma regra.
413
+ - **Modal que fecha com a pessoa na camada.** O Radix leva o foco ao gatilho do modal que fecha.
414
+ Com o foco numa camada, o Dialog e o Drawer cancelam essa volta, e o foco fica onde a pessoa está
415
+ digitando. É comportamento novo, simétrico à decisão 3.
416
+ - **Camada que sai com o foco dentro.** Desmontar a `FloatingLayer` com o foco nela e um modal de
417
+ trabalho aberto devolve o foco ao modal, pela mesma regra da decisão 3.
418
+ - **Aperto num painel que some.** O último aperto deixa de pôr a pessoa na camada quando o filho
419
+ direto do hospedeiro que o recebeu sai da camada, como um painel fechado por código e trocado
420
+ pelo FAB. A âncora é esse filho, e não o elemento apertado: o `Markdown` do `Chat` troca todos os
421
+ nós da resposta a cada pedaço do streaming, e a pessoa que clicou no texto continua no painel.
422
+ - **Portais de dentro de um modal interno à camada.** Popover, Menu e Tooltip abertos de dentro dele
423
+ vão para o hospedeiro como irmãos do modal e recebem a mesma marca, para nunca contar como camada.
424
+ - **Tab nas bordas da camada.** O hospedeiro passou a decidir o Tab ouvindo o documento, em
425
+ borbulha, depois dos `onKeyDown` do React de dentro da camada, e respeita um `preventDefault`
426
+ deles.
427
+
428
+ ### 9. As questões que ficaram para decisão
429
+
430
+ A proposta deixou sete questões para decidir antes da implementação. Em 2026-10-03, todas foram
431
+ decididas como a proposta recomendava; cada item registra a questão, a decisão e o motivo.
432
+
433
+ 1. **Como materializar o foco: blindar a trava do Radix ou refazê-la.** A alternativa de empilhar
434
+ escopos com um `FocusScope` aninhado foi descartada (seção “As exceções que existem”).
435
+ *Decisão:* blindar (seção 8), porque preserva o comportamento de foco do Radix para quem não
436
+ usa a camada e muda pouco código. Refazer fica como reserva se o protótipo mostrar que a
437
+ blindagem é frágil.
438
+ 2. **Como o teclado chega à camada com um modal aberto.** Hoje o Tab dá a volta dentro do modal, e
439
+ nenhuma tecla leva à camada. *Decisão:* incluir a camada no ciclo do Tab: depois do último
440
+ elemento do modal vem a camada, e depois da camada, o primeiro elemento do modal (Shift+Tab
441
+ percorre o caminho inverso). Fica descobrível sem decorar atalho. Com o painel fechado, custa uma
442
+ parada a mais (o FAB). Um atalho como F6 foi considerado, mas os navegadores usam F6 para a barra
443
+ de endereço, e ainda não foi testado se a página consegue interceptá-lo.
444
+ 3. **Para onde volta o foco quando a camada some com um modal aberto**, por exemplo quando a pessoa
445
+ fecha o painel. *Decisão:* para o elemento do modal que tinha o foco antes de a pessoa
446
+ entrar na camada; se ele não existir mais, para o primeiro elemento focável do modal. Assim a
447
+ pessoa continua o preenchimento de onde parou. Voltar para o FAB segue o padrão de “devolver ao
448
+ gatilho”, mas obriga mais um Tab para retomar o formulário.
449
+ 4. **A camada convive com diálogos de resposta obrigatória?** `mode="alert"` e as confirmações do
450
+ `DialogHost`, como “Excluir?”, exigem uma resposta antes de qualquer outra coisa.
451
+ *Decisão:* não. Esses diálogos ficam acima da camada e a bloqueiam, com o véu cobrindo o
452
+ FAB e o painel (seção 6). O estado fica visualmente honesto (nada parece clicável sem ser) e o
453
+ `alert` continua no `AlertDialog` do Radix, sem migração. O custo é não poder perguntar ao
454
+ Copilot no meio de uma confirmação; a pessoa cancela, pergunta e repete.
455
+ 5. **O que fazer com a sobreposição** (seção 7). As opções reais são:
456
+ - (a) a Grand Brasil leva o painel e o FAB para o lado oposto ao do Drawer, lendo
457
+ `data-modal-side`;
458
+ - (b) a Grand Brasil encolhe o painel enquanto houver modal aberto;
459
+ - (c) o Opus reserva a área da camada e desloca ou estreita o modal para desviar dela;
460
+ - (d) aceitar a sobreposição, e a pessoa fecha o painel quando precisar ver.
461
+
462
+ *Decisão:* (a) para Drawers, porque resolve o caso mais comum sem tocar no Opus além do
463
+ sinal publicado. Numa tela de `64rem` com um Drawer de `56rem`, ainda sobra sobreposição; aí o
464
+ painel encolhe, combinando com (b). Para Dialogs centralizados, (d), porque a sobreposição só
465
+ aparece em telas estreitas. A opção (c) fica para quando houver um segundo consumidor.
466
+ 6. **Teste de foco em navegador real dentro do pacote.** Os testes de UI do Opus rodam em
467
+ `happy-dom` 15.11.7, que não reproduz foco e Tab com fidelidade, e o pacote não tem harness de
468
+ navegador. *Decisão:* adicionar o modo navegador do Vitest com Playwright (Chromium) só para
469
+ os testes de foco desta camada, rodando no gate de release. É uma dependência de desenvolvimento
470
+ nova e um navegador baixado na máquina. A alternativa é um roteiro manual a cada mudança, que é
471
+ mais barato e mais fácil de esquecer.
472
+ *Nota de implementação (2026-10-03):* o harness é `vitest.browser.config.ts`, executado por
473
+ `pnpm test:browser` e pelo gate do `release.sh`, fora do `pnpm test`. O `playwright` é fixado
474
+ numa versão exata, porque cada versão aponta para uma revisão do Chromium. O harness também
475
+ compila o tema com `tailwindcss` e `@tailwindcss/vite`, devDependencies novas: o véu, o
476
+ hospedeiro e os `pointer-events` dos filhos só se comportam como num app com o CSS real.
477
+ 7. **O Toaster adota a camada?** As ações dos avisos parecem não responder a clique com um modal
478
+ aberto. *Decisão:* sim, numa entrega separada, depois de confirmar o defeito no navegador e
479
+ de a camada estar publicada.
480
+
481
+ ## Consequências
482
+
483
+ - Os patterns que usam `DialogContent` e `DrawerContent` passam a conviver com a camada sem mudança,
484
+ porque a regra mora nesses dois componentes.
485
+ - Sem nenhuma `FloatingLayer` montada, o comportamento dos modais é o mesmo de hoje. A mudança é
486
+ aditiva e sai numa versão menor.
487
+ - O custo está concentrado no foco e na pertença decidida no aperto do ponteiro. As exceções de
488
+ ponteiro e empilhamento são pequenas; rolagem e leitor de tela dependem de premissas que os testes
489
+ travam.
490
+ - A API é reversível no sentido que importa: a materialização da seção 8 pode ser trocada pela de
491
+ reserva sem que o consumidor perceba. Remover a `FloatingLayer` depois seria uma mudança major.
492
+ - A Grand Brasil, único consumidor hoje, adota assim:
493
+ - envolve o FAB e o painel do Copilot em `FloatingLayer` e passa a posicioná-los contra a
494
+ viewport, porque deixam de ficar dentro da moldura do shell;
495
+ - revisa `useDismissOnOutsidePointer`, que decide “fora” pelo `#root`; o painel sai do `#root`,
496
+ e o clique no véu ou no modal continua sem fechar o painel;
497
+ - move para o `onEscapeKeyDown` da camada qualquer tratamento de Esc do painel, por exemplo
498
+ fechar quando não houver texto digitado;
499
+ - reposiciona o painel e o FAB conforme `data-modal` e `data-modal-side` (decisão 5 da seção 9).
500
+ - Perguntar “sobre o que está na tela” também depende de o contexto enviado ao Copilot incluir o
501
+ modal aberto. Hoje o contexto vem da rota. Isso é da Grand Brasil e não bloqueia esta ADR.
502
+ - O Toaster tem o mesmo problema e pode adotar o mesmo mecanismo depois (decisão 7 da seção 9).
503
+ - A documentação ganha `floating-layer.md`, com o caso do Copilot, e as páginas de Dialog e Drawer
504
+ passam a mencionar a convivência.
505
+
506
+ ## Alternativas consideradas
507
+
508
+ ### Manter o estado atual
509
+
510
+ Não exige trabalho, mas contraria a decisão de produto: o Copilot fica inalcançável sempre que um
511
+ modal está aberto.
512
+
513
+ ### Fechar o modal ao acionar o FAB
514
+
515
+ Simples e sem risco de acessibilidade, mas descartada pelo produto: perde o que a pessoa preencheu.
516
+
517
+ ### Levar o Copilot para dentro do modal
518
+
519
+ O Opus oferece `SurfaceAssistant`, que divide a própria superfície entre conteúdo e assistência,
520
+ e a `Presentation` o usa quando declara assistência. A ADR 0087 da Grand Brasil usa esse mecanismo
521
+ para as conversas sobre um recurso e mantém o Copilot global separado delas, de propósito. Levar o
522
+ Copilot global para dentro de cada modal exigiria espaço em todo Dialog e Drawer, inclusive
523
+ confirmações e formulários estreitos, e desfaria essa separação.
524
+
525
+ ### `modal={false}` em cada consumidor, com reimplementação local
526
+
527
+ Cada tela ou pattern desligaria o modo modal e refaria trava, véu, rolagem e `aria-hidden`. A
528
+ técnica é a mesma da materialização de reserva, mas espalhada: cada cópia poderia divergir, e os
529
+ patterns precisariam de uma opção nova. Descartada em favor de concentrar a regra no Opus.
530
+
531
+ ### Peças internas do Radix atrás de uma API do Opus
532
+
533
+ `DismissableLayer.Branch` e `FocusScope` aninhado, importados de `radix-ui/internal`. O `Branch`
534
+ resolve só o clique e o foco “fora”, que os handlers públicos já resolvem. O `FocusScope` aninhado
535
+ deixaria o modal sem trava e sem a volta do Tab enquanto a camada estivesse montada. Ponteiro,
536
+ rolagem e leitor de tela continuariam sem solução, e o pacote passaria a depender de um módulo que o
537
+ Radix não documenta.
538
+
539
+ ### A top layer do navegador
540
+
541
+ Com `popover="manual"`, o navegador põe a camada acima de qualquer `z-index`. Isso resolve só o
542
+ empilhamento: foco, ponteiro, rolagem e leitor de tela continuam bloqueados pelo modal. Além disso,
543
+ nada do Opus conseguiria ficar acima da camada, o que impede a decisão 4 da seção 9.
544
+
545
+ ### Remover à mão o `aria-hidden` do hospedeiro
546
+
547
+ Depois que o Radix esconde o resto, o Opus tiraria o atributo do hospedeiro. Não resiste ao contador
548
+ do `hideOthers`: um menu modal aberto sobre o Dialog grava o atributo de novo e, ao fechar, não o
549
+ remove, porque o Dialog ainda conta; a camada fica escondida até o Dialog fechar. Também não cobre
550
+ as regiões `aria-live` de dentro do painel, que fazem o `hideOthers` esconder partes dele.
551
+
552
+ ### Isentar cada evento consultando o DOM no fim do clique
553
+
554
+ É o uso direto de `onInteractOutside`. Falha quando o elemento clicado some no próprio clique, como
555
+ o FAB (seção 8).
556
+
557
+ ## Premissas verificadas
558
+
559
+ Conferidas em `@radix-ui/react-dialog` 1.1.19 e 1.1.23, `@radix-ui/react-dismissable-layer` 1.1.15
560
+ e 1.1.19, `@radix-ui/react-focus-scope` 1.1.12 e 1.1.16, `@radix-ui/react-menu` 2.1.20, `radix-ui`
561
+ 1.6.2 e 1.6.7, `aria-hidden` 1.2.6, `react-remove-scroll` 2.7.2, `react-dom` 19.2.7 e `sonner`
562
+ 1.7.4. Onde há duas versões, a menor é a do lockfile do Opus e a maior, a da Grand Brasil; o
563
+ comportamento descrito é o mesmo nas duas. Cada pacote do Radix tem uma única cópia instalada em
564
+ cada repositório, o que mantém uma só pilha de escopos e um só contexto de `DismissableLayer`.
565
+
566
+ - `Dialog` modal: `hideOthers(content)`; `trapFocus` e `disableOutsidePointerEvents` ligados
567
+ enquanto aberto; `onFocusOutside` sempre cancelado; clique com botão direito não fecha;
568
+ `deferPointerDownOutside` ligado; o `Overlay` envolve o `RemoveScroll` com `shards` igual ao
569
+ conteúdo; nenhum `aria-modal`. No modo não modal, o `Overlay` não é renderizado.
570
+ - `AlertDialog`: força `modal: true` e cancela clique fora.
571
+ - `DropdownMenu` e `Menu`: `modal` é `true` por padrão, e o conteúdo chama `hideOthers`.
572
+ - `DismissableLayer`: grava `pointer-events: none` no `body`; o Esc é ouvido no documento, em
573
+ captura, só pelo `DismissableLayer` mais recente; com `deferPointerDownOutside`, o fechamento por
574
+ clique fora roda num ouvinte `click` de uso único no documento e recebe o evento do aperto;
575
+ clique e foco dentro de um `Branch` não contam como fora. O `Tooltip` também é um
576
+ `DismissableLayer`.
577
+ - `FocusScope`: a trava ouve `focusin` e `focusout` no documento, em borbulha; montar um escopo
578
+ pausa o anterior, e só a desmontagem o retoma; com o escopo pausado, a volta do Tab também para.
579
+ - `hideOthers`: aceita uma lista de elementos a manter; a cada chamada, junta a ela todo
580
+ `[aria-live]` e `script` do `body`, preserva o caminho até eles e não desce para dentro deles;
581
+ controla o atributo por contador e só o remove quando o contador volta a zero.
582
+ - `react-remove-scroll`: ouve `wheel` e `touchmove` no documento, em borbulha; só a trava mais
583
+ recente age.
584
+ - `react-dom` 19: registra os ouvintes de eventos no contêiner de cada portal; atualizações de
585
+ eventos discretos são aplicadas numa microtarefa.
586
+ - `Toaster`: `section` com `aria-live="polite"` e `z-index: 999999999`; nenhuma regra de
587
+ `pointer-events: auto`.
588
+
589
+ ## Verificação
590
+
591
+ - **Testes do pacote (`happy-dom`):**
592
+ - com Dialog, Drawer, `ActionFormDialog`, `ActionListDialog`, `Presentation` e `CommandDialog`
593
+ abertos, clicar na camada não fecha o modal, e clicar no véu fecha;
594
+ - clicar no FAB que se desmonta no próprio clique não fecha o modal;
595
+ - fechar o painel pelo botão de fechar dele não fecha o modal;
596
+ - escolher um item de um popover da camada, que fecha no clique, não fecha o modal;
597
+
598
+ Nesses três testes, o fixture desmonta o elemento clicado de forma síncrona, dentro do próprio
599
+ handler (`flushSync(() => setOpen(true))` no `onClick` do FAB fictício). Num evento despachado
600
+ por script, como o do `fireEvent` ou do `user-event`, não há microtarefa entre os ouvintes, e
601
+ com `act` o React só aplica a mudança no fim. Sem o `flushSync`, uma implementação que consulta
602
+ o DOM no fim do clique passaria no teste. Os três cenários se repetem no navegador real;
603
+ - Esc com o foco na camada, ou depois de clicar numa área sem foco do painel, não fecha o modal e
604
+ chama `onEscapeKeyDown` uma única vez; com o foco num campo do modal, fecha;
605
+ - sem modal aberto, clicar no texto de uma resposta e apertar Esc chama `onEscapeKeyDown` uma
606
+ única vez;
607
+
608
+ Nos testes de clique em área sem foco, o clique é simulado com `user-event`, que leva o foco ao
609
+ `body`, ou o fixture tira o foco explicitamente. Um `fireEvent` de ponteiro não move o foco no
610
+ `happy-dom`, e o foco ficaria no campo do modal;
611
+ - o `onBlur` de um campo do modal dispara quando o foco vai para a camada;
612
+ - o hospedeiro não tem `aria-hidden` com um modal de trabalho aberto, e o resto da página tem;
613
+ - o hospedeiro continua visível ao leitor de tela depois que um `Menu` abre e fecha sobre um
614
+ Dialog;
615
+ - com um `Ask` aberto no painel no momento em que o modal abre, nenhuma parte do painel recebe
616
+ `aria-hidden`;
617
+ - com `mode="alert"` ou uma confirmação do `DialogHost` aberta e depois fechada sobre um Dialog,
618
+ a camada fica escondida e abaixo do véu durante o alerta e volta a ficar visível depois
619
+ (decisão 4 da seção 9);
620
+ - uma confirmação do `DialogHost` cuja resposta abre um Dialog em seguida: com o Dialog aberto, a
621
+ camada volta a ficar visível ao leitor de tela;
622
+ - um Dialog aberto de dentro da camada prende o foco, fecha pelo véu e pelo Esc, e não chama o
623
+ `onEscapeKeyDown` da camada;
624
+ - com um alerta no topo, um foco programático na camada é devolvido ao alerta;
625
+ - roda do mouse dentro da camada não é cancelada; fora dela, continua cancelada;
626
+ - Popover e Tooltip abertos dentro da camada são portados para o hospedeiro;
627
+ - `data-modal` e `data-modal-side` acompanham abertura e fechamento, inclusive com modais
628
+ empilhados;
629
+ - sem `FloatingLayer`, os testes atuais de Dialog, Drawer e patterns continuam passando sem
630
+ alteração.
631
+ - **Testes de premissa**, comportamentais, no próprio `happy-dom`: um por premissa que a
632
+ materialização usa. A trava do Radix devolve o foco quando o `focusin` chega ao documento e não
633
+ o devolve quando ele é parado no `body`; o `react-remove-scroll` cancela a roda que chega ao
634
+ documento; o `hideOthers` preserva `[aria-live]` em chamadas repetidas; o fechamento por clique
635
+ fora recebe o evento do aperto; o conteúdo do modal não tem `aria-modal`; há uma cópia única dos
636
+ pacotes do Radix. Se uma atualização mudar alguma delas, o teste aponta qual, antes de a
637
+ convivência quebrar em silêncio.
638
+ - **Navegador real** (no harness da decisão 6 da seção 9, ou num roteiro manual executado por quem
639
+ implementa, com o resultado registrado na descrição da entrega), em Chrome, Safari e Firefox:
640
+ - abrir um `ActionFormDialog`, digitar, abrir o Copilot, perguntar, fechar o painel e encontrar o
641
+ texto intacto, com o foco de volta no campo;
642
+ - clicar na caixa de mensagem com o modal aberto e digitar sem o foco voltar ao modal;
643
+ - clicar no FAB, no botão de fechar o painel e num item de popover do painel sem que o modal
644
+ feche;
645
+ - Tab e Shift+Tab percorrendo o modal e a camada conforme a decisão 2 da seção 9, sem nunca parar
646
+ na página escondida;
647
+ - Esc nos dois lados, com e sem popover aberto, e depois de clicar no texto de uma resposta;
648
+ - VoiceOver no macOS anunciando o modal e a camada, e não a página atrás; as regiões vivas do
649
+ painel, como a resposta em streaming e o `Ask`, continuam anunciadas, e a camada não anuncia
650
+ mudanças por conta própria;
651
+ - Drawer sobre Drawer, Dialog sobre Drawer e confirmação do `DialogHost` sobre Drawer.
652
+
653
+ Leitores de tela do Windows, como o NVDA com o Chrome, ficam fora desta verificação por falta de
654
+ ambiente. É um limite declarado, não uma garantia.
655
+
656
+ *Nota (2026-10-03):* a implementação no pacote cobre Chrome pelo harness da decisão 6. Safari,
657
+ Firefox e VoiceOver não foram verificados: a estação onde o pacote foi construído não tem esses
658
+ navegadores. O roteiro manual acima fica para a adoção na Grand Brasil, executado no Mac do João
659
+ (Safari com VoiceOver) e no Firefox antes de a adoção chegar à `main` da Grand Brasil, com o
660
+ resultado registrado na entrega da adoção.
661
+ - **Arquitetura:**
662
+ - todo uso de portal em `src/ui` lê o contêiner da camada;
663
+ - só `modal-layers.ts`, `dialog.tsx`, `drawer.tsx` e `floating-layer.tsx` aplicam as exceções;
664
+ nenhum pattern repassa handlers para contornar a regra;
665
+ - o teste de API pública passa a exigir `FloatingLayer`;
666
+ - o teste de numeração de ADRs continua passando.
667
+ - **Na Grand Brasil:** um teste do shell confirma que o FAB e o painel ficam dentro de
668
+ `[data-slot="floating-layer"]`, e o roteiro de navegador acima roda na pilha de desenvolvimento.