@the-inclusionist/engine 7.0.1 → 8.0.0-rc.2

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 (136) hide show
  1. package/README.md +5 -3
  2. package/app/css/style.css +22 -5
  3. package/app/public/vendor/fonts/fondamento-400-ext.woff2 +0 -0
  4. package/app/public/vendor/fonts/fondamento-400.woff2 +0 -0
  5. package/app/public/vendor/fonts/opendyslexic-400.woff2 +0 -0
  6. package/app/public/vendor/fonts/playwrite-ar.woff2 +0 -0
  7. package/app/public/vendor/fonts/playwrite-br.woff2 +0 -0
  8. package/app/public/vendor/fonts/playwrite-ca.woff2 +0 -0
  9. package/app/public/vendor/fonts/playwrite-cl.woff2 +0 -0
  10. package/app/public/vendor/fonts/playwrite-co.woff2 +0 -0
  11. package/app/public/vendor/fonts/playwrite-mx.woff2 +0 -0
  12. package/app/public/vendor/fonts/playwrite-us-modern.woff2 +0 -0
  13. package/app/public/vendor/fonts/playwrite-us-trad.woff2 +0 -0
  14. package/app/public/vendor/fonts/pressstart-400.woff2 +0 -0
  15. package/app/public/vendor/fonts.css +64 -2
  16. package/dist-pkg/boot/create-game.d.ts +107 -4
  17. package/dist-pkg/boot/create-game.js +337 -18
  18. package/dist-pkg/core/constants.d.ts +0 -28
  19. package/dist-pkg/core/constants.js +34 -17
  20. package/dist-pkg/core/contract.d.ts +99 -0
  21. package/dist-pkg/core/contract.js +78 -0
  22. package/dist-pkg/core/entity.d.ts +47 -4
  23. package/dist-pkg/core/layers.d.ts +18 -0
  24. package/dist-pkg/core/layers.js +18 -0
  25. package/dist-pkg/core/rng.js +14 -4
  26. package/dist-pkg/core/route.d.ts +42 -0
  27. package/dist-pkg/core/route.js +158 -0
  28. package/dist-pkg/core/state.d.ts +10 -1
  29. package/dist-pkg/core/state.js +13 -0
  30. package/dist-pkg/educational/adaptive-engine.d.ts +65 -0
  31. package/dist-pkg/educational/adaptive-engine.js +117 -0
  32. package/dist-pkg/educational/segment-bar.d.ts +96 -0
  33. package/dist-pkg/educational/segment-bar.js +89 -0
  34. package/dist-pkg/i18n/en.js +44 -0
  35. package/dist-pkg/i18n/es.js +44 -0
  36. package/dist-pkg/i18n/pt.js +60 -0
  37. package/dist-pkg/input/default-bindings.d.ts +40 -0
  38. package/dist-pkg/input/default-bindings.js +151 -9
  39. package/dist-pkg/input/gamepad.d.ts +33 -12
  40. package/dist-pkg/input/gamepad.js +103 -16
  41. package/dist-pkg/input/keyboard-runtime.d.ts +6 -8
  42. package/dist-pkg/input/keyboard-runtime.js +24 -5
  43. package/dist-pkg/input/keyboard.d.ts +42 -0
  44. package/dist-pkg/input/keyboard.js +87 -17
  45. package/dist-pkg/input/keydown.d.ts +40 -2
  46. package/dist-pkg/input/keydown.js +29 -6
  47. package/dist-pkg/input/latch-edge.d.ts +28 -0
  48. package/dist-pkg/input/latch-edge.js +45 -0
  49. package/dist-pkg/input/latch-scope.d.ts +70 -0
  50. package/dist-pkg/input/latch-scope.js +110 -0
  51. package/dist-pkg/input/latch-store.d.ts +45 -0
  52. package/dist-pkg/input/latch-store.js +74 -0
  53. package/dist-pkg/input/latch-sync.d.ts +47 -0
  54. package/dist-pkg/input/latch-sync.js +43 -0
  55. package/dist-pkg/input/origem-sintetica.d.ts +44 -0
  56. package/dist-pkg/input/origem-sintetica.js +60 -0
  57. package/dist-pkg/input/pad-defaults.d.ts +22 -0
  58. package/dist-pkg/input/pad-defaults.js +34 -0
  59. package/dist-pkg/input/pointer.d.ts +61 -0
  60. package/dist-pkg/input/pointer.js +71 -0
  61. package/dist-pkg/input/state.d.ts +86 -1
  62. package/dist-pkg/input/state.js +128 -1
  63. package/dist-pkg/input/touch-bindings.d.ts +21 -2
  64. package/dist-pkg/input/touch-bindings.js +15 -3
  65. package/dist-pkg/input/touch.js +9 -1
  66. package/dist-pkg/input/transporte-em-uso.d.ts +101 -0
  67. package/dist-pkg/input/transporte-em-uso.js +130 -0
  68. package/dist-pkg/input/transports.d.ts +88 -2
  69. package/dist-pkg/input/transports.js +60 -5
  70. package/dist-pkg/input/vocabulary-migration.d.ts +18 -7
  71. package/dist-pkg/platform/audio-earcons.d.ts +29 -2
  72. package/dist-pkg/platform/audio-earcons.js +10 -0
  73. package/dist-pkg/platform/audio-nav.d.ts +1 -1
  74. package/dist-pkg/platform/audio-sonar.d.ts +149 -9
  75. package/dist-pkg/platform/audio-sonar.js +232 -21
  76. package/dist-pkg/platform/guide-intensity.d.ts +40 -0
  77. package/dist-pkg/platform/guide-intensity.js +73 -0
  78. package/dist-pkg/platform/pesados-catalogo.d.ts +14 -0
  79. package/dist-pkg/platform/pesados-catalogo.js +177 -0
  80. package/dist-pkg/platform/pesados.d.ts +31 -0
  81. package/dist-pkg/platform/pesados.js +75 -0
  82. package/dist-pkg/platform/storage.d.ts +30 -0
  83. package/dist-pkg/platform/storage.js +35 -0
  84. package/dist-pkg/platform/tts.js +16 -1
  85. package/dist-pkg/platform/voice-plan.d.ts +109 -0
  86. package/dist-pkg/platform/voice-plan.js +156 -0
  87. package/dist-pkg/platform/vozes-prontas.d.ts +28 -0
  88. package/dist-pkg/platform/vozes-prontas.js +61 -0
  89. package/dist-pkg/render/draw.d.ts +1 -1
  90. package/dist-pkg/render/draw.js +15 -5
  91. package/dist-pkg/render/viz-axes.d.ts +17 -0
  92. package/dist-pkg/render/viz-axes.js +19 -0
  93. package/dist-pkg/render/viz-setters.d.ts +34 -2
  94. package/dist-pkg/render/viz-setters.js +189 -33
  95. package/dist-pkg/render/wheelchair-sprites.d.ts +11 -3
  96. package/dist-pkg/render/wheelchair-sprites.js +10 -4
  97. package/dist-pkg/ui/dom.js +20 -2
  98. package/dist-pkg/ui/fonts.d.ts +71 -1
  99. package/dist-pkg/ui/fonts.js +111 -8
  100. package/dist-pkg/ui/latch-refusal.d.ts +32 -0
  101. package/dist-pkg/ui/latch-refusal.js +60 -0
  102. package/dist-pkg/ui/layout.d.ts +32 -0
  103. package/dist-pkg/ui/layout.js +64 -1
  104. package/dist-pkg/ui/motion-scene.d.ts +41 -0
  105. package/dist-pkg/ui/motion-scene.js +76 -0
  106. package/dist-pkg/ui/panel-shell.d.ts +53 -0
  107. package/dist-pkg/ui/panel-shell.js +103 -0
  108. package/dist-pkg/ui/pause-icons.d.ts +168 -20
  109. package/dist-pkg/ui/pause-icons.js +315 -45
  110. package/dist-pkg/ui/reach-notice.js +8 -0
  111. package/dist-pkg/ui/settings-audio.d.ts +14 -1
  112. package/dist-pkg/ui/settings-audio.js +43 -3
  113. package/dist-pkg/ui/settings-controls.d.ts +66 -6
  114. package/dist-pkg/ui/settings-controls.js +179 -17
  115. package/dist-pkg/ui/settings-empathy.d.ts +15 -0
  116. package/dist-pkg/ui/settings-empathy.js +2 -0
  117. package/dist-pkg/ui/settings-motion.d.ts +25 -19
  118. package/dist-pkg/ui/settings-motion.js +32 -19
  119. package/dist-pkg/ui/settings-motor.d.ts +101 -3
  120. package/dist-pkg/ui/settings-motor.js +136 -9
  121. package/dist-pkg/ui/settings-typo.d.ts +49 -5
  122. package/dist-pkg/ui/settings-typo.js +74 -23
  123. package/dist-pkg/ui/settings-visual.d.ts +21 -1
  124. package/dist-pkg/ui/settings-visual.js +48 -4
  125. package/dist-pkg/ui/shell.d.ts +13 -3
  126. package/dist-pkg/ui/shell.js +3 -4
  127. package/dist-pkg/ui/simulation-refusal.d.ts +32 -0
  128. package/dist-pkg/ui/simulation-refusal.js +57 -0
  129. package/dist-pkg/ui/visual-axes-panel.d.ts +48 -0
  130. package/dist-pkg/ui/visual-axes-panel.js +95 -0
  131. package/dist-pkg/ui/webcam.js +6 -1
  132. package/docs/CREDITS.md +22 -1
  133. package/docs/LICENSES.md +170 -132
  134. package/package.json +25 -4
  135. package/app/public/vendor/fonts/greatvibes-400.woff2 +0 -0
  136. package/app/public/vendor/fonts/ufcook-700.woff2 +0 -0
