@softize/opus 25.3.0 → 25.3.1

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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,27 @@ Depois de qualquer bump, rode os gates (`typecheck` · `test` · `opus check` ·
7
7
  `opus copy --check` · `base copy check` · `manifest:check`) — eles apontam o que a
8
8
  mudança cobra do seu código.
9
9
 
10
+ ## 25.3.1 — 2026-10-05
11
+
12
+ Com um `Dialog` ou `Drawer` aberto e a pessoa numa `FloatingLayer` sem `onEscapeKeyDown`, o Esc
13
+ fecha o modal. Antes, o Opus impedia o modal de fechar e chamava um handler que não existia: quem
14
+ chegava ao botão do assistente pelo Tab e apertava Esc não via efeito nenhum. Com o handler, nada
15
+ muda: a camada recebe a tecla e o modal continua aberto. Para que o Esc no botão feche o modal,
16
+ passe `onEscapeKeyDown` só enquanto a camada tiver o que fechar, como com o painel aberto, e
17
+ `undefined` no restante do tempo. A decisão está numa nota da seção 5 da ADR 0052.
18
+
19
+ `assistantContextLeaves`, de `@softize/opus/assistant-schema`, passa a listar em `open` dois pontos
20
+ por onde qualquer campo chega ao modelo e que antes ficavam de fora: o `$ref` que não resolve dentro
21
+ do schema, que sumia do resultado, e o objeto que aceita chaves não declaradas
22
+ (`additionalProperties: true`, do `.passthrough()` do zod), que aparece como `<caminho>.*`. A forma
23
+ do resultado e a conferência do `opus gen` não mudam. Um teste de campos pessoais que reprova pontos
24
+ abertos pode passar a acusar esses casos; reprove-os ou justifique cada um.
25
+
26
+ A ADR 0052 e a página da `FloatingLayer` registram que o roteiro manual em Safari com VoiceOver e no
27
+ Firefox não foi executado: na adoção pela Grand Brasil, o dono do produto o dispensou. A evidência
28
+ é a dos testes de navegador no Chromium real, e o roteiro volta a ser exigido se surgir problema de
29
+ leitor de tela, de Safari ou de Firefox.
30
+
10
31
  ## 25.3.0 — 2026-10-05
11
32
 
12
33
  `ListAction.grouping` declara dimensões fechadas pelas quais a `ActionList` pode agrupar a tabela.
@@ -13,8 +13,9 @@ export interface AssistantContextLeaves {
13
13
  /** Os campos folha alcançados, com ponto e sem índice de lista; `*` marca a chave de um mapa. */
14
14
  leaves: string[]
15
15
  /**
16
- * Pontos em que o schema não declara forma ou volta a si mesmo por `$ref`: qualquer campo abaixo
17
- * deles pode ir ao modelo, e a conferência não alcança.
16
+ * Pontos por onde qualquer campo pode ir ao modelo sem que a conferência alcance: o schema que não
17
+ * declara forma, o `$ref` que volta a si mesmo ou que não resolve, e, como `<ponto>.*`, o objeto
18
+ * que aceita chaves não declaradas (`additionalProperties: true`, do `.passthrough()` do zod).
18
19
  */
19
20
  open: string[]
20
21
  }
