@the-inclusionist/engine 7.0.0 → 8.0.0-rc.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.
Files changed (116) 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/pressstart-400.woff2 +0 -0
  7. package/app/public/vendor/fonts.css +43 -2
  8. package/dist-pkg/boot/create-game.d.ts +76 -2
  9. package/dist-pkg/boot/create-game.js +271 -13
  10. package/dist-pkg/core/constants.d.ts +0 -28
  11. package/dist-pkg/core/constants.js +34 -17
  12. package/dist-pkg/core/contract.d.ts +39 -0
  13. package/dist-pkg/core/contract.js +36 -0
  14. package/dist-pkg/core/entity.d.ts +47 -4
  15. package/dist-pkg/core/layers.d.ts +18 -0
  16. package/dist-pkg/core/layers.js +18 -0
  17. package/dist-pkg/core/rng.js +14 -4
  18. package/dist-pkg/core/route.d.ts +42 -0
  19. package/dist-pkg/core/route.js +158 -0
  20. package/dist-pkg/core/state.d.ts +10 -1
  21. package/dist-pkg/core/state.js +13 -0
  22. package/dist-pkg/educational/adaptive-engine.d.ts +65 -0
  23. package/dist-pkg/educational/adaptive-engine.js +117 -0
  24. package/dist-pkg/educational/segment-bar.d.ts +96 -0
  25. package/dist-pkg/educational/segment-bar.js +89 -0
  26. package/dist-pkg/i18n/en.js +33 -0
  27. package/dist-pkg/i18n/es.js +33 -0
  28. package/dist-pkg/i18n/pt.js +46 -0
  29. package/dist-pkg/input/default-bindings.d.ts +40 -0
  30. package/dist-pkg/input/default-bindings.js +143 -9
  31. package/dist-pkg/input/gamepad.d.ts +18 -11
  32. package/dist-pkg/input/gamepad.js +75 -9
  33. package/dist-pkg/input/keyboard-runtime.d.ts +6 -8
  34. package/dist-pkg/input/keyboard-runtime.js +24 -5
  35. package/dist-pkg/input/keyboard.d.ts +10 -0
  36. package/dist-pkg/input/keyboard.js +41 -11
  37. package/dist-pkg/input/keydown.d.ts +27 -2
  38. package/dist-pkg/input/keydown.js +20 -6
  39. package/dist-pkg/input/latch-scope.d.ts +70 -0
  40. package/dist-pkg/input/latch-scope.js +110 -0
  41. package/dist-pkg/input/latch-store.d.ts +45 -0
  42. package/dist-pkg/input/latch-store.js +74 -0
  43. package/dist-pkg/input/origem-sintetica.d.ts +44 -0
  44. package/dist-pkg/input/origem-sintetica.js +60 -0
  45. package/dist-pkg/input/pointer.d.ts +61 -0
  46. package/dist-pkg/input/pointer.js +71 -0
  47. package/dist-pkg/input/state.d.ts +86 -1
  48. package/dist-pkg/input/state.js +128 -1
  49. package/dist-pkg/input/touch-bindings.d.ts +11 -2
  50. package/dist-pkg/input/touch-bindings.js +9 -3
  51. package/dist-pkg/input/touch.js +9 -1
  52. package/dist-pkg/input/transporte-em-uso.d.ts +101 -0
  53. package/dist-pkg/input/transporte-em-uso.js +130 -0
  54. package/dist-pkg/input/transports.d.ts +88 -2
  55. package/dist-pkg/input/transports.js +60 -5
  56. package/dist-pkg/input/vocabulary-migration.d.ts +18 -7
  57. package/dist-pkg/platform/audio-earcons.d.ts +29 -2
  58. package/dist-pkg/platform/audio-earcons.js +10 -0
  59. package/dist-pkg/platform/audio-nav.d.ts +1 -1
  60. package/dist-pkg/platform/audio-sonar.d.ts +149 -9
  61. package/dist-pkg/platform/audio-sonar.js +232 -21
  62. package/dist-pkg/platform/guide-intensity.d.ts +40 -0
  63. package/dist-pkg/platform/guide-intensity.js +73 -0
  64. package/dist-pkg/platform/storage.d.ts +30 -0
  65. package/dist-pkg/platform/storage.js +35 -0
  66. package/dist-pkg/platform/tts.js +16 -1
  67. package/dist-pkg/platform/voice-plan.d.ts +90 -0
  68. package/dist-pkg/platform/voice-plan.js +137 -0
  69. package/dist-pkg/render/draw.d.ts +1 -1
  70. package/dist-pkg/render/draw.js +15 -5
  71. package/dist-pkg/render/viz-axes.d.ts +17 -0
  72. package/dist-pkg/render/viz-axes.js +19 -0
  73. package/dist-pkg/render/viz-setters.d.ts +34 -2
  74. package/dist-pkg/render/viz-setters.js +189 -33
  75. package/dist-pkg/render/wheelchair-sprites.d.ts +11 -3
  76. package/dist-pkg/render/wheelchair-sprites.js +10 -4
  77. package/dist-pkg/ui/dom.js +20 -2
  78. package/dist-pkg/ui/fonts.d.ts +47 -1
  79. package/dist-pkg/ui/fonts.js +47 -9
  80. package/dist-pkg/ui/latch-refusal.d.ts +32 -0
  81. package/dist-pkg/ui/latch-refusal.js +60 -0
  82. package/dist-pkg/ui/layout.d.ts +32 -0
  83. package/dist-pkg/ui/layout.js +64 -1
  84. package/dist-pkg/ui/motion-scene.d.ts +41 -0
  85. package/dist-pkg/ui/motion-scene.js +76 -0
  86. package/dist-pkg/ui/panel-shell.d.ts +53 -0
  87. package/dist-pkg/ui/panel-shell.js +103 -0
  88. package/dist-pkg/ui/pause-icons.d.ts +134 -20
  89. package/dist-pkg/ui/pause-icons.js +307 -45
  90. package/dist-pkg/ui/reach-notice.js +8 -0
  91. package/dist-pkg/ui/settings-audio.d.ts +14 -1
  92. package/dist-pkg/ui/settings-audio.js +43 -3
  93. package/dist-pkg/ui/settings-controls.d.ts +66 -6
  94. package/dist-pkg/ui/settings-controls.js +179 -17
  95. package/dist-pkg/ui/settings-empathy.d.ts +15 -0
  96. package/dist-pkg/ui/settings-empathy.js +2 -0
  97. package/dist-pkg/ui/settings-motion.d.ts +25 -19
  98. package/dist-pkg/ui/settings-motion.js +32 -19
  99. package/dist-pkg/ui/settings-motor.d.ts +81 -3
  100. package/dist-pkg/ui/settings-motor.js +118 -9
  101. package/dist-pkg/ui/settings-typo.d.ts +32 -2
  102. package/dist-pkg/ui/settings-typo.js +64 -18
  103. package/dist-pkg/ui/settings-visual.d.ts +21 -1
  104. package/dist-pkg/ui/settings-visual.js +48 -4
  105. package/dist-pkg/ui/shell.d.ts +13 -3
  106. package/dist-pkg/ui/shell.js +3 -4
  107. package/dist-pkg/ui/simulation-refusal.d.ts +32 -0
  108. package/dist-pkg/ui/simulation-refusal.js +57 -0
  109. package/dist-pkg/ui/visual-axes-panel.d.ts +48 -0
  110. package/dist-pkg/ui/visual-axes-panel.js +95 -0
  111. package/dist-pkg/ui/webcam.js +6 -1
  112. package/docs/CREDITS.md +18 -0
  113. package/docs/LICENSES.md +12 -0
  114. package/package.json +24 -4
  115. package/app/public/vendor/fonts/greatvibes-400.woff2 +0 -0
  116. package/app/public/vendor/fonts/ufcook-700.woff2 +0 -0