@@ -25,12 +25,24 @@
25
25
  // · `rm`/`saveRM` — the reduced-motion flags object, co-owned with ui/settings-motion (same reference).
26
26
  // · `PM_BTNS`/`QL_NAME` — owned by ui/activities-menu; injected, never copied.
27
27
  import { t } from '../core/i18n.js';
28
- import { CONTRAST_LEVELS, CONTRAST_LABELS } from './settings-visual.js';
28
+ import { CONTRAST_LEVELS } from './settings-visual.js';
29
+ import { CURTO_DO_TEMA, CURTO_DA_CORRECAO } from './visual-axes-panel.js';
30
+ import { proximoTema, proximaCorrecao, temAltoContraste, PADRAO, } from '../render/viz-axes.js';
29
31
  import { anunciarItem } from './item-announcement.js';
30
32
  import { rotuloAcessivel } from '../core/rotulo-acessivel.js';
31
33
  import { passoNoAnel } from '../core/anel.js'; // da FOLHA, e não de ui/menu-nav: ver a nota lá
32
34
  // LIGAÇÃO VIVA (ESM): o índice pode ser desligado no menu, e o valor aqui acompanha sem assinatura.
33
- import { menuIndexOn } from '../core/state.js';
35
+ import { menuIndexOn, DEFAULTS, setModoCegoValue } from '../core/state.js';
36
+ // ⚠️ IMPORT DIRETO DE `platform/storage`, e não uma peça a mais no `ctx`, e a escolha é sobre quem pode
37
+ // esquecer: `initPauseIcons` é chamado pela raiz de composição de CADA jogo, e um `store` injetado é um
38
+ // campo que um consumidor pode omitir — e omiti-lo faria o nível TEA voltar a não persistir, em silêncio,
39
+ // exactamente no jogo que se esqueceu. É a mesma forma que `ui/fonts` usa, e `ui/` depender de `platform/`
40
+ // não inverte camada nenhuma.
41
+ import * as store from '../platform/storage.js';
42
+ import { definirAlternanciaDeMarcha } from './settings-motor.js';
43
+ import { recusaDaAlternancia } from './latch-refusal.js';
44
+ import { PM_BTNS, PM_OPTIONS_BTNS } from './activities-menu.js';
45
+ import { CHAVES_DE_CENA, ANIMACOES_DO_PERSONAGEM, lerCenaGuardada, guardarCena } from './motion-scene.js';
34
46
  /**
35
47
  * A LEGENDA de um ícone da barra de acessibilidade — uma função, e não três cópias da mesma expressão.
36
48
  *
@@ -69,6 +81,17 @@ const ICON_BY_KEY = new Map(PAUSE_ICONS.map((ic) => [ic.k, ic]));
69
81
  export function pauseIcon(k) { return ICON_BY_KEY.get(k); }
70
82
  /** TEA cycle: 0 = normal · 1 = calmo (reduces) · 2 = silencioso (switches off). Never touches TTS/blind mode. */
71
83
  export const CALM_NAMES = ['calm.off', 'calm.quiet', 'calm.silent'];