@@ -45,18 +45,26 @@ function isNullOnly(schema) {
45
45
  const NO_REFS = new Set();
46
46
  /** Marca o ponto em que um `$ref` volta a um schema que já está no caminho atual. */
47
47
  const CYCLE = Object.freeze({ cycle: true });
48
+ /** Marca o ponto em que um `$ref` não resolve dentro do schema: não há forma a conferir ali. */
49
+ const UNRESOLVED = Object.freeze({ unresolved: true });
50
+
51
+ /** Um dos marcadores de `expand`, e não um schema de verdade. */
52
+ function isMarker(schema) {
53
+ return schema === CYCLE || schema === UNRESOLVED;
54
+ }
48
55
 
49
56
  /**
50
57
  * Os ramos concretos e não nulos de um schema, com `$ref` e combinadores expandidos. Cada ramo traz
51
58
  * os `$ref` percorridos no caminho atual; um `$ref` que se repete ali vira `CYCLE`, em vez de
52
- * expandir de novo.
59
+ * expandir de novo, e um `$ref` que não resolve vira `UNRESOLVED`, em vez de sumir.
53
60
  */
54
61
  function expand(schema, root, active = NO_REFS, depth = 0) {
55
62
  if (schema === null || typeof schema !== "object" || depth > MAX_DEPTH) return [];
56
63
  if (typeof schema.$ref === "string") {
57
64
  if (active.has(schema.$ref)) return [{ schema: CYCLE, active }];
58
65
  const target = resolveRef(root, schema.$ref);
59
- return target === undefined ? [] : expand(target, root, new Set([...active, schema.$ref]), depth + 1);
66
+ if (target === undefined) return [{ schema: UNRESOLVED, active }];
67
+ return expand(target, root, new Set([...active, schema.$ref]), depth + 1);
60
68
  }
61
69
  const nested = COMBINATORS.flatMap((key) =>
62
70
  Array.isArray(schema[key]) ? schema[key].flatMap((branch) => expand(branch, root, active, depth + 1)) : [],
@@ -68,9 +76,9 @@ function expand(schema, root, active = NO_REFS, depth = 0) {
68
76
  return [{ schema, active }, ...nested];
69
77
  }
70
78
 
71
- /** Os ramos sem os ciclos, para quem só olha o tipo do próprio nó. */
79
+ /** Os ramos sem os marcadores, para quem só olha o tipo do próprio nó. */
72
80
  function branches(schema, root) {
73
- return expand(schema, root).flatMap(({ schema: branch }) => (branch === CYCLE ? [] : [branch]));
81
+ return expand(schema, root).flatMap(({ schema: branch }) => (isMarker(branch) ? [] : [branch]));
74
82
  }
75
83
 
76
84
  function isOpen(schema) {
@@ -89,7 +97,9 @@ function isArraySchema(schema) {
89
97
  * Os schemas alcançados por `segments` em todos os ramos não nulos. Um schema sem forma alcança
90
98
  * qualquer caminho abaixo dele e entra como está. Com `throughLists`, uma lista é atravessada. Um
91
99
  * ciclo de `$ref` sem avançar no caminho (por lista ou união) não leva a lugar nenhum; ao descer
92
- * por um campo, o caminho avança, e o mesmo `$ref` pode voltar, como numa árvore.
100
+ * por um campo, o caminho avança, e o mesmo `$ref` pode voltar, como numa árvore. Um `$ref` que não
101
+ * resolve antes do fim do caminho entra como `UNRESOLVED`: quem confere o caminho o descarta, e
102
+ * quem lista as folhas o marca como aberto.
93
103
  */
94
104
  function collectTargets(schema, segments, root, throughLists, out, active = NO_REFS, depth = 0) {
95
105
  if (depth > MAX_DEPTH) return;
@@ -99,7 +109,7 @@ function collectTargets(schema, segments, root, throughLists, out, active = NO_R
99
109
  }
100
110
  for (const { schema: branch, active: refs } of expand(schema, root, active)) {
101
111
  if (branch === CYCLE) continue;
102
- if (isOpen(branch)) {
112
+ if (branch === UNRESOLVED || isOpen(branch)) {
103
113
  out.push(branch);
104
114
  continue;
105
115
  }
@@ -127,7 +137,7 @@ function collectTargets(schema, segments, root, throughLists, out, active = NO_R
127
137
  export function schemaHasPath(schema, segments, root = schema) {
128
138
  const targets = [];
129
139
  collectTargets(schema, segments, root, true, targets);
130
- return targets.length > 0;
140
+ return targets.some((target) => target !== UNRESOLVED);
131
141
  }
132
142
 
133
143
  const TITLE_TYPES = new Set(["string", "number", "integer"]);
@@ -139,19 +149,24 @@ const TITLE_TYPES = new Set(["string", "number", "integer"]);
139
149
  export function schemaTitleIsText(schema, segments, root = schema) {
140
150
  const targets = [];
141
151
  collectTargets(schema, segments, root, false, targets);
142
- return targets.some((target) =>
143
- isOpen(target) ||
144
- branches(target, root).some((branch) => {
145
- if (isOpen(branch)) return true;
146
- const types = Array.isArray(branch.type) ? branch.type : [branch.type];
147
- return types.some((type) => TITLE_TYPES.has(type));
148
- }),
152
+ return targets.some(
153
+ (target) =>
154
+ target !== UNRESOLVED &&
155
+ (isOpen(target) ||
156
+ branches(target, root).some((branch) => {
157
+ if (isOpen(branch)) return true;
158
+ const types = Array.isArray(branch.type) ? branch.type : [branch.type];
159
+ return types.some((type) => TITLE_TYPES.has(type));
160
+ })),
149
161
  );
150
162
  }
151
163
 
152
164
  /**
153
- * As folhas abaixo de `schema`. Um `$ref` que volta a um schema já no caminho atual (uma estrutura
154
- * recursiva) entra em `open` naquele ponto: abaixo dele, qualquer profundidade pode chegar ao modelo.
165
+ * As folhas abaixo de `schema`. Entram em `open`, no ponto em que aparecem, o schema sem forma, o
166
+ * `$ref` que volta a um schema já no caminho atual (uma estrutura recursiva) e o `$ref` que não
167
+ * resolve: abaixo deles, qualquer campo pode chegar ao modelo. Um objeto que aceita chaves não
168
+ * declaradas (`additionalProperties: true`, como o `.passthrough()` do zod) entra como `<ponto>.*`,
169
+ * a mesma marca da chave de um mapa, porque a projeção copia o objeto inteiro.
155
170
  */
156
171
  function collectLeaves(schema, prefix, root, leaves, open, active = NO_REFS, depth = 0) {
157
172
  if (depth > MAX_DEPTH) {
@@ -159,7 +174,7 @@ function collectLeaves(schema, prefix, root, leaves, open, active = NO_REFS, dep
159
174
  return;
160
175
  }
161
176
  for (const { schema: branch, active: refs } of expand(schema, root, active)) {
162
- if (branch === CYCLE || isOpen(branch)) {
177
+ if (isMarker(branch) || isOpen(branch)) {
163
178
  open.add(prefix);
164
179
  continue;
165
180
  }
@@ -176,10 +191,13 @@ function collectLeaves(schema, prefix, root, leaves, open, active = NO_REFS, dep
176
191
  }
177
192
  }
178
193
  const additional = branch.additionalProperties;
179
- if (additional !== null && typeof additional === "object") {
194
+ const hasAdditional = additional === true || (additional !== null && typeof additional === "object");
195
+ if (additional === true) {
196
+ open.add(`${prefix}.*`);
197
+ } else if (hasAdditional) {
180
198
  collectLeaves(additional, `${prefix}.*`, root, leaves, open, refs, depth + 1);
181
199
  }
182
- if (!hasProperties && !(additional !== null && typeof additional === "object")) leaves.add(prefix);
200
+ if (!hasProperties && !hasAdditional) leaves.add(prefix);
183
201
  }
184
202
  }
185
203
 
@@ -190,8 +208,10 @@ function collectLeaves(schema, prefix, root, leaves, open, active = NO_REFS, dep
190
208
  * ao modelo por um caminho que termina num contêiner.
191
209
  *
192
210
  * - `leaves`: caminhos das folhas, com ponto e sem índice de lista; `*` marca a chave de um mapa;
193
- * - `open`: pontos em que o schema não declara forma (`z.unknown()`, `z.any()`) ou volta a si mesmo
194
- * por `$ref`: qualquer campo abaixo deles pode ir ao modelo, e a conferência não alcança.
211
+ * - `open`: pontos por onde qualquer campo pode ir ao modelo sem que a conferência alcance: o schema
212
+ * que não declara forma (`z.unknown()`, `z.any()`), o `$ref` que volta a si mesmo ou que não
213
+ * resolve, e, como `<ponto>.*`, o objeto que aceita chaves não declaradas
214
+ * (`additionalProperties: true`, do `.passthrough()` do zod).
195
215
  */
196
216
  export function assistantContextLeaves(output, context, root = output) {
197
217
  return context.map((path) => {
@@ -225,6 +225,17 @@ camada uma vez. No modal, o Esc fecha o modal, como hoje. A alternativa de fecha
225
225
  está visualmente por cima foi descartada: quem preenche o modal e aperta Esc quer sair do modal, não
226
226
  do painel que nem está usando.
227
227
 
228
+ *Nota (2026-10-05):* a regra vale para a camada que tem o que fechar. Uma camada sem
229
+ `onEscapeKeyDown` não engole o Esc: com a pessoa nela, a tecla segue para o modal de trabalho, que
230
+ fecha como fecharia com a pessoa nele. Antes, o Opus marcava o evento e chamava um handler ausente,
231
+ e nem o modal nem a camada reagiam; quem chegava ao FAB pelo Tab e apertava Esc encontrava uma
232
+ tecla sem efeito. Com o handler, nada muda: a camada recebe a tecla e o modal continua aberto. O
233
+ consumidor que só tem o que fechar com o painel aberto passa o handler nesse estado e `undefined`
234
+ com o FAB, como a Grand Brasil já faz. A revisão também apontou o handler presente que não tem o que
235
+ fechar naquele momento; o Opus não consegue distinguir esse caso, e a forma de dizer “não há nada a
236
+ fechar aqui” é não passar o handler. Os testes do pacote cobrem o FAB focado e o clique numa área
237
+ sem foco da camada, com Dialog e Drawer, e o navegador real cobre o FAB alcançado pelo Tab.
238
+
228
239
  Antes dessa regra vale a do Radix: um `DismissableLayer` aberto depois do modal consome o Esc, onde
229
240
  quer que a pessoa esteja. Isso dá três exceções conhecidas e aceitas:
230
241
 
@@ -658,6 +669,12 @@ cada repositório, o que mantém uma só pilha de escopos e um só contexto de `
658
669
  navegadores. O roteiro manual acima fica para a adoção na Grand Brasil, executado no Mac do João
659
670
  (Safari com VoiceOver) e no Firefox antes de a adoção chegar à `main` da Grand Brasil, com o
660
671
  resultado registrado na entrega da adoção.
672
+
673
+ *Nota (2026-10-05):* o roteiro manual não foi executado. Na adoção pela Grand Brasil, o dono do
674
+ produto decidiu seguir sem ele. A evidência da camada é a dos testes de navegador do pacote, no
675
+ Chromium real, somada aos testes da adoção. Safari, Firefox e VoiceOver continuam sem
676
+ verificação. Se aparecer em uso um problema de leitor de tela, de Safari ou de Firefox com a
677
+ camada sobre um modal, o roteiro acima volta a ser exigido.
661
678
  - **Arquitetura:**
662
679
  - todo uso de portal em `src/ui` lê o contêiner da camada;
663
680
  - só `modal-layers.ts`, `dialog.tsx`, `drawer.tsx` e `floating-layer.tsx` aplicam as exceções;
@@ -666,3 +683,5 @@ cada repositório, o que mantém uma só pilha de escopos e um só contexto de `
666
683
  - o teste de numeração de ADRs continua passando.
667
684
  - **Na Grand Brasil:** um teste do shell confirma que o FAB e o painel ficam dentro de
668
685
  `[data-slot="floating-layer"]`, e o roteiro de navegador acima roda na pilha de desenvolvimento.
686
+ *Nota (2026-10-05):* o roteiro foi dispensado na adoção e não rodou; ver a nota da mesma data no
687
+ item **Navegador real**.
@@ -355,7 +355,14 @@ aberto assim:
355
355
  `assistantContextLeaves(output, context)`: a partir do JSON Schema da saída, como o manifest o
356
356
  traz, devolve por caminho os campos folha alcançados e os pontos sem forma declarada ou em que um
357
357
  `$ref` volta a si mesmo, por onde qualquer campo pode passar. O subpath publica tipos
358
- (`assistant-schema.d.mts`) para um teste em TypeScript estrito.
358
+ (`assistant-schema.d.mts`) para um teste em TypeScript estrito. *Nota (2026-10-05):* dois pontos
359
+ por onde qualquer campo passa ficavam fora de `open`. Um `$ref` que não resolve dentro do schema
360
+ sumia sem aparecer em `leaves` nem em `open`; agora entra em `open` no ponto em que aparece. Um
361
+ objeto que aceita chaves não declaradas (`additionalProperties: true`, que o `.passthrough()` do
362
+ zod produz) era tratado como fechado, embora a projeção copie o objeto inteiro; agora entra em
363
+ `open` como `<caminho>.*`, a mesma marca da chave de um mapa. A conferência do `opus gen` não
364
+ muda: um caminho abaixo de um `$ref` que não resolve continua recusado, e ele não vale como
365
+ `title`.
359
366
 
360
367
  ## Consequências
361
368
 
package/docs/protocol.md CHANGED
@@ -263,7 +263,10 @@ o dado necessário. A action `view` que lê o recurso declara as três coisas em
263
263
  um intermediário `null` aparece como `null`. Num campo anulável ou numa união, o `opus gen` aceita
264
264
  o caminho que existe em algum ramo não nulo. Um caminho que termina num objeto ou numa lista leva
265
265
  tudo abaixo dele (`assistantContextLeaves`, de `@softize/opus/assistant-schema`, lista o que cada
266
- caminho alcança). `title` aponta para texto ou número, fora de lista.
266
+ caminho alcança: em `leaves`, as folhas; em `open`, os pontos por onde qualquer campo passa sem
267
+ conferência, como um schema sem forma, um `$ref` recursivo ou que não resolve e, como
268
+ `<caminho>.*`, um objeto com `additionalProperties: true`). `title` aponta para texto ou número,
269
+ fora de lista.
267
270
  - **Leitura.** `runtime.readResource(resource, id, base, { context? })` executa a action dona com
268
271
  `{ id }` pelo `execute`: autorização, loaders, auditoria, marca de dado pessoal e span são os da
269
272
  action. Devolve `{ ok: true, resource: { kind, id, title, label, skill }, context?, meta }` ou o
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softize/opus",
3
- "version": "25.3.0",
3
+ "version": "25.3.1",
4
4
  "description": "End-to-end action protocol for TypeScript. Single package with subpath exports (core + adapters).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -14,7 +14,9 @@ export interface FloatingLayerProps {
14
14
  /**
15
15
  * Esc enquanto a pessoa está na camada, com ou sem modal aberto. É chamado uma vez por tecla.
16
16
  * Nenhum modal fecha nesse caso, e o Opus não fecha a camada sozinho, porque o estado de aberto
17
- * pertence ao consumidor.
17
+ * pertence ao consumidor. Sem o handler, o Esc com a pessoa na camada fecha o modal de trabalho
18
+ * aberto: passe `undefined` enquanto a camada não tiver o que fechar, como com o painel fechado e
19
+ * só o botão que o abre.
18
20
  */
19
21
  onEscapeKeyDown?: (event: KeyboardEvent) => void;
20
22
  }
@@ -49,9 +51,7 @@ export function FloatingLayer({
49
51
  const element = document.createElement("div");
50
52
  element.setAttribute("data-slot", "floating-layer");
51
53
  document.body.appendChild(element);
52
- const unregister = registerFloatingLayer(element, (event) =>
53
- escapeRef.current?.(event),
54
- );
54
+ const unregister = registerFloatingLayer(element, () => escapeRef.current);
55
55
  setHost(element);
56
56
  // Desfaz o registro, retira o hospedeiro e, se o foco estava nele com um modal aberto,
57
57
  // devolve o foco ao modal.
@@ -225,7 +225,12 @@ caminho que passa por uma lista vale para cada item, e um campo intermediário `
225
225
  endereço, e o nome já vai como `title`. Um caminho que termina num objeto ou numa lista leva tudo o
226
226
  que está abaixo dele, inclusive campos acrescentados depois à saída; para conferir num teste o que
227
227
  cada caminho alcança, use `assistantContextLeaves`, de `@softize/opus/assistant-schema`, sobre o
228
- schema de saída do manifest. O `title` precisa apontar para texto ou número.
228
+ schema de saída do manifest. Ela devolve, por caminho, as folhas em `leaves` e, em `open`, os pontos
229
+ que a conferência não alcança e por onde qualquer campo pode passar: um campo sem forma declarada
230
+ (`z.unknown()`, `z.any()`), uma estrutura recursiva, uma referência que não resolve e, como
231
+ `<caminho>.*`, um objeto que aceita chaves não declaradas (`.passthrough()`). Num teste que reprova
232
+ campo pessoal, trate também `open`: reprove cada ponto aberto ou justifique por que ele não leva dado
233
+ pessoal. O `title` precisa apontar para texto ou número.
229
234
 
230
235
  O `opus check` cobra a forma da declaração (regra `assistant-declaration`), o `opus gen` recusa
231
236
  `title`, caminho de `context` ou entrada que não conferem com os schemas, e o registro recusa duas
@@ -38,7 +38,7 @@ function AssistantDemo() {
38
38
  </DialogContent>
39
39
  </Dialog>
40
40
  {editing && (
41
- <FloatingLayer onEscapeKeyDown={() => setAssistant(false)}>
41
+ <FloatingLayer onEscapeKeyDown={assistant ? () => setAssistant(false) : undefined}>
42
42
  {assistant ? (
43
43
  <aside
44
44
  aria-label="Assistente"
@@ -126,6 +126,11 @@ não recebe foco, nenhum modal fecha e o Opus chama `onEscapeKeyDown` uma vez po
126
126
  no modal, o Esc fecha o modal, como sempre. Sem modal aberto, `onEscapeKeyDown` também é chamado
127
127
  quando a pessoa está na camada.
128
128
 
129
+ Passe `onEscapeKeyDown` só enquanto a camada tiver o que fechar. Sem ele, o Esc com a pessoa na
130
+ camada segue para o modal aberto, que fecha. É o que permite a quem chega ao botão do assistente
131
+ pelo Tab sair do modal com o Esc. No exemplo acima, o handler só existe com o painel aberto: com o
132
+ painel, o Esc fecha o painel; com o botão, fecha o formulário.
133
+
129
134
  Trate o Esc da camada só por `onEscapeKeyDown`. Para impedir que o modal feche, o Opus marca o evento
130
135
  com `preventDefault` antes de ele chegar aos elementos da camada; um `onKeyDown` próprio para Esc no
131
136
  painel trataria a tecla duas vezes ou leria um evento já marcado.
@@ -164,15 +169,17 @@ você.
164
169
  - O hospedeiro carrega `aria-live="off"`, o valor padrão do atributo: a camada não anuncia mudanças
165
170
  por conta própria, e regiões vivas de dentro dela, como a de um [`Ask`](/ui/ask), mantêm o próprio
166
171
  comportamento. O atributo é o que mantém a camada visível ao leitor de tela com um modal aberto.
167
- - O foco e o Tab descritos acima são verificados em Chromium por testes automatizados. Safari,
168
- Firefox e a leitura com VoiceOver ainda não foram verificados: o roteiro manual da ADR 0052 roda
169
- na adoção pela Grand Brasil, em Safari com VoiceOver e no Firefox, antes de ela chegar à versão
170
- principal da plataforma. Leitores de tela do Windows, como o NVDA, ficam fora dessa verificação.
172
+ - O foco, o Tab e o Esc descritos acima são verificados em Chromium por testes automatizados.
173
+ Safari, Firefox e a leitura com VoiceOver não foram verificados. Em 2026-10-05, na adoção pela
174
+ Grand Brasil, o dono do produto dispensou o roteiro manual da ADR 0052 para esses navegadores, e
175
+ ele não foi executado. Se surgir um problema de leitor de tela, de Safari ou de Firefox com a
176
+ camada sobre um modal, o roteiro volta a ser exigido. Leitores de tela do Windows, como o NVDA,
177
+ ficam fora dessa verificação.
171
178
 
172
179
  ## Propriedades de FloatingLayer
173
180
 
174
- | Propriedade | Tipo | Padrão | Descrição |
175
- | ----------------- | -------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
176
- | `children` | `ReactNode` | | Elementos da camada. Cada filho direto recebe clique e se posiciona contra a janela. |
177
- | `className` | `string` | | Classes do hospedeiro. A posição de cada filho continua sendo sua. |
178
- | `onEscapeKeyDown` | `(event: KeyboardEvent) => void` | | Chamado uma vez por tecla quando a pessoa aperta Esc na camada, com ou sem modal aberto. Nenhum modal fecha nesse caso, e o Opus não fecha a camada. |
181
+ | Propriedade | Tipo | Padrão | Descrição |
182
+ | ----------------- | -------------------------------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
183
+ | `children` | `ReactNode` | | Elementos da camada. Cada filho direto recebe clique e se posiciona contra a janela. |
184
+ | `className` | `string` | | Classes do hospedeiro. A posição de cada filho continua sendo sua. |
185
+ | `onEscapeKeyDown` | `(event: KeyboardEvent) => void` | | Chamado uma vez por tecla quando a pessoa aperta Esc na camada, com ou sem modal aberto. Nenhum modal fecha nesse caso, e o Opus não fecha a camada. Sem ele, o Esc na camada fecha o modal aberto. |
@@ -12,7 +12,8 @@ import * as React from "react";
12
12
  * - um único ouvinte no `body`, em borbulha, impede que `focusin`, `focusout`, `wheel` e `touchmove`
13
13
  * ligados ao hospedeiro cheguem ao documento, onde a trava de foco do Radix e a trava de rolagem
14
14
  * os veriam;
15
- * - o Esc age onde a pessoa está: na camada, chama o `onEscapeKeyDown` dela e não fecha o modal;
15
+ * - o Esc age onde a pessoa está: na camada que tem `onEscapeKeyDown`, chama esse handler e não
16
+ * fecha o modal; na camada sem handler, segue para o modal, que fecha como sempre;
16
17
  * - o Tab passa do último elemento do modal para a camada e da camada de volta ao modal;
17
18
  * - quando o elemento focado da camada some, o foco volta ao modal;
18
19
  * - o hospedeiro carrega `aria-live="off"`, que faz o `hideOthers` do Radix preservá-lo.
@@ -37,9 +38,12 @@ interface ModalEntry {
37
38
  content: HTMLElement | null;
38
39
  }
39
40
 
41
+ /** O `onEscapeKeyDown` corrente da camada; `undefined` quando ela não tem o que fechar. */
42
+ export type LayerEscape = () => ((event: KeyboardEvent) => void) | undefined;
43
+
40
44
  interface LayerRecord {
41
45
  host: HTMLElement;
42
- escape: (event: KeyboardEvent) => void;
46
+ escape: LayerEscape;
43
47
  dispose: () => void;
44
48
  }
45
49
 
@@ -328,7 +332,7 @@ function removeGlobalListeners(): void {
328
332
  */
329
333
  export function registerFloatingLayer(
330
334
  host: HTMLElement,
331
- escape: (event: KeyboardEvent) => void,
335
+ escape: LayerEscape,
332
336
  ): () => void {
333
337
  const onPointerDown = (event: PointerEvent): void => {
334
338
  // Pertença decidida no aperto: no fim do clique, o elemento apertado pode já ter sumido.
@@ -354,7 +358,7 @@ export function registerFloatingLayer(
354
358
  const onDocumentKeyDown = (event: KeyboardEvent): void => {
355
359
  // Com um modal de trabalho aberto, a composição do modal já tratou o Esc e o marcou.
356
360
  if (event.key !== "Escape" || event.defaultPrevented) return;
357
- if (layerWherePersonIs() === record) escape(event);
361
+ if (layerWherePersonIs() === record) escape()?.(event);
358
362
  };
359
363
  const observer = new MutationObserver(() => {
360
364
  if (pointerAnchor !== null && pointerAnchor.parentNode !== pointerLayer?.host) {
@@ -424,16 +428,20 @@ export function isLayerInteraction(event: {
424
428
  }
425
429
 
426
430
  /**
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.
431
+ * Esc no modal de trabalho do topo com a pessoa numa camada que tem `onEscapeKeyDown`: nenhum modal
432
+ * fecha e a camada recebe a tecla uma única vez. O `preventDefault` impede o Radix de fechar e marca
433
+ * o evento para o ouvinte da própria camada, que ignora eventos já tratados.
434
+ *
435
+ * Numa camada sem handler, a camada não tem o que fechar, e o Esc segue para o modal (nota de
436
+ * 2026-10-05 na seção 5 da ADR 0052): engolir a tecla deixaria quem chega ao FAB pelo Tab sem
437
+ * nenhum efeito.
430
438
  */
431
439
  export function routeEscapeToLayer(event: KeyboardEvent): boolean {
432
440
  if (!layerExemptionsActive()) return false;
433
- const layer = layerWherePersonIs();
434
- if (layer === null) return false;
441
+ const escape = layerWherePersonIs()?.escape();
442
+ if (escape === undefined) return false;
435
443
  event.preventDefault();
436
- layer.escape(event);
444
+ escape(event);
437
445
  return true;
438
446
  }
439
447