@@ -121,6 +121,10 @@ export function sinkSelectValue(p) {
121
121
  // DOM-facing (thin) — requires `document`/injected ctx
122
122
  // ---------------------------------------------------------------------------------------------
123
123
  export function initSettingsAudio(ctx) {
124
+ // ⚠️ PADRÃO DA ENGINE (ADR-0106 §4): quem injecta manda; quem não injecta deixa de ficar sem modo cego.
125
+ // O `setModoCegoValue` faz as três coisas que o `core/state` diz que um setter faz — grava, persiste, avisa
126
+ // — e nada mais: os efeitos (refazer os extras do nível) são reação, e quem reage assina o evento.
127
+ const setModoCego = ctx.setModoCego ?? state.setModoCegoValue;
124
128
  let audioDevices = [];
125
129
  function reflectMaster() {
126
130
  const b = ctx.$('#audio-master');
@@ -179,6 +183,8 @@ export function initSettingsAudio(ctx) {
179
183
  return;
180
184
  el.innerHTML = catsListHTML(keys, ctx.audioCats, state);
181
185
  wireCatControls(el);
186
+ // A prosa volta para o rodapé depois de as linhas serem reconstruídas (CLAUDE.md §4, #109).
187
+ ctx.fillExplain?.(ctx.$('#audio .overlay__card'));
182
188
  }
183
189
  function renderNavSound() {
184
190
  renderCategoryList('#navsound-list', NAV_CATS);
@@ -417,9 +423,28 @@ export function initSettingsAudio(ctx) {
417
423
  NAV_CATS.forEach((k) => { state[k].vol = v; state[k].on = true; ctx.setCatGain(k); });
418
424
  renderNavSound();
419
425
  });
426
+ /*
427
+ * ⚠️ ESTE BOTÃO ERA O ÚNICO DESTE PAINEL QUE NÃO ANUNCIAVA. Medido em 2026-09-08: os cinco irmãos daqui
428
+ * anunciam (som, TTS, divisor da bengala, índice de menu, saída de áudio) e o modo cego não — ele parecia
429
+ * anunciar porque UM cartucho o fazia a partir do próprio `setModoCego`, e o painel herdava o efeito.
430
+ *
431
+ * ⚠️ E ISSO PASSOU A EXPOR SILÊNCIO no mesmo dia: desde que o campo ganhou padrão da engine
432
+ * (`setModoCegoValue`, que grava/persiste/avisa e NÃO fala), um jogo que não injecta o seu próprio setter
433
+ * ficava com este botão mudo. Um alternador que muda estado sem o dizer é invisível para quem usa leitor de
434
+ * tela — a mesma família de defeito que o `reflectTTS` e o `reflectModoCego` já custaram aqui.
435
+ *
436
+ * O anúncio pertence a QUEM É ACCIONADO, não ao setter: `core/state` diz que o setter faz três coisas e só
437
+ * três. ⚠️ Consequência de lockstep, escrita para não se descobrir depois: quando o `game-platformer` subir
438
+ * de versão, tem de TIRAR o `srSay` do `setModoCego` dele, senão a criança ouve o estado duas vezes.
439
+ */
420
440
  const mcBtn = ctx.$('#opt-modocego');
421
- if (mcBtn)
422
- mcBtn.addEventListener('click', () => { ctx.setModoCego(!ctx.getModoCego()); reflectModoCego(); });
441
+ if (mcBtn) {
442
+ mcBtn.addEventListener('click', () => {
443
+ setModoCego(!ctx.getModoCego());
444
+ reflectModoCego();
445
+ ctx.srSay(t(ctx.getModoCego() ? 'sr.blind.on' : 'sr.blind.off'));
446
+ });
447
+ }
423
448
  const caneDivSel = ctx.$('#cane-div');
424
449
  if (caneDivSel) {
425
450
  caneDivSel.value = String(ctx.getCaneBlockDiv());
@@ -516,7 +541,7 @@ export function initSettingsAudio(ctx) {
516
541
  const resetBtn = ctx.$('#audio-reset');
517
542
  if (resetBtn)
518
543
  resetBtn.addEventListener('click', () => {
519
- ctx.setModoCego(DEFAULTS.modoCego);
544
+ setModoCego(DEFAULTS.modoCego);
520
545
  ctx.setCaneBlockDiv(DEFAULTS.caneBlockDiv);
521
546
  const state = ctx.getAudioCat();
522
547
  if (state)
@@ -554,5 +579,20 @@ export function initSettingsAudio(ctx) {
554
579
  if (audioDetectBtn)
555
580
  audioDetectBtn.addEventListener('click', () => { void detectAudioDevices(); });
556
581
  reflectMaster(); // estado inicial do botão/slider mestre, antes de qualquer abertura do painel
582
+ /*
583
+ * ⚠️ O PAINEL ASSINA O EVENTO, e isto não é uma ideia nova: é a decisão que o `core/state` já tinha
584
+ * escrito ao lado do `setModoCegoValue` — «o setter faz três coisas e só três: grava, persiste, avisa. Os
585
+ * efeitos … são reação, e quem reage assina o evento».
586
+ *
587
+ * Sem esta assinatura, um jogo que NÃO injecta o seu próprio `setModoCego` liga o modo cego pelo ícone da
588
+ * barra e o botão `#opt-modocego` deste painel continua a dizer «Desligado», com `aria-pressed=false` — o
589
+ * controlo a mentir o estado para o leitor de tela. É o gémeo exacto do defeito do `reflectTTS` que já está
590
+ * registado no `ui/pause-icons`, e não vale a pena descobri-lo uma terceira vez.
591
+ *
592
+ * ⚠️ É seguro para quem JÁ reflecte a partir do seu próprio setter: reflectir é idempotente — relê o estado
593
+ * e reescreve o botão. Um anúncio duplicado seria outra história, e por isso a assinatura NÃO anuncia: o
594
+ * ícone da barra já diz `sr.icon.blindOn`/`Off` por si.
595
+ */
596
+ state.on('modoCego', () => { reflectModoCego(); });
557
597
  return { renderAudio, reflectModoCego, reflectTts };
558
598
  }
@@ -1,6 +1,7 @@
1
1
  // SPDX-License-Identifier: AGPL-3.0-or-later
2
2
  import type { DomQuery } from '../core/dom-query.js';
3
3
  import type { KeyScheme } from '../core/entity.js';
4
+ import { type Action } from '../core/actions.js';
4
5
  import type { KBDefaults } from '../input/keyboard.js';
5
6
  import type { KeydownEventLike } from '../input/keydown.js';
6
7
  /** Minimal DOM-selector shape (matches ui/dom.ts's `$`). */
@@ -29,7 +30,7 @@ export interface SettingsControlsCtx {
29
30
  * remapear um botão que não faz nada.
30
31
  */
31
32
  acoesDoJogo: () => readonly {
32
- readonly acao: string;
33
+ readonly acao: Action;
33
34
  readonly rotulo: string;
34
35
  }[];
35
36
  /** Screen-reader "polite" announcement (core/a11y-sr's srSay), injected. */
@@ -45,12 +46,37 @@ export interface SettingsControlsCtx {
45
46
  /** Shared helper (game.js): the scheme for a given player index, given `kb`/numPlayers. Not owned by this panel —
46
47
  * other systems (gamepad binding, HUD) call the same game.js function. */
47
48
  kbFor: (playerIndex: number) => KeyScheme;
49
+ /**
50
+ * O esquema DE FÁBRICA deste assento (ADR-0029) — o que ele teria se ninguém tivesse remapeado nada.
51
+ *
52
+ * ⚠️ INJECTADO PELA MESMA RAZÃO QUE O `kbFor` LOGO ACIMA: o mapeamento «quantos jogadores → que balde»
53
+ * (`solo`/`p2`/`p3`/`p4`) é do consumidor, e uma segunda cópia dessa regra dentro da engine divergiria da
54
+ * primeira no dia em que um dos dois mudasse.
55
+ *
56
+ * 🎯 E NÃO SE OBTÉM CHAMANDO `store.resetKB()`, embora ele devolva exactamente a configuração de fábrica:
57
+ * o `input/keyboard.resetKB` faz `store.remove(CKEY)` ANTES de devolver a cópia. Usá-lo como leitor
58
+ * apagaria o remapeamento da criança a cada render, e o estrago só apareceria no arranque seguinte.
59
+ */
60
+ kbPadraoFor: (playerIndex: number) => KeyScheme;
48
61
  /** Shared: current player count (core/state.ts's numPlayers, read live via game.js). */
49
62
  getNumPlayers: () => number;
50
63
  /** Shared: propagates `kb` -> the live control aliases (game.js's applyControls). Called after remap/reset. */
51
64
  applyControls: () => void;
52
65
  /** Shared: propagates `kb` -> each player's `p.ctrl` (game.js's assignControls). Called after remap/reset. */
53
66
  assignControls: () => void;
67
+ /**
68
+ * Move a prosa das linhas para o rodapé (`ui/settings-panel` → `fillExplain`). Chamado a CADA render.
69
+ *
70
+ * ⚠️ NÃO É OPCIONAL POR ELEGÂNCIA: `fillExplain` roda uma vez quando o overlay é frontalizado e move o
71
+ * `.opt-hint` de dentro de cada linha para o rodapé. Este painel RECONSTRÓI as linhas, e as linhas novas
72
+ * voltam com a prosa lá dentro — então a explicação aparece duas vezes, no rodapé e sob o rótulo, a
73
+ * partir do primeiro clique. O `CLAUDE.md` §4 regista exatamente isto, e a issue #109 já o consertou
74
+ * uma vez noutros painéis.
75
+ *
76
+ * Opcional na assinatura porque um consumidor pode montar o painel sem a casca (um teste, o segundo
77
+ * consumidor): sem casca não há rodapé para duplicar.
78
+ */
79
+ fillExplain?: (card: HTMLElement | null) => void;
54
80
  }
55
81
  export interface SettingsControlsApi {
56
82
  /** Re-renders #ctrl-list for the given player index and (re)wires its "Alterar" buttons. Idempotent. */
@@ -70,12 +96,27 @@ export interface SettingsControlsApi {
70
96
  handleCaptureKeydown: (e: KeydownEventLike) => boolean;
71
97
  }
72
98
  /**
73
- * action key -> i18n KEY of the label shown in the panel. Also reused by main.js's openHelp() (pause-menu help
74
- * screen), which is why it lives here instead of being duplicated in two places.
99
+ * As oito posições do jogo de plataforma, ligadas às chaves de i18n das palavras DELE.
100
+ *
101
+ * ⚠️ ELA TEM CONSUMIDOR, e eu já disse aqui que não tinha. A afirmação anterior — «sem um único consumidor,
102
+ * nem aqui nem no `game-platformer`» — vinha de uma varredura com um padrão que **excluía o `main.ts`** do
103
+ * cartucho, por ele estar directamente em `app/js/` e o glob exigir um subdirectório. Medido de novo com
104
+ * `git grep`: `game-platformer/app/js/main.ts` importa-a e usa-a na TELA DE AJUDA do menu de pausa (a linha
105
+ * que lista posição ↔ tecla). O `openHelp()` que o cabeçalho original citava não morreu — mudou de
106
+ * repositório com o cartucho (#111) e continua a ler daqui.
75
107
  *
76
- * Holds keys, not text, for the reason spelled out in input/devices: a module-level const is evaluated once at
77
- * import, and core/i18n's `dict` is a `let` that setLocale reassigns — text captured here would freeze the
78
- * language at boot. Resolve with `t(ACT_LABEL[a])` at the point of use.
108
+ * ⚠️ E ELA CONTINUA A SER A CAUSA DA #125, o que é diferente de estar morta. O defeito era o `aria-label` da
109
+ * tela de remapeamento ser montado a partir dela: oito posições contra as catorze do vocabulário, e as
110
+ * palavras de UM jogo dentro do motor. Esse uso saiu. O que resta é um consumidor para quem a tabela está
111
+ * certa — porque ele É o jogo de plataforma.
112
+ *
113
+ * ⚠️ REMOVÊ-LA NÃO É LIMPEZA, É MIGRAÇÃO. O cartucho já tem `acoesDoJogo()` (`main.ts:122`), derivado do
114
+ * preset dele; a tela de ajuda passar a usá-lo é edição de lá, e só depois disso é que isto pode sair daqui.
115
+ * Enquanto não sair, quem escrever código NOVO na engine pede a palavra ao jogo por `ctx.acoesDoJogo()` — a
116
+ * engine sabe que a posição existe, só o jogo sabe como ela se chama (ADR-0086).
117
+ *
118
+ * Guarda CHAVES e não texto porque uma `const` de módulo é avaliada uma vez no import, e o `dict` do
119
+ * `core/i18n` é um `let` que o `setLocale` reatribui — texto capturado aqui congelaria o idioma no boot.
79
120
  */
80
121
  export declare const ACT_LABEL: Record<string, string>;
81
122
  /**
@@ -97,4 +138,23 @@ export declare function keyName(code: string): string;
97
138
  * index) so a scheme with many bound keys doesn't cost a full re-scan per lookup.
98
139
  */
99
140
  export declare function keyUsedByOther(code: string, mapRef: KeyScheme, schemes: readonly KeyScheme[]): number;
141
+ /**
142
+ * Qual OUTRA ação DO MESMO esquema já tem `code` — ou `null` se nenhuma.
143
+ *
144
+ * ⚠️ O IRMÃO QUE FALTAVA AO `keyUsedByOther`, E A FALTA ERA INVISÍVEL NUM JOGO DE UM JOGADOR (#126). Aquele
145
+ * exclui o esquema em edição **por referência**; com um jogador só, `schemesFor()` devolve exatamente esse
146
+ * esquema, então a guarda varre uma lista vazia e **nunca pode disparar**. A criança que põe `W` numa ação
147
+ * nova continua com `W` na antiga, e passa o jogo inteiro com as duas a disparar juntas.
148
+ *
149
+ * ⚠️ E O DEFEITO É O PIOR FEITIO POSSÍVEL, escrito no cabeçalho do `input/default-bindings` desde sempre:
150
+ * «as duas ações disparam juntas, e a criança vê uma ação dupla intermitente que ninguém consegue reproduzir
151
+ * de propósito». Numa tela que ela abriu **porque** não conseguia usar os controles padrão.
152
+ *
153
+ * ⚠️ A guarda entre JOGADORES não estava partida — estava inalcançável. Medido na auditoria: com dois
154
+ * assentos ela funciona e recusa certo. O que faltava era a verificação dentro do mesmo esquema.
155
+ *
156
+ * Devolve a AÇÃO e não um booleano, porque o anúncio tem de dizer qual — «essa tecla já está em uso» manda a
157
+ * criança procurar o que a função já sabe.
158
+ */
159
+ export declare function acaoQueJaTem(code: string, mapRef: KeyScheme, exceto: Action): Action | null;
100
160
  export declare function initSettingsControls(ctx: SettingsControlsCtx): SettingsControlsApi;
@@ -6,19 +6,41 @@
6
6
  // shared per-player helpers (`kbFor`/`getNumPlayers`/`applyControls`/`assignControls`) that game.js also uses
7
7
  // elsewhere (gamepad binding, HUD, other settings panels) and therefore stay there, injected. Overlay open/close
8
8
  // plumbing (#options hidden toggle, focus management, Escape-closes-dialog) and the pad-button-design select are
9
- // shared/unrelated infra and stay in game.js. `openHelp()` (pause-menu help screen) reuses ACT_LABEL/keyName —
10
- // both are exported here instead of duplicated.
9
+ // shared/unrelated infra and stay in game.js. `openHelp()` (pause-menu help screen) reuses `keyName`, which is
10
+ // exported here instead of duplicated.
11
+ //
12
+ // ⚠️ ELE TAMBÉM LIA O `ACT_LABEL`, E DEIXOU DE LER EM 2026-09-07. A tela de ajuda do cartucho passou a montar
13
+ // as linhas do preset dele (`acoesDoJogo`), que é quem sabe quantas posições este jogo usa e como elas se
14
+ // chamam. Com isso o `ACT_LABEL` ficou sem UM leitor sequer — conferido com `git grep` nos dois repositórios,
15
+ // e o que resta dele são comentários e a própria declaração. Ver `docs/6-DevOps-SRE/Breaking-Changes.md`.
11
16
  import { t } from '../core/i18n.js';
17
+ import { ACTIONS, isAction } from '../core/actions.js';
18
+ import { markChanged } from './changed-mark.js';
12
19
  // ---------------------------------------------------------------------------------------------
13
20
  // Pure logic (no `document`, testable in node)
14
21
  // ---------------------------------------------------------------------------------------------
15
22
  /**
16
- * action key -> i18n KEY of the label shown in the panel. Also reused by main.js's openHelp() (pause-menu help
17
- * screen), which is why it lives here instead of being duplicated in two places.
23
+ * As oito posições do jogo de plataforma, ligadas às chaves de i18n das palavras DELE.
18
24
  *
19
- * Holds keys, not text, for the reason spelled out in input/devices: a module-level const is evaluated once at
20
- * import, and core/i18n's `dict` is a `let` that setLocale reassigns — text captured here would freeze the
21
- * language at boot. Resolve with `t(ACT_LABEL[a])` at the point of use.
25
+ * ⚠️ ELA TEM CONSUMIDOR, e eu já disse aqui que não tinha. A afirmação anterior — «sem um único consumidor,
26
+ * nem aqui nem no `game-platformer`» — vinha de uma varredura com um padrão que **excluía o `main.ts`** do
27
+ * cartucho, por ele estar directamente em `app/js/` e o glob exigir um subdirectório. Medido de novo com
28
+ * `git grep`: `game-platformer/app/js/main.ts` importa-a e usa-a na TELA DE AJUDA do menu de pausa (a linha
29
+ * que lista posição ↔ tecla). O `openHelp()` que o cabeçalho original citava não morreu — mudou de
30
+ * repositório com o cartucho (#111) e continua a ler daqui.
31
+ *
32
+ * ⚠️ E ELA CONTINUA A SER A CAUSA DA #125, o que é diferente de estar morta. O defeito era o `aria-label` da
33
+ * tela de remapeamento ser montado a partir dela: oito posições contra as catorze do vocabulário, e as
34
+ * palavras de UM jogo dentro do motor. Esse uso saiu. O que resta é um consumidor para quem a tabela está
35
+ * certa — porque ele É o jogo de plataforma.
36
+ *
37
+ * ⚠️ REMOVÊ-LA NÃO É LIMPEZA, É MIGRAÇÃO. O cartucho já tem `acoesDoJogo()` (`main.ts:122`), derivado do
38
+ * preset dele; a tela de ajuda passar a usá-lo é edição de lá, e só depois disso é que isto pode sair daqui.
39
+ * Enquanto não sair, quem escrever código NOVO na engine pede a palavra ao jogo por `ctx.acoesDoJogo()` — a
40
+ * engine sabe que a posição existe, só o jogo sabe como ela se chama (ADR-0086).
41
+ *
42
+ * Guarda CHAVES e não texto porque uma `const` de módulo é avaliada uma vez no import, e o `dict` do
43
+ * `core/i18n` é um `let` que o `setLocale` reatribui — texto capturado aqui congelaria o idioma no boot.
22
44
  */
23
45
  export const ACT_LABEL = {
24
46
  left: 'act.left', right: 'act.right', up: 'act.up', down: 'act.down',
@@ -54,13 +76,40 @@ export function keyUsedByOther(code, mapRef, schemes) {
54
76
  schemes.forEach((m, i) => {
55
77
  if (m === mapRef)
56
78
  return;
57
- for (const a in m)
79
+ for (const a of ACTIONS)
58
80
  for (const c of m[a] || [])
59
81
  if (!owners.has(c))
60
82
  owners.set(c, i);
61
83
  });
62
84
  return owners.get(code) ?? -1;
63
85
  }
86
+ /**
87
+ * Qual OUTRA ação DO MESMO esquema já tem `code` — ou `null` se nenhuma.
88
+ *
89
+ * ⚠️ O IRMÃO QUE FALTAVA AO `keyUsedByOther`, E A FALTA ERA INVISÍVEL NUM JOGO DE UM JOGADOR (#126). Aquele
90
+ * exclui o esquema em edição **por referência**; com um jogador só, `schemesFor()` devolve exatamente esse
91
+ * esquema, então a guarda varre uma lista vazia e **nunca pode disparar**. A criança que põe `W` numa ação
92
+ * nova continua com `W` na antiga, e passa o jogo inteiro com as duas a disparar juntas.
93
+ *
94
+ * ⚠️ E O DEFEITO É O PIOR FEITIO POSSÍVEL, escrito no cabeçalho do `input/default-bindings` desde sempre:
95
+ * «as duas ações disparam juntas, e a criança vê uma ação dupla intermitente que ninguém consegue reproduzir
96
+ * de propósito». Numa tela que ela abriu **porque** não conseguia usar os controles padrão.
97
+ *
98
+ * ⚠️ A guarda entre JOGADORES não estava partida — estava inalcançável. Medido na auditoria: com dois
99
+ * assentos ela funciona e recusa certo. O que faltava era a verificação dentro do mesmo esquema.
100
+ *
101
+ * Devolve a AÇÃO e não um booleano, porque o anúncio tem de dizer qual — «essa tecla já está em uso» manda a
102
+ * criança procurar o que a função já sabe.
103
+ */
104
+ export function acaoQueJaTem(code, mapRef, exceto) {
105
+ for (const a of ACTIONS) {
106
+ if (a === exceto)
107
+ continue;
108
+ if ((mapRef[a] || []).includes(code))
109
+ return a;
110
+ }
111
+ return null;
112
+ }
64
113
  export function initSettingsControls(ctx) {
65
114
  let kb = ctx.kb;
66
115
  let capture = null;
@@ -69,6 +118,27 @@ export function initSettingsControls(ctx) {
69
118
  const n = ctx.getNumPlayers();
70
119
  return Array.from({ length: n }, (_, i) => ctx.kbFor(i));
71
120
  }
121
+ /**
122
+ * COMO ESTE JOGO CHAMA esta posição, ou `null` se ele não a nomeia. Um sítio só, porque três pontos
123
+ * precisavam dela e cada um a ia buscar por sua conta — e um deles ia buscá-la à tabela errada (#125).
124
+ *
125
+ * 🔴 O RECUO ERA O ID ABSTRATO (`?? a`), DEFENDIDO AQUI COM «`action3` é feio, mas é verdade». Era um
126
+ * defeito, e o ADR-0074 chama-lhe isso em tantas palavras: «o nome que a CRIANÇA lê e ouve — na tela de
127
+ * remapeamento, na bolha de toque, no anúncio — é sempre a palavra do jogo, nunca `action1`. Um nome
128
+ * abstracto que chega a uma pessoa é um defeito.»
129
+ *
130
+ * ⚠️ E ESTAVA A UM TOQUE DE DISTÂNCIA, com o esquema PADRÃO desta engine: ele liga OITO posições e um quiz
131
+ * nomeia três. A criança escolhia «Confirmar», carregava numa tecla que o padrão tinha em `action2`, e o
132
+ * leitor de tela dizia «Essa tecla já é de action2» — precisamente a ela, que é quem não tem outro canal.
133
+ *
134
+ * 📌 A TERCEIRA SAÍDA JÁ ESTAVA DECIDIDA UM MÓDULO ABAIXO, e este ficheiro tinha decidido outra:
135
+ * `core/actions.labellerFrom` devolve `null` «e quem chama decide — uma ausência vira menos um passo, nunca
136
+ * um passo mudo». Duas respostas à mesma pergunta no mesmo repositório é o defeito que o `DomQuery` já
137
+ * custou dezasseis vezes; agora são uma.
138
+ */
139
+ function palavraDaAcao(a) {
140
+ return ctx.acoesDoJogo().find((x) => x.acao === a)?.rotulo ?? null;
141
+ }
72
142
  function render(selPlayer) {
73
143
  const el = ctx.$('#ctrl-list');
74
144
  if (!el)
@@ -77,10 +147,27 @@ export function initSettingsControls(ctx) {
77
147
  const player = selPlayer >= n ? 0 : selPlayer;
78
148
  lastPlayer = player;
79
149
  // E3: sem abas de outros jogadores — você edita só o seu controle; só o hint muda com o modo.
150
+ //
151
+ // ⚠️ ESTA FRASE ESTAVA EM PORTUGUÊS CRU DENTRO DO MOTOR (#125), inteira, com o `<strong>` e o plural à
152
+ // mão. Vai pelo `t()` agora — e o realce sobrevive porque o molde é PARTIDO no marcador `{modo}` antes
153
+ // da substituição, em vez de o dicionário carregar markup (que o gate `i18n-sem-markup` proíbe, e com
154
+ // razão: string de dicionário que vira markup é a porta por onde uma tradução passa a ser código).
80
155
  const tabs = ctx.$('#ctrl-players');
81
156
  if (tabs) {
82
157
  tabs.hidden = false;
83
- tabs.innerHTML = `<span class="opt-hint" style="width:100%;margin:0">Editando o <strong>seu</strong> controle — modo <strong>${n === 1 ? '1 jogador' : n + ' jogadores'}</strong>.</span>`;
158
+ // O esqueleto por `innerHTML` — ele não tem dado nenhum de fora; o TEXTO entra por `textContent`, que
159
+ // é o mesmo idioma que o `.ctrl-nome` abaixo já usa.
160
+ tabs.innerHTML = '<span class="opt-hint" style="width:100%;margin:0">'
161
+ + '<span data-modo="pre"></span><strong data-modo="v"></strong><span data-modo="pos"></span></span>';
162
+ const [antes, depois] = t('ctrl.editingYours').split('{modo}');
163
+ const posto = (sel, txt) => {
164
+ const el2 = tabs.querySelector(sel);
165
+ if (el2)
166
+ el2.textContent = txt;
167
+ };
168
+ posto('[data-modo="pre"]', antes ?? '');
169
+ posto('[data-modo="v"]', n === 1 ? t('ctrl.mode.one') : t('ctrl.mode.many', { n }));
170
+ posto('[data-modo="pos"]', depois ?? '');
84
171
  }
85
172
  const map = ctx.kbFor(player);
86
173
  // ⚠️ O `rotulo` SAIU DO MARKUP (issue #106) e entra logo abaixo por `textContent`. Ele é a PALAVRA do
@@ -88,24 +175,83 @@ export function initSettingsControls(ctx) {
88
175
  // `data-act="${a}"` fica, e a diferença é a razão: `a` é o nome ABSTRATO da ação, que a engine enumera em
89
176
  // `core/actions`. É separação que o ADR-0086 fez, e é ela que torna um dos dois seguro e o outro não.
90
177
  el.innerHTML = ctx.acoesDoJogo().map(({ acao: a }) => `<div class="ctrl-row"><span><b class="ctrl-nome"></b>: ${(map[a] || []).map(keyName).map((k) => `<kbd>${k}</kbd>`).join(' ')}</span>` +
91
- `<button class="mode-btn" data-act="${a}" type="button" aria-label="${t('ctrl.changeKeyAria', { acao: t(ACT_LABEL[a]), n: player + 1 })}">${t('ctrl.change')}</button></div>`).join('');
92
- // As palavras do jogo, por `textContent` — que escapa por construção. A ordem casa porque é a mesma lista.
178
+ `<button class="mode-btn" data-act="${a}" type="button">${t('ctrl.change')}</button></div>`).join('');
179
+ // As palavras do jogo, por API do DOM — que escapa por construção. A ordem casa porque é a mesma lista.
180
+ //
181
+ // ⚠️ O `aria-label` DESCEU PARA CÁ, E É A CORREÇÃO DA #125. Ele era montado no template acima a partir do
182
+ // `ACT_LABEL`, que ficou a ser a tabela do JOGO DE PLATAFORMA quando a #106 mudou o rótulo visível para o
183
+ // `acoesDoJogo()`. Medido no `game-soccer`: **«Alterar tecla de undefined do Jogador 1» em seis de doze
184
+ // botões**, enquanto uma criança que vê lia «Conter» na mesma linha. E um `aria-label` SOBREPÕE-SE ao
185
+ // texto visível, então quem depende do leitor de tela ouvia a palavra errada nos outros seis — que é pior
186
+ // do que não ter `aria-label` nenhum, e invisível de dentro da engine, porque a plataforma é o único
187
+ // consumidor para o qual a tabela está certa.
188
+ //
189
+ // ⚠️ E DESCEU POR `setAttribute` E NÃO PARA O TEMPLATE, de propósito: o `rotulo` é TEXTO DO JOGO. Metê-lo
190
+ // num `aria-label="…"` dentro de um template literal seria interpolar texto de fora em markup — o mesmo
191
+ // motivo pelo qual o `.ctrl-nome` já entrava por `textContent`.
93
192
  {
94
- const nomes = el.querySelectorAll('.ctrl-nome');
193
+ const linhas = el.querySelectorAll('.ctrl-row');
95
194
  const palavras = ctx.acoesDoJogo();
96
- for (let i = 0; i < nomes.length && i < palavras.length; i++)
97
- nomes[i].textContent = palavras[i].rotulo;
195
+ for (let i = 0; i < linhas.length && i < palavras.length; i++) {
196
+ const nome = linhas[i].querySelector('.ctrl-nome');
197
+ if (nome)
198
+ nome.textContent = palavras[i].rotulo;
199
+ const botao = linhas[i].querySelector('button[data-act]');
200
+ if (botao)
201
+ botao.setAttribute('aria-label', t('ctrl.changeKeyAria', { acao: palavras[i].rotulo, n: player + 1 }));
202
+ }
203
+ }
204
+ /**
205
+ * A MARCA DE «SAIU DO PADRÃO» (ADR-0029), e este era o ÚLTIMO menu sem ela.
206
+ *
207
+ * ⚠️ E É O MENU ONDE ELA MAIS FALTAVA, porque é o único cuja razão de existir é mexer: uma criança que
208
+ * remapeou as teclas percorria a lista, ouvia os nomes das acções, e nada lhe dizia onde ela própria tinha
209
+ * alterado. Os três canais do ADR-0029 — cor, forma (anéis) e NOME — passam a valer aqui.
210
+ *
211
+ * ⚠️ O PADRÃO VEM DO CONSUMIDOR (`ctx.kbPadraoFor`) e não de uma tabela lida aqui, pela mesma razão que o
212
+ * `kbFor` é injectado: o mapeamento «quantos jogadores → que balde» (`p2`/`p3`/`p4`) é dele, e uma segunda
213
+ * cópia dessa regra divergiria da primeira.
214
+ *
215
+ * 🎯 E NÃO SE USA O `resetKB` PARA LER O PADRÃO, embora ele devolva exactamente a configuração de fábrica:
216
+ * ele é DESTRUTIVO — `input/keyboard.resetKB` faz `store.remove(CKEY)` antes de devolver a cópia. Chamá-lo
217
+ * a cada render apagaria o remapeamento da criança, e o estrago só apareceria no arranque seguinte.
218
+ *
219
+ * 📌 COMPARA-SE A LISTA DE CÓDIGOS, não a identidade do objecto: reatribuir a MESMA tecla não é uma
220
+ * mudança, e uma criança que experimenta e volta atrás não pode ficar com a marca acesa para sempre.
221
+ */
222
+ {
223
+ const padrao = ctx.kbPadraoFor(player);
224
+ const atual = ctx.kbFor(player);
225
+ const mesmas = (a, b) => (a ?? []).length === (b ?? []).length && (a ?? []).every((k, i) => k === (b ?? [])[i]);
226
+ for (const linha of el.querySelectorAll('.ctrl-row')) {
227
+ const act = linha.querySelector('button[data-act]')?.dataset.act;
228
+ if (!act || !isAction(act))
229
+ continue;
230
+ markChanged(linha, !mesmas(atual[act], padrao[act]));
231
+ }
98
232
  }
99
233
  el.querySelectorAll('button[data-act]').forEach((b) => {
100
234
  b.addEventListener('click', () => {
235
+ // ⚠️ `isAction` E NÃO SÓ `if (!act)`: o valor vem de um atributo do DOM, e desde a #118 o esquema só
236
+ // aceita as quatorze posições. Uma captura iniciada sobre uma posição inventada gravaria uma tecla
237
+ // numa chave que transporte nenhum lê — a criança carregaria a tecla nova e nada aconteceria.
101
238
  const act = b.dataset.act;
102
- if (!act)
239
+ if (!act || !isAction(act))
240
+ return;
241
+ // ⚠️ SEM PALAVRA, NÃO SE PERGUNTA — a regra do `labellerFrom`, aplicada onde ela é visível: «se o jogo
242
+ // não a usa, não há o que mapear; uma ausência vira menos um passo, nunca um passo mudo». As linhas
243
+ // vêm todas de `acoesDoJogo()`, logo isto não acontece hoje — e é essa garantia que fica escrita em
244
+ // vez de assumida, porque quem a partir amanhã acorda um anúncio sem sujeito.
245
+ const palavra = palavraDaAcao(act);
246
+ if (!palavra)
103
247
  return;
104
248
  capture = { action: act, mapRef: map, player };
105
- b.textContent = 'Pressione…';
106
- ctx.srAlert(t('sr.ctrl.pressNewKey', { acao: t(ACT_LABEL[act]), n: player + 1 }));
249
+ b.textContent = t('ctrl.pressing'); // estava cravado em português dentro do motor (#125)
250
+ ctx.srAlert(t('sr.ctrl.pressNewKey', { acao: palavra, n: player + 1 }));
107
251
  });
108
252
  });
253
+ // A prosa volta para o rodapé depois de as linhas serem reconstruídas (CLAUDE.md §4, #109).
254
+ ctx.fillExplain?.(ctx.$('#options .overlay__card'));
109
255
  }
110
256
  function isCapturing() {
111
257
  return capture !== null;
@@ -128,6 +274,22 @@ export function initSettingsControls(ctx) {
128
274
  e.preventDefault();
129
275
  return true; // não associa: segue capturando
130
276
  }
277
+ // A MESMA guarda, dentro do próprio esquema (#126). Recusa em vez de MOVER, e a escolha tem motivo:
278
+ // mover deixaria a ação antiga com lista vazia — que o `bindingProblems` classifica como problema, e que
279
+ // a criança descobriria no meio do jogo, sem anúncio, com uma ação que deixou de existir. Recusar custa
280
+ // dois passos (soltar a antiga, prender a nova) e não perde nada pelo caminho.
281
+ const aqui = acaoQueJaTem(e.code, capture.mapRef, capture.action);
282
+ if (aqui) {
283
+ // ⚠️ `aqui` VEM DO ESQUEMA, e o esquema liga posições que o jogo pode não nomear — é por aqui que o id
284
+ // abstracto chegava a uma criança. Sem palavra, a frase diz a verdade que INTERESSA («a tecla está
285
+ // ocupada aqui») em vez do nome interno: calar seria o defeito gémeo, e dizer `action2` era o defeito.
286
+ const palavra = palavraDaAcao(aqui);
287
+ ctx.srAlert(palavra
288
+ ? t('sr.ctrl.keyTakenHere', { acao: palavra })
289
+ : t('sr.ctrl.keyTakenHereUnnamed'));
290
+ e.preventDefault();
291
+ return true; // não associa: segue capturando
292
+ }
131
293
  capture.mapRef[capture.action] = [e.code];
132
294
  ctx.store.saveKB(kb);
133
295
  ctx.applyControls();
@@ -31,6 +31,21 @@ export interface EmpathySettingsCtx {
31
31
  reflectVizButtons(): void;
32
32
  /** Brings an overlay to front + fills its footer explanations; shared by every Sensibilidade panel. */
33
33
  frontOverlay(el: HTMLElement | null): void;
34
+ /**
35
+ * Move a prosa das linhas para o rodapé (`ui/settings-panel` → `fillExplain`). Chamado a CADA render.
36
+ *
37
+ * ⚠️ E AQUI A RECONSTRUÇÃO NÃO SE VÊ NO FICHEIRO, que foi o que atrasou este conserto. Este painel não tem
38
+ * um `innerHTML` sequer: quem reconstrói a lista é o `renderVizGroup` injetado, cuja implementação
39
+ * (`render/viz-setters`) faz `el.innerHTML = vizGroupHtml(…)` e devolve `.ctrl-row`s NOVAS, com o
40
+ * `.opt-hint` outra vez lá dentro e sem o `data-explain-done` que torna o `fillExplain` idempotente.
41
+ *
42
+ * Isto acontece a cada clique numa simulação e a cada uso do botão de perda auditiva — que é o pior caso
43
+ * possível, porque a criança que acabou de ligar "Simular cegueira total" está com a tela preta e depende
44
+ * do rodapé `aria-live` para saber onde está.
45
+ *
46
+ * Opcional (`?.`) como nos irmãos: um consumidor que não injete continua a desenhar o painel.
47
+ */
48
+ fillExplain?: (card: HTMLElement | null) => void;
34
49
  /** Devolve o foco a quem abriu o diálogo (ui/settings-panel `restoreFocus`). Injetado, e não um `#opt-*`
35
50
  * fixo: o id que este módulo focava não existe no documento, então fechar deixava o foco no `<body>`. */
36
51
  restoreFocus?: (id: string) => boolean;
@@ -40,6 +40,8 @@ export function initSettingsEmpathy(ctx) {
40
40
  }
41
41
  ctx.reflectMotorEmpathy();
42
42
  refreshMarks();
43
+ // Depois do `renderVizGroup`, e não antes: é ele quem repõe o `.opt-hint` dentro das linhas.
44
+ ctx.fillExplain?.(ctx.$('#empathy .overlay__card'));
43
45
  }
44
46
  /**
45
47
  * A marca de "saiu do padrão" (ADR-0029). Neste menu ela quer dizer "esta simulação está LIGADA", e por
@@ -1,16 +1,10 @@
1
1
  // SPDX-License-Identifier: AGPL-3.0-or-later
2
- import type { PlayerView } from '../core/entity.js';
3
- export type MotionSceneKey = 'parallax' | 'decor' | 'items' | 'particles';
4
- export type MotionCharProp = 'rmWalk' | 'rmBreath' | 'rmFlavor';
5
- export interface MotionCharDef {
6
- readonly prop: MotionCharProp;
7
- readonly lbl: string;
8
- }
9
- /** As três chaves de Movimento Reduzido por personagem, derivadas de core/entity. Eram um
10
- * `Partial<Record<MotionCharProp, boolean>>`, o que dizia a forma certa sem dizer que os campos são DO
11
- * JOGADOR — um `Record` aceita qualquer objeto com essas chaves, inclusive um que não seja jogador nenhum. */
12
- export type MotionPlayer = PlayerView<'rmWalk' | 'rmBreath' | 'rmFlavor'>;
13
- export type MotionSceneFlags = Record<MotionSceneKey, boolean>;
2
+ import type { MotionSceneKey as ChaveDeCenaLeaf, MotionCharProp as PropDoPersonagemLeaf, MotionCharDef as DefDoPersonagemLeaf, MotionPlayer as JogadorDeMovimentoLeaf, MotionSceneFlags as BandeirasDeCenaLeaf } from './motion-scene.js';
3
+ export type MotionSceneKey = ChaveDeCenaLeaf;
4
+ export type MotionCharProp = PropDoPersonagemLeaf;
5
+ export type MotionCharDef = DefDoPersonagemLeaf;
6
+ export type MotionPlayer = JogadorDeMovimentoLeaf;
7
+ export type MotionSceneFlags = BandeirasDeCenaLeaf;
14
8
  export interface SettingsMotionCtx {
15
9
  /** Quantos jogadores/telas. Estado de RODADA (ADR-0038): vem da instância que a raiz possui.
16
10
  * Era `numPlayers`, um `let` de `core/state` importado como binding vivo — e um `let` de módulo
@@ -34,15 +28,27 @@ export interface SettingsMotionCtx {
34
28
  restoreFocus?: (id: string) => boolean;
35
29
  /** Reflete on/off num botão (classe is-on + aria-pressed) — helper genérico usado por vários botões-mestre. */
36
30
  toggleBtn: (el: HTMLElement, on: boolean) => void;
37
- /** Movimento reduzido de CENA (parallax/decor/items/particles) — objeto VIVO, mutado in-place. Fica em game.js:
38
- * applyCalm() (modo TEA) também o usa, não é exclusivo deste painel. */
39
- rm: MotionSceneFlags;
40
- /** Persiste `rm` (localStorage 'inclusionist.reducedmotion.v1') — mesmo motivo, fica em game.js. */
41
- saveRM: () => void;
31
+ /** Movimento reduzido de CENA (parallax/decor/items/particles) — objeto VIVO, mutado in-place. */
32
+ rm?: MotionSceneFlags;
33
+ /** Persiste `rm` (localStorage 'inclusionist.reducedmotion.v1'). */
34
+ saveRM?: () => void;
42
35
  /** As 4 chaves de cena — a MESMA array que applyCalm() itera. */
43
- rmKeys: readonly MotionSceneKey[];
36
+ rmKeys?: readonly MotionSceneKey[];
44
37
  /** Os 3 alvos de movimento reduzido do PERSONAGEM — a MESMA array que applyCalm() itera. */
45
- rmChar: readonly MotionCharDef[];
38
+ rmChar?: readonly MotionCharDef[];
39
+ /**
40
+ * Move a prosa das linhas para o rodapé (`ui/settings-panel` → `fillExplain`). Chamado a CADA render.
41
+ *
42
+ * ⚠️ NÃO É OPCIONAL POR ELEGÂNCIA: `fillExplain` roda uma vez quando o overlay é frontalizado e move o
43
+ * `.opt-hint` de dentro de cada linha para o rodapé. Este painel RECONSTRÓI as linhas, e as linhas novas
44
+ * voltam com a prosa lá dentro — então a explicação aparece duas vezes, no rodapé e sob o rótulo, a
45
+ * partir do primeiro clique. O `CLAUDE.md` §4 regista exatamente isto, e a issue #109 já o consertou
46
+ * uma vez noutros painéis.
47
+ *
48
+ * Opcional na assinatura porque um consumidor pode montar o painel sem a casca (um teste, o segundo
49
+ * consumidor): sem casca não há rodapé para duplicar.
50
+ */
51
+ fillExplain?: (card: HTMLElement | null) => void;
46
52
  }
47
53
  /**
48
54
  * Alvo → CHAVE i18n do rótulo. CHAVES, e não texto, pelo motivo de sempre: uma tabela de `const` com texto