84
+ /**
85
+ * O nível TEA guardado, saneado. Fora de 0..2 devolve o padrão — dado do navegador é dado de fora, e um
86
+ * nível inventado escolheria `CALM_NAMES[3]`, que é `undefined`, e o anúncio ao leitor de tela sairia vazio.
87
+ */
88
+ export function saneiaNivelTea(bruto) {
89
+ return Number.isInteger(bruto) && bruto >= 0 && bruto < CALM_NAMES.length ? bruto : DEFAULTS.calmMode;
90
+ }
91
+ /** Lê o nível TEA do armazenamento. Chamado no `init`, nunca no import. */
92
+ function lerNivelTea() {
93
+ return saneiaNivelTea(store.getNum(store.KEYS.tea, DEFAULTS.calmMode));
94
+ }
72
95
  /** The audio categories `applyCalm` governs. TTS/sonar/guarda/guia stay untouched — a calm player still needs them. */
73
96
  export const CALM_AUDIO_CATS = ['ambient', 'music', 'earcons', 'other', 'interact'];
74
97
  /** Colour-vision-deficiency cycle, in `player.viz` values. */
@@ -151,9 +174,9 @@ export function computeIconLabel(k, s) {
151
174
  if (k === 'altmove')
152
175
  return rotulo(s.toggleMove ? 'state.on' : 'state.off');
153
176
  if (k === 'contrast')
154
- return rotulo(CONTRAST_LABELS[s.viz] || 'contrast.off');
177
+ return rotulo(CURTO_DO_TEMA[s.visual.tema]);
155
178
  if (k === 'cvd')
156
- return t('icon.state', { nome: t('icon.cvd.short'), v: t(CVD_LABELS[s.viz] || 'cvd.off') });
179
+ return t('icon.state', { nome: t('icon.cvd.short'), v: t(CURTO_DA_CORRECAO[s.visual.correcao]) });
157
180
  return t(ic.n);
158
181
  }
159
182
  /** Pure form of reflectIconBtn's branching. A `soon` icon lands on all-false — it never claims to be on. */
@@ -176,17 +199,17 @@ export function computeIconVisual(k, s) {
176
199
  }
177
200
  else if (k === 'altmove') {
178
201
  on = s.toggleMove;
202
+ dis = !!s.alternanciaExigida;
179
203
  }
180
204
  else if (k === 'contrast') {
181
- on = /^hc-direto/.test(s.viz || '');
205
+ on = temAltoContraste(s.visual);
182
206
  }
183
207
  else if (k === 'cvd') {
184
- if (s.viz === 'fix-protan')
185
- cvd = 'pi-cvd-protan';
186
- else if (s.viz === 'fix-deuter')
187
- cvd = 'pi-cvd-deuter';
188
- else if (s.viz === 'fix-tritan')
189
- cvd = 'pi-cvd-tritan';
208
+ // ⚠️ O FUNDO DE DUAS CORES É O SINAL DE LIGADO deste ícone, e agora ele lê o EIXO da correção — que
209
+ // continua a dizer o mesmo quando o tema também está ligado, coisa que a chave única não conseguia: com
210
+ // `hc-direto-7` no campo, a correção da criança desaparecia do ícone que existe para a mostrar.
211
+ if (s.visual.correcao !== 'tricro')
212
+ cvd = 'pi-cvd-' + s.visual.correcao;
190
213
  }
191
214
  return { on, dis, calm, cvd, active: on || calm || !!cvd };
192
215
  }
@@ -201,9 +224,60 @@ export function iconBtnMarkup(ic) {
201
224
  return '<button class="pi-btn' + (ic.soon ? ' pi-soon' : '') + '" type="button" data-pi="' + ic.k +
202
225
  '" aria-label="' + (ic.soon ? t('icon.soon', { nome: t(ic.n) }) : t(ic.n)) + '">' + ic.e + '</button>';
203
226
  }
227
+ export function iconesQueAccionam(escritores) {
228
+ // ⚠️ POR ÍCONE, e não um booleano para os dois — e foi uma MUTAÇÃO SOBREVIVENTE que o mostrou. Com uma
229
+ // única bandeira, `&&` e `||` produziam o mesmo resultado nos casos que eu tinha escrito, porque todos
230
+ // tiravam os DOIS escritores. O `&&` escondia um ícone que FUNCIONA quando só um escritor falta, e o `||`
231
+ // mostrava um que NÃO funciona. Os dois erram, em direcções opostas, e a pergunta certa nunca foi «este
232
+ // jogo tem escritores visuais» — é «este ÍCONE tem quem o accione».
233
+ // 📌 E o `altmove` entra pela MESMA porta, que é o achado: «este jogo segura teclas?» é a mesma pergunta
234
+ // que «este ícone tem quem o accione», feita a um campo do contrato em vez de a um escritor injectado.
235
+ // Um terceiro ramo, e não uma regra nova.
236
+ return PAUSE_ICONS.filter((ic) => (ic.k === 'contrast' ? escritores.tema
237
+ : ic.k === 'cvd' ? escritores.correcao
238
+ : ic.k === 'altmove' ? escritores.seguraTeclas
239
+ : true));
240
+ }
241
+ /**
242
+ * OS TRÊS ITENS QUE A ENGINE ACCIONA SOZINHA, e que por isso nunca dependem do `getPauseActs` de um jogo.
243
+ *
244
+ * 📏 Lidos do despacho, e não decididos aqui: `options`/`pmback` trocam qual lista está no cartão e
245
+ * `acessibilidade` leva o cursor à barra rápida — os três são tratados neste módulo e voltam antes de a
246
+ * tabela do jogo ser consultada.
247
+ */
248
+ export const ITENS_DA_ENGINE = new Set(['options', 'pmback', 'acessibilidade']);
249
+ /**
250
+ * OS ITENS DO MENU QUE ESTE JOGO CONSEGUE MESMO ACCIONAR (ADR-0106 §5).
251
+ *
252
+ * ⚠️ HOJE UM ITEM SEM ACÇÃO É UM BOTÃO MORTO, E EM SILÊNCIO. O despacho faz `const fn = acts[act]; if (fn)
253
+ * fn();` — quem carrega num item que o jogo não implementou não recebe erro, não recebe anúncio, não recebe
254
+ * nada. Para quem vê, parece que o clique falhou; para quem navega por leitor de tela, o menu leu-lhe um
255
+ * item que não existe. É exactamente o que o §5 chama de pior do que a ausência: «uma barra que oferece um
256
+ * caminho e depois o recusa ensina-lhe que o caminho não é para ela».
257
+ */
258
+ export function itensQueAccionam(botoes, acts) {
259
+ return botoes.filter((b) => ITENS_DA_ENGINE.has(b.act) || typeof acts[b.act] === 'function');
260
+ }
261
+ /**
262
+ * A LISTA RAIZ, com uma regra a mais: `options` é uma PORTA, e uma porta para uma sala vazia também é um
263
+ * botão morto.
264
+ *
265
+ * ⚠️ Esta é a parte que um filtro item-a-item não apanha. Se todos os painéis de ajuste forem filtrados —
266
+ * um jogo que não monta nenhum —, o item `options` sobrevive (a engine acciona-o) e abre uma lista sem nada.
267
+ * A criança atravessa uma porta e fica presa num submenu vazio, cuja única saída é o `pmback` que também
268
+ * sumiu com ele.
269
+ */
270
+ export function raizQueAcciona(raiz, opcoes, acts) {
271
+ const opcoesVivas = itensQueAccionam(opcoes, acts).filter((b) => b.act !== 'pmback');
272
+ const viva = itensQueAccionam(raiz, acts);
273
+ return opcoesVivas.length > 0 ? viva : viva.filter((b) => b.act !== 'options');
274
+ }
204
275
  /** The whole icon bar. Used by the pause screen AND by the splash `#title-icons` (which built the same string
205
- * by hand in game.js — that duplication dies with this export). */
206
- export function iconsMarkup() { return PAUSE_ICONS.map(iconBtnMarkup).join(''); }
276
+ * by hand in game.js — that duplication dies with this export).
277
+ * O parâmetro é ADITIVO e o padrão é a lista inteira: quem já chamava sem argumentos não muda de resultado. */
278
+ export function iconsMarkup(icones = PAUSE_ICONS) {
279
+ return icones.map(iconBtnMarkup).join('');
280
+ }
207
281
  /** One `.pm-btn`. Dynamic labels (`letra`/`nivel`) are rendered eagerly and carry NO `data-i18n`, so
208
282
  * i18n.applyDom() cannot overwrite them. */
209
283
  /**
@@ -268,8 +342,8 @@ export function mostrarSubmenuDaPausa(sp, sub) {
268
342
  * A LEGENDA VIAJA JUNTO. Ela é a dica que substitui, para quem não vê, o `title` que só o mouse revela;
269
343
  * deixá-la no cartão tornaria a barra do HUD muda.
270
344
  */
271
- export function quickBarMarkup() {
272
- return '<div class="pause-icons" role="group" aria-label="' + t('pause.iconBarAria') + '">' + iconsMarkup() +
345
+ export function quickBarMarkup(icones = PAUSE_ICONS) {
346
+ return '<div class="pause-icons" role="group" aria-label="' + t('pause.iconBarAria') + '">' + iconsMarkup(icones) +
273
347
  '</div><p class="pause-icons-cap" aria-live="polite"></p>';
274
348
  }
275
349
  export function acaoNaBarra(k, temStart) {
@@ -298,11 +372,96 @@ export function screenPauseMarkup(o) {
298
372
  // DOM-facing shell
299
373
  // ---------------------------------------------------------------------------------------------
300
374
  export function initPauseIcons(ctx) {
301
- // TEA level. It lives HERE (game.js's `let calmMode` had no other reader) and is deliberately NOT persisted
302
- // — verbatim: game.js never wrote it to storage, even though applyCalm() persists `rm` as a side effect.
303
- let calmMode = 0;
375
+ // ⚠️ O NÍVEL TEA PASSOU A PERSISTIR EM 2026-09-07 (issue #61). Este comentário dizia «deliberately NOT
376
+ // persisted — verbatim: game.js never wrote it to storage», e o **verbatim** é o que o desqualificava como
377
+ // decisão: foi PRESERVADO na extração do monólito, não escolhido. O ADR-0028 diz que todo menu persiste, e
378
+ // este é um controlo de menu que vive na barra rápida.
379
+ //
380
+ // O custo de não persistir era da criança que mais precisa dele: quem usa o modo SILENCIOSO voltava a
381
+ // pô-lo a cada sessão — e é para quem o barulho inesperado custa mais. Um ajuste que se esquece não é um
382
+ // ajuste, é uma tarefa diária.
383
+ //
384
+ // ⚠️ LÊ NO INIT, NUNCA NO IMPORT: a regra vale para todo o projeto e aqui tem custo concreto — um teste que
385
+ // importasse este módulo passaria a depender do `localStorage` do ambiente, e um nível herdado de outro
386
+ // caso é uma falha que aparece longe da causa.
387
+ let calmMode = lerNivelTea();
304
388
  const P = () => ctx.getPlayers();
389
+ /*
390
+ * ⚠️ A ALTERNÂNCIA DE MARCHA PASSOU A TER PADRÃO DA ENGINE (ADR-0106 §4, etapa 1b). Quem injecta continua a
391
+ * mandar; quem não injecta deixa de ficar sem ela — que era o caso dos cinco jogos sem barra.
392
+ *
393
+ * O `store` vem do import directo deste módulo e não do `ctx`, porque é assim que este ficheiro já persiste
394
+ * o resto: uma chave injectada é um campo que um consumidor pode omitir, e omiti-la faria escrever num nome
395
+ * torto.
396
+ */
397
+ const setToggleMove = ctx.setToggleMove
398
+ ?? ((i, on) => definirAlternanciaDeMarcha({
399
+ players: P(), store, srSay: ctx.srSay, getNumPlayers: ctx.getNumPlayers,
400
+ // ⚠️ ATRAVESSA, e não se resolve aqui: o ícone e o painel têm de escrever a MESMA coisa. Resolver
401
+ // o aparelho num deles e não no outro é como duas superfícies da mesma engine passam a discordar.
402
+ transporteEmUso: ctx.transporteEmUso,
403
+ }, i, on));
404
+ /*
405
+ * ⚠️ O MODO CEGO IDEM, e aqui o padrão é literalmente o que o `core/state` já decidiu que um setter faz:
406
+ * «grava, persiste, avisa» — e nada mais. Os efeitos de jogo (refazer os extras do nível) são REACÇÃO, e
407
+ * quem reage assina `on('modoCego', …)`. O anúncio não se perde para quem não injecta: este ícone já diz
408
+ * `sr.icon.blindOn`/`Off` por si, logo abaixo.
409
+ */
410
+ const setModoCego = ctx.setModoCego ?? setModoCegoValue;
411
+ /** O documento onde se constroi. Resolvido a cada uso, e por globalThis — em node o identificador
412
+ * document nem existe, e um ?? sobre ele lançaria ReferenceError em vez de cair no padrão. */
413
+ const docDaMontagem = () => ctx.doc ?? globalThis.document;
414
+ /*
415
+ * ⚠️ O CONTRASTE E A COR SÓ APARECEM SE HOUVER QUEM OS ESCREVA (ADR-0106 §5).
416
+ *
417
+ * 📏 Medido em 2026-09-08, e é o que separa este caso dos outros seis campos «acidentais»: eles eram estado
418
+ * que um cartucho calhou de guardar, e a engine pôde reclamá-los. Estes precisam de um
419
+ * `render/viz-setters`, cujo contexto pede **34 campos** do grafo de render de UM jogo — `parallaxLayers`,
420
+ * `decoSprites`, `getPowerups`, `rebuildCoins`, `worldSprite`. O `createGame` não monta isso, e um quiz não
421
+ * tem nada disso para montar. A engine não pode dar um padrão aqui; o que ela pode é não FINGIR.
422
+ *
423
+ * Decidido uma vez, no arranque, e não a cada montagem de barra: o conjunto de escritores de um consumidor
424
+ * não muda a meio de uma partida, e recalcular por tela faria as telas discordarem entre si.
425
+ */
426
+ const iconesDoJogo = iconesQueAccionam({
427
+ tema: Boolean(ctx.setTemaDoJogador),
428
+ correcao: Boolean(ctx.setCorrecaoDoJogador),
429
+ // 📌 Sem `Boolean(...)`: os dois de cima perguntam «existe escritor?» a um campo opcional; este é uma
430
+ // RESPOSTA que o jogo deu, e envolvê-la faria um `undefined` de um ctx mal montado virar `false` —
431
+ // esconder o controle em silêncio, que é metade do defeito que este campo existe para não cometer.
432
+ seguraTeclas: ctx.seguraTeclas,
433
+ });
434
+ /*
435
+ * ⚠️ RESOLVIDOS UMA VEZ, no arranque, pela mesma razão do `settings-motion`: o `rm` é mutado in-place e
436
+ * partilhado por REFERÊNCIA com quem desenha a cena, e resolvê-lo a cada uso criaria um objecto novo por
437
+ * chamada — o interruptor deixaria de alcançar o desenho, sem erro nenhum.
438
+ *
439
+ * 📌 O `getPauseActs` fica FUNÇÃO e não valor, porque a laziness dele é a razão de ele existir assim: no
440
+ * cartucho a tabela é um `const` declarado ~1200 linhas abaixo, e lê-la aqui cairia na zona morta temporal.
441
+ */
442
+ const rm = ctx.rm ?? lerCenaGuardada();
443
+ const rmKeys = ctx.rmKeys ?? CHAVES_DE_CENA;
444
+ const rmChar = ctx.rmChar ?? ANIMACOES_DO_PERSONAGEM;
445
+ const saveRM = ctx.saveRM ?? (() => guardarCena(rm));
446
+ /*
447
+ * ⚠️ ESTES TRÊS SÃO LIDOS A CADA CHAMADA, e não resolvidos uma vez como o `rm` acima. A diferença é
448
+ * deliberada e um teste apanhou-me a errá-la: congelar `ctx.getPauseActs` no arranque partiu um caso que
449
+ * TROCA a tabela depois do `init` — e trocar depois é legítimo, porque a laziness deste campo existe
450
+ * precisamente por a tabela chegar tarde. Um padrão não pode custar a ligação tardia que o campo tem.
451
+ *
452
+ * O `rm` é o contrário e por isso fica congelado: ali o que importa é a IDENTIDADE do objecto, partilhada
453
+ * por referência com quem desenha a cena.
454
+ *
455
+ * A anotação de tipo é necessária: sem ela o padrão `() => ({})` infere `{}`, que não aceita indexação por
456
+ * string, e o compilador passaria a recusar `acts[act]` — o despacho inteiro.
457
+ */
458
+ const dynLabel = (b) => (ctx.dynLabel ? ctx.dynLabel(b) : null);
459
+ const getPauseActs = () => (ctx.getPauseActs ? ctx.getPauseActs() : {});
460
+ const setPauseActor = (i) => { if (ctx.setPauseActor)
461
+ ctx.setPauseActor(i); };
305
462
  function hasPrivateOutput(i) { return hasPrivateOutputIn(P(), ctx.getNumPlayers(), i); }
463
+ /** A recusa da alternância para este jogador agora, ou `null`. Recalculada: o aparelho em uso muda. */
464
+ function recusaAgora(i) { return ctx.transporteEmUso ? recusaDaAlternancia(ctx.transporteEmUso(i)) : null; }
306
465
  function iconState(i) {
307
466
  const p = P()[i] || {};
308
467
  const cat = ctx.getAudioCat();
@@ -312,19 +471,25 @@ export function initPauseIcons(ctx) {
312
471
  librasOn: ctx.isLibrasOn(),
313
472
  calmMode,
314
473
  toggleMove: !!p.toggleMove,
315
- viz: p.viz || '',
474
+ // ⚠️ `DEFAULTS.viz` E NÃO `''` (issue #61). A cadeia vazia funcionava por ACIDENTE: não casa
475
+ // `hc-direto` nem `fix-*`, então os dois ícones ficavam apagados pelo motivo certo por engano. O padrão
476
+ // passou a ter nome em `core/state`, e `render/viz-modes` já declarava esse modo com `kind:'normal'` —
477
+ // o que não faz nada. Dizer o padrão em vez de o deduzir é o que torna a marca do ADR-0029 possível
478
+ // aqui, porque ela lê `DEFAULTS` e mais nada.
479
+ visual: p.visual ?? PADRAO,
316
480
  privateOutput: hasPrivateOutput(i),
481
+ alternanciaExigida: recusaAgora(i) !== null,
317
482
  };
318
483
  }
319
484
  // --- TEA ---------------------------------------------------------------------------------
320
485
  function applyCalm() {
321
486
  const plan = calmMotionPlan(calmMode);
322
- for (const k of ctx.rmKeys)
323
- ctx.rm[k] = plan.sceneReduced;
324
- ctx.saveRM();
487
+ for (const k of rmKeys)
488
+ rm[k] = plan.sceneReduced;
489
+ saveRM();
325
490
  // "silencioso" freezes the character too. Iterates the WHOLE players array (not just numPlayers) — verbatim.
326
491
  for (const p of P())
327
- for (const c of ctx.rmChar)
492
+ for (const c of rmChar)
328
493
  p[c.prop] = plan.charFrozen;
329
494
  const cat = ctx.getAudioCat();
330
495
  for (const k of CALM_AUDIO_CATS) {
@@ -341,7 +506,7 @@ export function initPauseIcons(ctx) {
341
506
  // --- icon actions (the dispatcher, as a table) ---------------------------------------------
342
507
  const ICON_ACTS = {
343
508
  blind: () => {
344
- ctx.setModoCego(!ctx.getModoCego());
509
+ setModoCego(!ctx.getModoCego());
345
510
  ctx.srSay(t(ctx.getModoCego() ? 'sr.icon.blindOn' : 'sr.icon.blindOff'));
346
511
  },
347
512
  tts: () => {
@@ -360,22 +525,35 @@ export function initPauseIcons(ctx) {
360
525
  },
361
526
  tea: () => {
362
527
  calmMode = nextCalmMode(calmMode);
528
+ store.set(store.KEYS.tea, calmMode); // ADR-0028: todo menu persiste. Ver a nota no `let` acima.
363
529
  applyCalm();
364
530
  ctx.srSay(t('sr.icon.tea', { v: t(CALM_NAMES[calmMode]) }));
365
531
  },
366
532
  altmove: (i) => {
367
533
  // verbatim: `players[i].toggleMove` with no `||{}` guard (unlike contrast/cvd below).
368
- ctx.setToggleMove(i, !P()[i].toggleMove);
534
+ setToggleMove(i, !P()[i].toggleMove);
369
535
  },
536
+ // ⚠️ OS DOIS ÍCONES DEIXARAM DE SE APAGAR UM AO OUTRO (#104). Eles SEMPRE ciclaram dentro do seu eixo —
537
+ // `nextContrast` e `nextCvd` existem separados desde sempre —, mas escreviam os dois no mesmo campo, e
538
+ // por isso mexer num zerava o outro. O snapshot dizia isso como se fosse desenho: «they overwrite each
539
+ // other; that is by design». Agora cada um escreve no seu eixo e o outro fica onde estava.
540
+ // ⚠️ AS DUAS GUARDAS NÃO SÃO CINTO E SUSPENSÓRIOS. Sem escritor, o ícone nem sequer é montado
541
+ // (`iconesDoJogo`), então este ramo não deveria ser alcançável pela barra — mas `iconAct` é EXPORTADO e
542
+ // qualquer consumidor pode chamá-lo por chave. Sem a guarda, essa chamada rebentaria; com ela, não faz
543
+ // nada e não anuncia — que é o mesmo que dizer a verdade: este jogo não tem por onde.
370
544
  contrast: (i) => {
371
- const nx = nextContrast((P()[i] || {}).viz);
372
- ctx.setPlayerViz(i, nx);
373
- ctx.srSay(t('sr.visual.contrast', { v: t(CONTRAST_LABELS[nx] || 'contrast.off') }));
545
+ if (!ctx.setTemaDoJogador)
546
+ return;
547
+ const v = proximoTema((P()[i] || {}).visual ?? PADRAO);
548
+ ctx.setTemaDoJogador(i, v.tema);
549
+ ctx.srSay(t('sr.visual.contrast', { v: t(CURTO_DO_TEMA[v.tema]) }));
374
550
  },
375
551
  cvd: (i) => {
376
- const nx = nextCvd((P()[i] || {}).viz);
377
- ctx.setPlayerViz(i, nx.mode);
378
- ctx.srSay(t('sr.icon.cvd', { v: t(CVD_NAMES[nx.idx]) }));
552
+ if (!ctx.setCorrecaoDoJogador)
553
+ return;
554
+ const v = proximaCorrecao((P()[i] || {}).visual ?? PADRAO);
555
+ ctx.setCorrecaoDoJogador(i, v.correcao);
556
+ ctx.srSay(t('sr.icon.cvd', { v: t(CURTO_DA_CORRECAO[v.correcao]) }));
379
557
  },
380
558
  };
381
559
  function iconAct(k, i) {
@@ -388,6 +566,17 @@ export function initPauseIcons(ctx) {
388
566
  ctx.srAlert(t('sr.icon.needsPrivateOutput'));
389
567
  return;
390
568
  }
569
+ // ⚠️ A MESMA RECUSA DO PAINEL, na mesma forma que as duas acima: DIZER e voltar. Aceitar o clique e
570
+ // ignorá-lo é a outra metade do que o ADR-0076 proíbe, e aqui há um agravante — este ícone e o
571
+ // `#opt-altmove` escrevem o MESMO valor, logo um a aceitar enquanto o outro recusa daria à criança dois
572
+ // botões que discordam sobre o mesmo ajuste.
573
+ if (k === 'altmove') {
574
+ const recusa = recusaAgora(i);
575
+ if (recusa) {
576
+ ctx.srAlert(t(recusa.chave));
577
+ return;
578
+ }
579
+ }
391
580
  const act = ICON_ACTS[k];
392
581
  if (act)
393
582
  act(i);
@@ -405,10 +594,27 @@ export function initPauseIcons(ctx) {
405
594
  b.classList.add(v.cvd);
406
595
  b.classList.toggle('pi-on', v.on);
407
596
  b.classList.toggle('pi-dis', v.dis);
597
+ /*
598
+ * 🔴 A ISSUE #128, E ELA É DE UMA LINHA: `pi-dis` é CLASSE CSS. A criança que enxerga vê o ícone
599
+ * apagado; a que navega por leitor de tela não recebe nada — o botão anuncia-se accionável e não
600
+ * responde. `aria-disabled` espelha o mesmo facto para quem ouve.
601
+ *
602
+ * ⚠️ E `aria-disabled` e NÃO `disabled`: o segundo tira o botão da ordem de tabulação, e quem navega
603
+ * por teclado deixaria de o alcançar — logo deixaria de poder ouvir POR QUE ele não responde. É a
604
+ * mesma escolha que o `#opt-altmove` faz no painel, pela mesma razão.
605
+ */
606
+ if (v.dis)
607
+ b.setAttribute('aria-disabled', 'true');
608
+ else
609
+ b.removeAttribute('aria-disabled');
408
610
  b.setAttribute('aria-pressed', String(v.active));
409
- // `soon` buttons keep the label the markup gave them (same string) — no state to report.
410
- if (!(ICON_BY_KEY.get(k) || {}).soon)
411
- b.setAttribute('aria-label', computeIconLabel(k, st));
611
+ // ⚠️ TODO ÍCONE RECEBE RÓTULO, `soon` INCLUÍDO — e o guarda que aqui estava dizia por que não: «`soon`
612
+ // buttons keep the label the markup gave them (same string)». A segunda metade continua certa (não há
613
+ // estado a reportar), mas «mesma string» era verdade só enquanto a marcação e o reflexo corressem no
614
+ // MESMO IDIOMA — e desde que a engine passou a montar a barra (ADR-0106 etapa 2) deixam de correr.
615
+ // O `initI18n` carrega en/es de forma assíncrona; a marcação nasce em pt e só o reflexo a corrige.
616
+ // 📌 Medido num navegador: cinco ícones em inglês e três ainda em «(em construção)», na mesma barra.
617
+ b.setAttribute('aria-label', computeIconLabel(k, st));
412
618
  }
413
619
  function reflectIconsIn(root, i) {
414
620
  if (!root)
@@ -419,8 +625,48 @@ export function initPauseIcons(ctx) {
419
625
  * Reflete os ícones de TODAS as telas. Varre as BARRAS e não mais os cartões de pausa: desde o item 7 do
420
626
  * ADR-0044 os ícones vivem no HUD, e um cartão de pausa não contém `.pi-btn` nenhum.
421
627
  */
628
+ /**
629
+ * OS CARTÕES QUE ESTA INSTÂNCIA CONSTRUIU. Privado, e é a resposta a um problema que eu próprio criei.
630
+ *
631
+ * ⚠️ O `PauseIconsCtx` tinha um `getPauseScreens` e eu removi-o hoje, com razão: tinha ZERO leitores. Agora
632
+ * este módulo precisa de alcançar os cartões — e a saída certa NÃO é repor o campo. Quem os construiu foi
633
+ * ele; guardar o que construiu não pede nada a consumidor nenhum, e um campo de ctx é mais uma coisa que
634
+ * cada um dos 300 jogos teria de se lembrar de passar.
635
+ */
636
+ const cartoes = [];
637
+ /**
638
+ * ESCONDE OS ITENS QUE ESTE JOGO NÃO CONSEGUE ACCIONAR — recalculado, e não decidido no arranque.
639
+ *
640
+ * ⚠️ ESTA FUNÇÃO EXISTE POR UM DEFEITO DE TEMPO. O cartão era FILTRADO no `buildScreenPause`, e o
641
+ * `getPauseActs` é um getter precisamente porque a tabela CHEGA TARDE — «`pauseActs` is a `const` declared
642
+ * far below the init site», diz o próprio campo. Um consumidor que siga esse padrão documentado montava um
643
+ * cartão sem os itens cuja acção só existiu depois do boot, e nunca mais os recuperava.
644
+ *
645
+ * 📌 A avaliação passou para o ÚLTIMO instante possível: o `ui/shell` chama `reflectPauseIcons()` quando a
646
+ * fase vira `pause-menu`, ou seja quando a pausa ABRE. O §5 continua respeitado — a criança nunca vê um
647
+ * item que não acciona —, e agora também vê os que passaram a accionar.
648
+ */
649
+ function refrescarItensDaPausa() {
650
+ const acts = getPauseActs();
651
+ const raiz = ctx.pmButtons ?? PM_BTNS;
652
+ const opcoes = ctx.optionsButtons ?? PM_OPTIONS_BTNS;
653
+ const vivos = new Set([
654
+ ...raizQueAcciona(raiz, opcoes, acts).map((b) => b.act),
655
+ ...itensQueAccionam(opcoes, acts).map((b) => b.act),
656
+ ]);
657
+ // ⚠️ `filter(Boolean)`: os cartões são indexados por JOGADOR, e montar só a tela 2 deixa um buraco no
658
+ // índice 0. Um `for…of` sobre array esparso entrega `undefined`, e foi o que rebentou à primeira.
659
+ for (const cartao of cartoes.filter(Boolean)) {
660
+ for (const btn of cartao.querySelectorAll('.pm-btn')) {
661
+ // ⚠️ `hidden` e não `remove()`: reaparecer tem de ser possível, porque a tabela pode crescer outra vez
662
+ // (um jogo que só liga «sair» depois da primeira fase). Remover seria decidir uma vez de novo.
663
+ btn.hidden = !vivos.has(btn.dataset.act ?? '');
664
+ }
665
+ }
666
+ }
422
667
  function reflectPauseIcons() {
423
668
  ctx.getA11yBars().forEach((bar, i) => reflectIconsIn(bar, i));
669
+ refrescarItensDaPausa();
424
670
  }
425
671
  // --- the pause screen ----------------------------------------------------------------------
426
672
  /**
@@ -472,7 +718,7 @@ export function initPauseIcons(ctx) {
472
718
  if (!bar || !primeiro)
473
719
  return;
474
720
  naBarra.add(i);
475
- const acts = ctx.getPauseActs();
721
+ const acts = getPauseActs();
476
722
  if (acts.resume)
477
723
  acts.resume(); // volta à tela normal: o modo é para usar DURANTE a partida
478
724
  ctx.srSay(t('sr.a11y.barEnter'));
@@ -516,7 +762,7 @@ export function initPauseIcons(ctx) {
516
762
  const cur = iconeSelecionado(bar);
517
763
  const idx = cur ? icones.indexOf(cur) : 0;
518
764
  if (acao === 'ativar') {
519
- ctx.setPauseActor(i);
765
+ setPauseActor(i);
520
766
  if (cur)
521
767
  cur.click();
522
768
  return;
@@ -527,19 +773,36 @@ export function initPauseIcons(ctx) {
527
773
  }
528
774
  }
529
775
  function buildScreenPause(i) {
530
- const sp = document.createElement('div');
776
+ const sp = docDaMontagem().createElement('div');
531
777
  sp.className = 'screen-pause';
532
778
  sp.hidden = true;
533
779
  sp.dataset.player = String(i);
780
+ /*
781
+ * ⚠️ MONTA A LISTA INTEIRA E ESCONDE DEPOIS — e a versão anterior desta linha FILTRAVA aqui, o que estava
782
+ * errado por uma razão de TEMPO. O comentário que estava neste sítio dizia «montar é o primeiro instante
783
+ * em que a resposta existe»; não é. O `getPauseActs` é um getter precisamente porque a tabela chega
784
+ * TARDE — «`pauseActs` is a `const` declared far below the init site», diz o próprio campo —, e o
785
+ * `createGame` monta durante o próprio `createGame(...)`. Filtrar aqui apagava para sempre todo item cuja
786
+ * acção só passou a existir depois do boot.
787
+ *
788
+ * Quem decide o que se VÊ é o `refrescarItensDaPausa`, a cada `reflectPauseIcons()` — que o `ui/shell`
789
+ * dispara quando a fase vira `pause-menu`, ou seja quando a pausa ABRE. O §5 continua respeitado (a
790
+ * criança nunca vê um item que não acciona) e agora também vê os que passaram a accionar.
791
+ */
534
792
  sp.innerHTML = screenPauseMarkup({
535
- player: i, numPlayers: ctx.getNumPlayers(), pmButtons: ctx.pmButtons, optionsButtons: ctx.optionsButtons,
536
- dynLabel: ctx.dynLabel, t,
793
+ player: i,
794
+ numPlayers: ctx.getNumPlayers(),
795
+ pmButtons: ctx.pmButtons ?? PM_BTNS,
796
+ optionsButtons: ctx.optionsButtons ?? PM_OPTIONS_BTNS,
797
+ dynLabel: dynLabel, t,
537
798
  });
799
+ cartoes[i] = sp;
800
+ refrescarItensDaPausa(); // o §5 vale já na montagem, e não só na primeira abertura
538
801
  sp.addEventListener('click', (e) => {
539
802
  const target = e.target;
540
803
  const b = target && target.closest('.pm-btn');
541
804
  if (b) {
542
- ctx.setPauseActor(i);
805
+ setPauseActor(i);
543
806
  const act = b.dataset.act || '';
544
807
  // NAVEGAÇÃO DENTRO DO CARTÃO fica aqui, e não na tabela de ações: `options` e `pmback` não fazem nada
545
808
  // ao jogo — trocam qual lista está na tela. A tabela vive em `ui/shell`, que não conhece este `sp`.
@@ -554,7 +817,7 @@ export function initPauseIcons(ctx) {
554
817
  entrarNaBarra(i);
555
818
  return;
556
819
  }
557
- const acts = ctx.getPauseActs();
820
+ const acts = getPauseActs();
558
821
  const fn = acts[act];
559
822
  if (fn)
560
823
  fn();
@@ -571,10 +834,10 @@ export function initPauseIcons(ctx) {
571
834
  * facilidade não existe —, e degradar o que existe para DAR acesso seria o contrário do que ele serve.
572
835
  */
573
836
  function buildQuickBar(i) {
574
- const bar = document.createElement('div');
837
+ const bar = docDaMontagem().createElement('div');
575
838
  bar.className = 'screen-a11y';
576
839
  bar.dataset.player = String(i);
577
- bar.innerHTML = quickBarMarkup();
840
+ bar.innerHTML = quickBarMarkup(iconesDoJogo);
578
841
  // FORA DA ORDEM DE TABULAÇÃO durante a partida (ADR-0044, item 7). Dez paradas entre a criança e o jogo
579
842
  // seria o preço de deixá-los lá — e o alcance por teclado não se perde: ele passa a ser o modo
580
843
  // `accessibility`, que se abre pela pausa. A barra do TÍTULO não é afetada: lá não se está jogando, e o
@@ -585,7 +848,7 @@ export function initPauseIcons(ctx) {
585
848
  const ib = e.target?.closest('.pi-btn');
586
849
  if (!ib)
587
850
  return;
588
- ctx.setPauseActor(i);
851
+ setPauseActor(i);
589
852
  iconAct(ib.dataset.pi || '', i);
590
853
  reflectPauseIcons(); // must run BEFORE reading the label back — that is what makes the caption honest
591
854
  if (cap)
@@ -614,7 +877,14 @@ export function initPauseIcons(ctx) {
614
877
  }
615
878
  return {
616
879
  buildScreenPause, buildQuickBar, entrarNaBarra, sairDaBarra, naBarraDe, navBar,
880
+ iconesMontados: iconesDoJogo,
617
881
  iconAct, iconLabel, reflectIconBtn, reflectIconsIn, reflectPauseIcons,
618
- applyCalm, getCalmMode: () => calmMode, setCalmMode: (n) => { calmMode = n; }, iconState,
882
+ // ⚠️ O `setCalmMode` PERSISTE TAMBÉM, e sanea. Ele é a outra porta para o mesmo valor — se só o ciclo do
883
+ // ícone gravasse, um nível posto por aqui sobreviveria à sessão e não ao fecho da aba, que é a metade
884
+ // pior do defeito: o ajuste parece ter pegado e some depois.
885
+ applyCalm,
886
+ getCalmMode: () => calmMode,
887
+ setCalmMode: (n) => { calmMode = saneiaNivelTea(n); store.set(store.KEYS.tea, calmMode); },
888
+ iconState,
619
889
  };
620
890
  }
@@ -17,6 +17,14 @@ export function linhasDoAviso(a, t) {
17
17
  for (const c of a.curtos) {
18
18
  linhas.push(t('reach.curto', { transporte: nome(c.id), lugares: c.slots }));
19
19
  }
20
+ // ⚠️ A TERCEIRA FRASE, e ela existe porque um transporte pode CHEGAR a todas as ações e ainda assim não
21
+ // deixar a criança jogar (ADR-0104). Medido: a plataforma pede nove ações, o controle de tela tem nove
22
+ // lugares, e o cartão nunca aparecia — mas correr, andar e pular ao mesmo tempo são três dedos, e um
23
+ // telemóvel que reconhece dois não os dá. A criança tentava, não acontecia nada, e concluía que o jogo
24
+ // estava partido. Uma frase antes de começar é a resposta honesta; meia tela jogável não é.
25
+ for (const s of a.naoSeguram) {
26
+ linhas.push(t('reach.naoSegura', { transporte: nome(s.id), segura: s.holds, pedidas: a.seguraPedidas }));
27
+ }
20
28
  linhas.push(a.serviriamSeLigados.length
21
29
  ? t('reach.ligue', { saida: a.serviriamSeLigados.map(nome).join(t('reach.ou')) })
22
30
  : t('reach.semSaida'));
@@ -74,10 +74,23 @@ export interface SettingsAudioCtx {
74
74
  /** Core collision state (NOT owned by this panel — core/collision.ts reads it via isModoCego). The toggle's
75
75
  * widget lives inside #audio; the state and its gameplay side effects (setupExtras) stay in game.js. */
76
76
  getModoCego: () => boolean;
77
- setModoCego: (on: boolean) => void;
77
+ setModoCego?: (on: boolean) => void;
78
78
  /** Core collision state (cane hit spacing). Same reasoning as modo cego. */
79
79
  getCaneBlockDiv: () => number;
80
80
  setCaneBlockDiv: (div: number) => void;
81
+ /**
82
+ * Move a prosa das linhas para o rodapé (`ui/settings-panel` → `fillExplain`). Chamado a CADA render.
83
+ *
84
+ * ⚠️ NÃO É OPCIONAL POR ELEGÂNCIA: `fillExplain` roda uma vez quando o overlay é frontalizado e move o
85
+ * `.opt-hint` de dentro de cada linha para o rodapé. Este painel RECONSTRÓI as linhas, e as linhas novas
86
+ * voltam com a prosa lá dentro — então a explicação aparece duas vezes, no rodapé e sob o rótulo, a
87
+ * partir do primeiro clique. O `CLAUDE.md` §4 regista exatamente isto, e a issue #109 já o consertou
88
+ * uma vez noutros painéis.
89
+ *
90
+ * Opcional na assinatura porque um consumidor pode montar o painel sem a casca (um teste, o segundo
91
+ * consumidor): sem casca não há rodapé para duplicar.
92
+ */
93
+ fillExplain?: (card: HTMLElement | null) => void;
81
94
  }
82
95
  export interface SettingsAudioApi {
83
96
  /** Re-renders the whole #audio overlay content (master, categories, nav-sound, TTS panel, sinks, modo-cego and