@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
@@ -16,6 +16,7 @@ import { CRT, CRT_DEFAULT, applyCrt } from '../render/crt.js';
16
16
  import { defaultReducedMotion } from '../core/state.js';
17
17
  import { markChanged, markMenuChanged } from './changed-mark.js';
18
18
  import { t } from '../core/i18n.js';
19
+ import { CHAVES_DE_CENA, ANIMACOES_DO_PERSONAGEM, lerCenaGuardada, guardarCena } from './motion-scene.js';
19
20
  /**
20
21
  * Alvo → CHAVE i18n do rótulo. CHAVES, e não texto, pelo motivo de sempre: uma tabela de `const` com texto
21
22
  * resolve UMA vez, no import, e fica congelada no idioma do boot.
@@ -112,10 +113,20 @@ export function getSelectedPlayer() { return selectedPlayer; }
112
113
  /** Chamado de fora (ex.: o atalho "anim" do menu de pausa) antes de open(). */
113
114
  export function setSelectedPlayer(i) { selectedPlayer = i; }
114
115
  export function initSettingsMotion(ctx) {
116
+ /*
117
+ * ⚠️ RESOLVIDAS UMA VEZ, NO ARRANQUE, e não a cada uso. O `rm` é mutado in-place e partilhado por
118
+ * REFERÊNCIA com quem desenha a cena; resolvê-lo a cada leitura criaria um objecto novo por chamada, o
119
+ * interruptor deixaria de alcançar o desenho, e não haveria erro nenhum — o menu diria «reduzido» e a cena
120
+ * continuaria a mexer-se.
121
+ */
122
+ const rm = ctx.rm ?? lerCenaGuardada();
123
+ const rmKeys = ctx.rmKeys ?? CHAVES_DE_CENA;
124
+ const rmChar = ctx.rmChar ?? ANIMACOES_DO_PERSONAGEM;
125
+ const saveRM = ctx.saveRM ?? (() => guardarCena(rm));
115
126
  function reflectMotionBtn() {
116
127
  const b = ctx.$('#opt-animation');
117
128
  if (b)
118
- b.classList.toggle('is-on', ctx.rmKeys.some((k) => ctx.rm[k]));
129
+ b.classList.toggle('is-on', rmKeys.some((k) => rm[k]));
119
130
  }
120
131
  function updateMotionMaster() {
121
132
  reflectMotionBtn();
@@ -123,7 +134,7 @@ export function initSettingsMotion(ctx) {
123
134
  if (!m)
124
135
  return;
125
136
  const player = ctx.getPlayers()[selectedPlayer];
126
- const allFrozen = allMotionFrozen(ctx.rmKeys, ctx.rm, ctx.rmChar, player);
137
+ const allFrozen = allMotionFrozen(rmKeys, rm, rmChar, player);
127
138
  m.textContent = motionMasterLabel(allFrozen);
128
139
  ctx.toggleBtn(m, allFrozen);
129
140
  }
@@ -142,11 +153,13 @@ export function initSettingsMotion(ctx) {
142
153
  tabs.querySelectorAll('button[data-ap]').forEach((b) => b.addEventListener('click', () => {
143
154
  selectedPlayer = Number(b.dataset.ap);
144
155
  render();
156
+ // A prosa volta para o rodapé depois de as linhas serem reconstruídas (CLAUDE.md §4, #109).
157
+ ctx.fillExplain?.(ctx.$('#animation .overlay__card'));
145
158
  }));
146
159
  }
147
160
  const player = ctx.getPlayers()[selectedPlayer];
148
- const charRows = buildCharRowsHtml(ctx.rmChar, player);
149
- const sceneRows = buildSceneRowsHtml(ctx.rmKeys, ctx.rm, RM_LABEL, RM_SOON);
161
+ const charRows = buildCharRowsHtml(rmChar, player);
162
+ const sceneRows = buildSceneRowsHtml(rmKeys, rm, RM_LABEL, RM_SOON);
150
163
  const crtRows = crtToggleRowHtml(t(CRT_LBL.scan), 'scan', !!CRT.scan) + crtToggleRowHtml(t(CRT_LBL.vig), 'vig', !!CRT.vig) + crtRoundRowHtml(t(CRT_LBL.round), CRT.round);
151
164
  el.innerHTML =
152
165
  `<h3 class="panel-sub">Personagem${ctx.getNumPlayers() > 1 ? ' · Jogador ' + (selectedPlayer + 1) : ''} <span class="panel-sub__tag">por jogador</span></h3>${charRows}` +
@@ -174,11 +187,11 @@ export function initSettingsMotion(ctx) {
174
187
  }));
175
188
  el.querySelectorAll('button[data-rm]').forEach((b) => b.addEventListener('click', () => {
176
189
  const k = b.dataset.rm;
177
- ctx.rm[k] = !ctx.rm[k];
178
- ctx.saveRM();
190
+ rm[k] = !rm[k];
191
+ saveRM();
179
192
  render();
180
193
  updateMotionMaster();
181
- ctx.srSay(sceneMotionAnnouncement(t(RM_LABEL[k]), ctx.rm[k]));
194
+ ctx.srSay(sceneMotionAnnouncement(t(RM_LABEL[k]), rm[k]));
182
195
  }));
183
196
  updateMotionMaster();
184
197
  refreshMarks();
@@ -200,10 +213,10 @@ export function initSettingsMotion(ctx) {
200
213
  mudou.push(changed);
201
214
  markChanged(el?.querySelector(sel)?.closest('.ctrl-row') ?? null, changed);
202
215
  };
203
- for (const c of ctx.rmChar)
216
+ for (const c of rmChar)
204
217
  marcar(`[data-rmc="${c.prop}"]`, !!(player && player[c.prop]) !== padraoRm);
205
- for (const k of ctx.rmKeys)
206
- marcar(`[data-rm="${k}"]`, !!ctx.rm[k] !== padraoRm);
218
+ for (const k of rmKeys)
219
+ marcar(`[data-rm="${k}"]`, !!rm[k] !== padraoRm);
207
220
  marcar('[data-crt-tgl="scan"]', !!CRT.scan !== !!CRT_DEFAULT.scan);
208
221
  marcar('[data-crt-tgl="vig"]', !!CRT.vig !== !!CRT_DEFAULT.vig);
209
222
  marcar('[data-crt="round"]', CRT.round !== CRT_DEFAULT.round);
@@ -222,11 +235,11 @@ export function initSettingsMotion(ctx) {
222
235
  if (resetBtn)
223
236
  resetBtn.addEventListener('click', () => {
224
237
  const padraoRm = defaultReducedMotion();
225
- for (const k of ctx.rmKeys)
226
- ctx.rm[k] = padraoRm;
227
- ctx.saveRM();
238
+ for (const k of rmKeys)
239
+ rm[k] = padraoRm;
240
+ saveRM();
228
241
  ctx.getPlayers().forEach((p, i) => {
229
- for (const c of ctx.rmChar) {
242
+ for (const c of rmChar) {
230
243
  p[c.prop] = padraoRm;
231
244
  ctx.store.setBool('incl_' + c.prop + '_p' + i, padraoRm);
232
245
  }
@@ -264,13 +277,13 @@ export function initSettingsMotion(ctx) {
264
277
  if (master)
265
278
  master.addEventListener('click', () => {
266
279
  const player = ctx.getPlayers()[selectedPlayer];
267
- const allFrozen = allMotionFrozen(ctx.rmKeys, ctx.rm, ctx.rmChar, player);
280
+ const allFrozen = allMotionFrozen(rmKeys, rm, rmChar, player);
268
281
  const next = !allFrozen;
269
- for (const k of ctx.rmKeys)
270
- ctx.rm[k] = next;
271
- ctx.saveRM();
282
+ for (const k of rmKeys)
283
+ rm[k] = next;
284
+ saveRM();
272
285
  if (player)
273
- for (const c of ctx.rmChar) {
286
+ for (const c of rmChar) {
274
287
  player[c.prop] = next;
275
288
  ctx.store.setBool('incl_' + c.prop + '_p' + selectedPlayer, next);
276
289
  }
@@ -12,7 +12,7 @@ export interface MotorStore {
12
12
  }
13
13
  /** Minimal per-player shape this module reads/writes (core/state.ts's `players` entries carry much more). */
14
14
  /** As duas escolhas motoras por jogador: modo Fácil e teclas de alternância. */
15
- export type MotorPlayer = PlayerView<'easy' | 'toggleMove' | 'toggleRun'>;
15
+ export type MotorPlayer = PlayerView<'easy' | 'toggleMove' | 'toggleRun' | 'walkDir'>;
16
16
  export interface SettingsMotorCtx {
17
17
  /** DOM selector (querySelector), injected — never reaches `document` globally. */
18
18
  $: DomQuery;
@@ -25,7 +25,14 @@ export interface SettingsMotorCtx {
25
25
  /** Live player count (core/state.ts's `numPlayers`); a getter because the value is reassigned over time. */
26
26
  getNumPlayers: () => number;
27
27
  /** SHARED setter (also used by the pause-menu quick icon `altmove`) — stays in game.js, injected. */
28
- setToggleMove: (i: number, on: boolean) => void;
28
+ setToggleMove?: (i: number, on: boolean) => void;
29
+ /**
30
+ * QUAL APARELHO ESTE JOGADOR ESTÁ A USAR (ADR-0113) — atravessa daqui para a escrita.
31
+ *
32
+ * ⚠️ Opcional pela mesma razão que na `EscritaDaAlternanciaCtx`: sem ele a escrita cai no que já fazia,
33
+ * e exigi-lo quebraria todo consumidor por causa de uma migração a meio.
34
+ */
35
+ transporteEmUso?: (jogador: number) => string;
29
36
  /**
30
37
  * A ALTERNÂNCIA DO BOTÃO DE CORRER. Injetada como a irmã acima e pelo mesmo motivo: quem persiste e anuncia
31
38
  * é a raiz de composição, que é quem conhece `players` e o armazenamento.
@@ -33,6 +40,19 @@ export interface SettingsMotorCtx {
33
40
  setToggleRun: (i: number, on: boolean) => void;
34
41
  /** Coin layout depends on any player's Modo Fácil (moedas no chão) — owned by the coin subsystem, injected. */
35
42
  rebuildCoins: () => void;
43
+ /**
44
+ * Move a prosa das linhas para o rodapé (`ui/settings-panel` → `fillExplain`). Chamado a CADA render.
45
+ *
46
+ * ⚠️ NÃO É OPCIONAL POR ELEGÂNCIA: `fillExplain` roda uma vez quando o overlay é frontalizado e move o
47
+ * `.opt-hint` de dentro de cada linha para o rodapé. Este painel RECONSTRÓI as linhas, e as linhas novas
48
+ * voltam com a prosa lá dentro — então a explicação aparece duas vezes, no rodapé e sob o rótulo, a
49
+ * partir do primeiro clique. O `CLAUDE.md` §4 regista exatamente isto, e a issue #109 já o consertou
50
+ * uma vez noutros painéis.
51
+ *
52
+ * Opcional na assinatura porque um consumidor pode montar o painel sem a casca (um teste, o segundo
53
+ * consumidor): sem casca não há rodapé para duplicar.
54
+ */
55
+ fillExplain?: (card: HTMLElement | null) => void;
36
56
  }
37
57
  export interface SettingsMotorApi {
38
58
  /** Re-renders #movement-players (kept `hidden`, per E3 — see playerTabsHTML) and (re)wires its buttons. */
@@ -52,8 +72,66 @@ export interface SettingsMotorApi {
52
72
  }
53
73
  /** localStorage key for a player's Modo Fácil flag (== platform/storage.ts's `easy_p{i}` pattern). */
54
74
  export declare function easyKey(i: number): string;
55
- /** localStorage key da alternância do botão de CORRER (== `toggleRunP` de platform/storage). */
75
+ /**
76
+ * localStorage key da alternância do botão de CORRER, na forma LEGADA (sem transporte).
77
+ *
78
+ * ⚠️ ERA UMA CÓPIA DO LITERAL, com um comentário ao lado a dizer «== `toggleRunP` de platform/storage» — o
79
+ * que é a admissão do defeito escrita como se fosse documentação. Duas cópias de um nome mudam uma de cada
80
+ * vez, e o `platform/storage` já tinha escrito a razão de as chaves serem funções: «virar função aqui é o
81
+ * que impede que um deles escreva num nome torto». Agora delega, e há um nome só.
82
+ *
83
+ * ⚠️ E É A CHAVE LEGADA. O ADR-0104 §C pôs o TRANSPORTE no nome, porque a alternância é do aparelho e não da
84
+ * pessoa; esta continua a ser lida para herdar o que a criança já tinha, e não é escrita. A chave nova é
85
+ * `chaveDaAlternancia`, em `input/latch-scope`.
86
+ */
56
87
  export declare function toggleRunKey(i: number): string;
88
+ /** localStorage key da alternância de MARCHA, por jogador. Delega, como os dois irmãos acima. */
89
+ export declare function toggleMoveKey(i: number): string;
90
+ /**
91
+ * A fatia mínima que a escrita da alternância de marcha toca.
92
+ *
93
+ * ⚠️ `walkDir` ENTRA, e não é detalhe: desligar a alternância tem de PARAR quem está a andar por travamento.
94
+ * Sem isso, a criança desliga o modo e a personagem continua a andar sozinha, sem tecla nenhuma premida —
95
+ * e não há erro nenhum a dizê-lo.
96
+ *
97
+ * ⚠️ E É FATIA PRÓPRIA, e não o `MotorPlayer`, porque os DOIS chamadores têm fatias diferentes: o painel
98
+ * motor traz `easy`/`toggleRun` que isto não lê, e o `PausePlayer` traz o visual e as três do movimento
99
+ * reduzido. Uma fatia mínima é o que deixa os dois passarem sem que nenhum tenha de carregar o do outro.
100
+ * `MotorPlayer` e `PausePlayer` ganharam `walkDir` — quebra declarada, porque o campo é do `PlayerBase` e
101
+ * todo jogador da engine já o tem.
102
+ */
103
+ export type JogadorDaAlternancia = PlayerView<'toggleMove' | 'walkDir'>;
104
+ /** O que a escrita precisa de saber. Tudo o que está aqui já vive no `SettingsMotorCtx` e no `PauseIconsCtx`. */
105
+ export interface EscritaDaAlternanciaCtx {
106
+ readonly players: readonly JogadorDaAlternancia[];
107
+ readonly store: {
108
+ setBool(key: string, on: boolean): void;
109
+ };
110
+ readonly srSay: (msg: string) => void;
111
+ readonly getNumPlayers: () => number;
112
+ /**
113
+ * QUAL APARELHO ESTE JOGADOR ESTÁ A USAR (ADR-0113). `input/state.entradaDe(i).emUso` é quem responde.
114
+ *
115
+ * ⚠️ OPCIONAL DE PROPÓSITO, e a razão é o que acontece sem ele: a escrita cai exactamente no que já fazia
116
+ * hoje — só a chave por jogador. Torná-lo obrigatório quebraria todo consumidor que constrói este ctx,
117
+ * por causa de uma migração que ainda não terminou, e o `holdsAtOnce` já mostrou o que isso custa.
118
+ *
119
+ * 📌 E é INJECTADO em vez de importado: `ui/` a ler estado de módulo de `input/` é uma aresta nova entre
120
+ * duas camadas, para poupar um argumento. Este ctx já recebe tudo o resto assim.
121
+ */
122
+ readonly transporteEmUso?: (jogador: number) => string;
123
+ }
124
+ /**
125
+ * LIGA OU DESLIGA A ALTERNÂNCIA DE MARCHA DE UM JOGADOR — e agora é a engine que o faz (ADR-0106 §4).
126
+ *
127
+ * ⚠️ O COMENTÁRIO QUE JUSTIFICAVA A INJEÇÃO ARGUMENTAVA CONTRA ELA. Ele dizia: «SHARED setter (also used by
128
+ * the pause-menu quick icon `altmove`) — stays in game.js, injected». Ser partilhado por DUAS superfícies da
129
+ * engine é razão para a engine o possuir, não para o cartucho o guardar — e a medição de 2026-09-08 mostra
130
+ * que cada passo já era da engine: `toggleMove` e `walkDir` são campos do `PlayerBase`, a chave é do
131
+ * `platform/storage`, e `sr.motor.toggleMove*` são chaves i18n da engine. Não sobrava efeito de jogo nenhum,
132
+ * o que faz deste o mais limpo dos sete: aqui não há sequer um efeito colateral a injectar.
133
+ */
134
+ export declare function definirAlternanciaDeMarcha(ctx: EscritaDaAlternanciaCtx, i: number, on: boolean): void;
57
135
  /** Clamps the selected player back to 0 once it falls outside 0..numPlayers-1 (e.g. player count dropped). */
58
136
  export declare function clampSelPlayer(sel: number, numPlayers: number): number;
59
137
  /** Whether ANY player currently uses Modo Fácil or alternância — lights the #opt-movement bar button. */
@@ -3,25 +3,86 @@
3
3
  // renderMovPlayers()/reflectFacil()/reflectAltMove()/setEasy() (~line 2136). Pure logic (per-player tab
4
4
  // clamp/view-model, the "any player active" predicate, the Modo Fácil announcement text) is separated from the
5
5
  // thin DOM-touching render/reflect functions. DI via initSettingsMotor(ctx): `$` (DOM selector), `srSay`,
6
- // `store` (platform/storage shape), `players`/`getNumPlayers` (core/state.ts's live state) and two setters that
7
- // stay OUTSIDE this module because they are shared with other UI surfaces: `setToggleMove` (also fired by the
8
- // pause-menu quick-icon `iconAct('altmove', …)`, unrelated to this panel) and `rebuildCoins` (coin subsystem,
9
- // Modo Fácil puts coins on the ground). Overlay open/close plumbing (frontOverlay, #movement hidden toggle,
6
+ // `store` (platform/storage shape), `players`/`getNumPlayers` (core/state.ts's live state) and `rebuildCoins`
7
+ // (coin subsystem, Modo Fácil puts coins on the ground).
8
+ //
9
+ // ⚠️ `setToggleMove` DEIXOU DE ESTAR NESSA LISTA em 2026-09-08 (ADR-0106 §4, etapa 1b), e o que ela dava como
10
+ // razão era o argumento contrário: dizia que ele «fica fora deste módulo porque é PARTILHADO com outra
11
+ // superfície da interface» — o ícone `altmove` da pausa. Ser partilhado por duas superfícies da ENGINE é razão
12
+ // para a engine o possuir. `definirAlternanciaDeMarcha` mora aqui; o campo do `ctx` ficou OPCIONAL, então
13
+ // quem injecta continua a mandar e quem não injecta deixa de ficar sem ele.
14
+ // Overlay open/close plumbing (frontOverlay, #movement hidden toggle,
10
15
  // Escape handling, renderMapHub) is the SHARED helper used by every settings panel and stays in game.js.
11
16
  import { toggleLabel } from './dom.js';
12
17
  import { t } from '../core/i18n.js';
13
18
  import { markChanged, markMenuChanged } from './changed-mark.js';
14
19
  import { DEFAULTS } from '../core/state.js';
20
+ // ⚠️ IMPORT DIRETO, e não uma peça a mais no `ctx`, pela mesma razão que o `ui/pause-icons` importa
21
+ // `platform/storage`: um nome de chave injetado é um campo que um consumidor pode omitir, e omiti-lo aqui
22
+ // faria o painel escrever num nome torto — que é o defeito que este import acaba de fechar.
23
+ import { KEYS } from '../platform/storage.js';
24
+ import { gravarAlternancia } from '../input/latch-store.js';
25
+ import { recusaDaAlternancia } from './latch-refusal.js';
15
26
  // ---------------------------------------------------------------------------------------------
16
27
  // Pure logic (no `document`, testable in node)
17
28
  // ---------------------------------------------------------------------------------------------
18
29
  /** localStorage key for a player's Modo Fácil flag (== platform/storage.ts's `easy_p{i}` pattern). */
19
30
  export function easyKey(i) {
20
- return 'incl_easy_p' + i;
31
+ // ⚠️ ERA A ÚLTIMA CÓPIA DO LITERAL neste ficheiro, e o irmão logo abaixo (`toggleRunKey`) já regista por
32
+ // extenso porque isso é defeito: «duas cópias de um nome mudam uma de cada vez». O `platform/storage` diz o
33
+ // resto — as chaves são funções «para impedir que um deles escreva num nome torto». Passada em 2026-09-08,
34
+ // ao acrescentar o terceiro irmão; um gate afirma agora que os três concordam com o `KEYS`.
35
+ return KEYS.easyP(i);
21
36
  }
22
- /** localStorage key da alternância do botão de CORRER (== `toggleRunP` de platform/storage). */
37
+ /**
38
+ * localStorage key da alternância do botão de CORRER, na forma LEGADA (sem transporte).
39
+ *
40
+ * ⚠️ ERA UMA CÓPIA DO LITERAL, com um comentário ao lado a dizer «== `toggleRunP` de platform/storage» — o
41
+ * que é a admissão do defeito escrita como se fosse documentação. Duas cópias de um nome mudam uma de cada
42
+ * vez, e o `platform/storage` já tinha escrito a razão de as chaves serem funções: «virar função aqui é o
43
+ * que impede que um deles escreva num nome torto». Agora delega, e há um nome só.
44
+ *
45
+ * ⚠️ E É A CHAVE LEGADA. O ADR-0104 §C pôs o TRANSPORTE no nome, porque a alternância é do aparelho e não da
46
+ * pessoa; esta continua a ser lida para herdar o que a criança já tinha, e não é escrita. A chave nova é
47
+ * `chaveDaAlternancia`, em `input/latch-scope`.
48
+ */
23
49
  export function toggleRunKey(i) {
24
- return 'incl_togglerun_p' + i;
50
+ return KEYS.toggleRunP(i);
51
+ }
52
+ /** localStorage key da alternância de MARCHA, por jogador. Delega, como os dois irmãos acima. */
53
+ export function toggleMoveKey(i) {
54
+ return KEYS.toggleMoveP(i);
55
+ }
56
+ /**
57
+ * LIGA OU DESLIGA A ALTERNÂNCIA DE MARCHA DE UM JOGADOR — e agora é a engine que o faz (ADR-0106 §4).
58
+ *
59
+ * ⚠️ O COMENTÁRIO QUE JUSTIFICAVA A INJEÇÃO ARGUMENTAVA CONTRA ELA. Ele dizia: «SHARED setter (also used by
60
+ * the pause-menu quick icon `altmove`) — stays in game.js, injected». Ser partilhado por DUAS superfícies da
61
+ * engine é razão para a engine o possuir, não para o cartucho o guardar — e a medição de 2026-09-08 mostra
62
+ * que cada passo já era da engine: `toggleMove` e `walkDir` são campos do `PlayerBase`, a chave é do
63
+ * `platform/storage`, e `sr.motor.toggleMove*` são chaves i18n da engine. Não sobrava efeito de jogo nenhum,
64
+ * o que faz deste o mais limpo dos sete: aqui não há sequer um efeito colateral a injectar.
65
+ */
66
+ export function definirAlternanciaDeMarcha(ctx, i, on) {
67
+ const p = ctx.players[i];
68
+ if (!p)
69
+ return;
70
+ p.toggleMove = on;
71
+ // ⚠️ AS DUAS CHAVES, E A ANTIGA NÃO SAI AINDA — é a forma do `p.visual` ao lado do `p.viz` (#104 etapa 1a),
72
+ // e pela mesma razão: quem LÊ ainda é o cartucho, por `KEYS.toggleMoveP(i)` (`main.ts:540`). Parar de a
73
+ // escrever agora faria a criança perder a escolha no arranque seguinte — o defeito que o ADR-0113 nomeia
74
+ // como a cláusula que decide se a decisão custa um ajuste real no dia em que sai.
75
+ ctx.store.setBool(toggleMoveKey(i), on);
76
+ // 📌 E a chave NOVA, quando se sabe o aparelho. `gravarAlternancia` recusa-se nos quatro assistidos, onde
77
+ // não há escolha a guardar (ADR-0113 cláusula 3) — e devolve `false` para quem chama desabilitar o
78
+ // controle com o motivo dito. Aqui a recusa não muda mais nada: o valor em memória continua a ser o que
79
+ // a regra resolve, e é ela que responde `true` naqueles quatro.
80
+ const transporte = ctx.transporteEmUso ? ctx.transporteEmUso(i) : null;
81
+ if (transporte)
82
+ gravarAlternancia((chave, ligada) => ctx.store.setBool(chave, ligada), 'togglemove', i, transporte, on);
83
+ if (!on)
84
+ p.walkDir = 0;
85
+ ctx.srSay(playerPrefix(i, ctx.getNumPlayers()) + t(on ? 'sr.motor.toggleMoveOn' : 'sr.motor.toggleMoveOff'));
25
86
  }
26
87
  /** Clamps the selected player back to 0 once it falls outside 0..numPlayers-1 (e.g. player count dropped). */
27
88
  export function clampSelPlayer(sel, numPlayers) {
@@ -59,9 +120,27 @@ export function easyAnnouncement(i, numPlayers, on) {
59
120
  // DOM-facing (thin) — requires `document`/injected ctx
60
121
  // ---------------------------------------------------------------------------------------------
61
122
  export function initSettingsMotor(ctx) {
123
+ // ⚠️ RESOLVIDO UMA VEZ: quem injecta manda, quem não injecta passa a ter. A engine sabe fazê-lo sozinha
124
+ // desde 2026-09-08 — ver definirAlternanciaDeMarcha, e o comentário do campo, que argumentava contra si.
125
+ const setToggleMove = ctx.setToggleMove ?? ((i, on) => definirAlternanciaDeMarcha(ctx, i, on));
62
126
  let selMovPlayer = 0; // jogador selecionado no painel Acessibilidade motora
63
127
  const facilBtn = ctx.$('#opt-facil');
64
128
  const altMoveBtn = ctx.$('#opt-altmove');
129
+ /**
130
+ * A DICA ORIGINAL DA LINHA, guardada uma vez.
131
+ *
132
+ * ⚠️ AQUI A RECUSA VAI E VEM, e é essa a diferença para o precedente. O `render/viz-setters` ACRESCENTA
133
+ * o motivo à dica e nunca o retira, o que é correcto lá: aquela lista é reconstruída a cada render. Este
134
+ * botão é persistente e a criança pode largar a webcam e voltar ao teclado — sem guardar o texto de
135
+ * origem, o motivo acumular-se-ia na linha a cada troca de aparelho.
136
+ */
137
+ const altMoveRow = altMoveBtn?.closest('.ctrl-row') ?? null;
138
+ const altMoveHint = altMoveRow?.querySelector('.opt-hint') ?? null;
139
+ const dicaOriginal = altMoveHint?.textContent ?? '';
140
+ /** A recusa DESTE jogador agora, ou `null`. Recalculada a cada reflexo: o aparelho em uso muda. */
141
+ function recusaAgora(i) {
142
+ return ctx.transporteEmUso ? recusaDaAlternancia(ctx.transporteEmUso(i)) : null;
143
+ }
65
144
  const toggleRunBtn = ctx.$('#opt-togglerun');
66
145
  // barra acende se QUALQUER jogador usa Fácil/alternância
67
146
  function reflectMovementBtn() {
@@ -122,6 +201,23 @@ export function initSettingsMotor(ctx) {
122
201
  altMoveBtn.classList.toggle('is-on', on);
123
202
  altMoveBtn.setAttribute('aria-pressed', String(on));
124
203
  altMoveBtn.textContent = onOffLabel(on);
204
+ /*
205
+ * ⚠️ A CLÁUSULA 3 DO ADR-0113 NA TELA: onde a alternância é exigida, o controle NÃO SOME — fica
206
+ * `aria-disabled` e o motivo entra na dica, que a casca (`ui/settings-panel.fillExplain`) move para o
207
+ * rodapé. Sumir ensinaria que a coisa não existe; deixá-lo activo faria a criança carregar e não
208
+ * perceber por que nada mudou.
209
+ *
210
+ * 📌 E `aria-disabled` e não `disabled`: um botão desabilitado de verdade SAI da ordem de tabulação, e
211
+ * quem navega por teclado deixaria de o alcançar — logo deixaria de poder LER o motivo. É a mesma
212
+ * escolha que a #128 nomeia como defeito quando é feita ao contrário (só classe CSS, sem `aria`).
213
+ */
214
+ const recusa = recusaAgora(selMovPlayer);
215
+ if (recusa)
216
+ altMoveBtn.setAttribute('aria-disabled', 'true');
217
+ else
218
+ altMoveBtn.removeAttribute('aria-disabled');
219
+ if (altMoveHint)
220
+ altMoveHint.textContent = recusa ? `${dicaOriginal} ${t(recusa.chave)}`.trim() : dicaOriginal;
125
221
  }
126
222
  reflectMovementBtn();
127
223
  }
@@ -151,13 +247,26 @@ export function initSettingsMotor(ctx) {
151
247
  reflectAltMove();
152
248
  });
153
249
  });
250
+ // A prosa volta para o rodapé depois de as linhas serem reconstruídas (CLAUDE.md §4, #109).
251
+ ctx.fillExplain?.(ctx.$('#movement .overlay__card'));
154
252
  }
155
253
  if (facilBtn) {
156
254
  facilBtn.addEventListener('click', () => setEasy(selMovPlayer, !ctx.players[selMovPlayer].easy));
157
255
  }
158
256
  if (altMoveBtn) {
159
257
  altMoveBtn.addEventListener('click', () => {
160
- ctx.setToggleMove(selMovPlayer, !ctx.players[selMovPlayer].toggleMove);
258
+ /*
259
+ * ⚠️ RECUSAR DIZENDO, E NÃO EM SILÊNCIO. O precedente (`render/viz-setters`) resolve isto não ligando
260
+ * ouvinte nenhum — pode, porque reconstrói a lista a cada render. Aqui o ouvinte é ligado uma vez, e
261
+ * um `return` mudo seria «aceitar o clique e ignorá-lo», que é a outra metade do que o ADR-0076
262
+ * proíbe. Então a recusa FALA: quem carregou fica a saber por quê, mesmo sem ver a dica.
263
+ */
264
+ const recusa = recusaAgora(selMovPlayer);
265
+ if (recusa) {
266
+ ctx.srSay(t(recusa.chave));
267
+ return;
268
+ }
269
+ setToggleMove(selMovPlayer, !ctx.players[selMovPlayer].toggleMove);
161
270
  reflectAltMove();
162
271
  });
163
272
  }
@@ -191,7 +300,7 @@ export function initSettingsMotor(ctx) {
191
300
  if (p.easy)
192
301
  setEasy(i, false);
193
302
  if (p.toggleMove)
194
- ctx.setToggleMove(i, false);
303
+ setToggleMove(i, false);
195
304
  if (p.toggleRun)
196
305
  ctx.setToggleRun(i, false);
197
306
  });
@@ -38,7 +38,15 @@ export interface SettingsTypoApi {
38
38
  /** Currently selected font key. */
39
39
  getFontKey: () => string;
40
40
  }
41
- /** A key is selectable when it exists in the catalog and is not marked `.off` (licence pending, etc.). */
41
+ /**
42
+ * A key is selectable when it exists in the catalog, is not marked `.off` (licence pending, etc.) — and is
43
+ * `geral`.
44
+ *
45
+ * ⚠️ O TERCEIRO TERMO ENTROU EM 2026-09-07 (issue #87), e sem ele o resto da mudança seria decoração: o menu
46
+ * deixaria de OFERECER as caligráficas e elas continuariam SELECIONÁVEIS por qualquer outro caminho — o
47
+ * `resolveFontKey` de uma chave guardada, um `data-font` num markup de consumidor. «Não está na lista» e «não
48
+ * pode ser escolhida» têm de ser a mesma afirmação, ou a lista é só uma sugestão.
49
+ */
42
50
  export declare function isSelectableFont(k: string): boolean;
43
51
  export { resolveFontKey, persistFontKey } from './fonts.js';
44
52
  import type { DomQuery } from '../core/dom-query.js';
@@ -67,8 +75,30 @@ export interface TypoGroupView {
67
75
  g: string;
68
76
  rows: TypoRow[];
69
77
  }
78
+ /**
79
+ * UMA linha da lista, a partir de uma face do catálogo. Extraída em 2026-09-07 (issue #87) porque o gate do
80
+ * mecanismo `.off` precisava de a exercitar com uma face de MENTIRA: as duas únicas entradas desligadas
81
+ * saíram do roster, e um caso que dependa da composição do catálogo reprova sempre que o roster muda.
82
+ *
83
+ * O mecanismo fica, e é preciso: o item 4 da #87 usa-o para a **Ronde**, que só pode ser oferecida se uma de
84
+ * três faces estiver instalada, porque duas delas são gratuitas apenas para uso pessoal e não podem ser
85
+ * empacotadas.
86
+ */
87
+ export declare function linhaDaFonte(it: FontItem, fontKey: string): TypoRow;
70
88
  /** Pure view-model for the typography list: which row is selected/disabled and its note, per catalog group. */
71
89
  export declare function typoGroups(fontKey: string): TypoGroupView[];
72
- /** Full innerHTML for #typo-list, given the currently selected key. Pure string building — no DOM. */
90
+ /**
91
+ * Full innerHTML for #typo-list, given the currently selected key. Pure string building — no DOM.
92
+ *
93
+ * ⚠️ É UM GRUPO DE RÁDIO SÓ, ATRAVESSANDO AS TRÊS SECÇÕES, e isso é a decisão e não um detalhe de
94
+ * marcação: a exclusividade é do MENU inteiro — uma fonte activa no total —, não de cada família. Três
95
+ * grupos diriam a quem escuta que dá para ter uma sans E uma serif ao mesmo tempo.
96
+ *
97
+ * ⚠️ E ISTO DEIXOU DE SER UM INTERRUPTOR EM 2026-09-07. Era `class="mode-btn switch"` + `aria-pressed` +
98
+ * `toggleLabel()`, ou seja dezassete interruptores independentes anunciando «Ligado»/«Desligado» para
99
+ * escolher UMA fonte. A emenda do ADR-0012 diz o contrário em tantas palavras: «THE MENU IS A CHOICE, NOT
100
+ * A TOGGLE […] One font is active; the others are alternatives, not switches.» O `switch` do
101
+ * `style.css:427` desenha uma chave de 52×28 px com bolinha — era o desenho de um estado que não existe.
102
+ */
73
103
  export declare function typoListHTML(fontKey: string): string;
74
104
  export declare function initSettingsTypo(ctx: SettingsTypoCtx): SettingsTypoApi;
@@ -6,17 +6,25 @@
6
6
  // production). Overlay open/close plumbing (frontOverlay, focus management, the #typo hidden toggle, Escape
7
7
  // handling) is the SHARED helper used by every settings panel and stays in game.js. The font catalog itself
8
8
  // (FONT_GROUPS/FONT_BY_KEY) stays in ./fonts.js (Phase 2 extraction) — imported here, never duplicated.
9
- import { toggleLabel } from './dom.js';
9
+ // (`toggleLabel` saiu daqui em 2026-09-07: ele devolve «Ligado»/«Desligado», e este menu é uma ESCOLHA.)
10
10
  import { t } from '../core/i18n.js';
11
- import { FONT_GROUPS, FONT_BY_KEY, DEFAULT_FONT_KEY } from './fonts.js';
11
+ import { FONT_GROUPS, FONT_BY_KEY, DEFAULT_FONT_KEY, papelDaFonte } from './fonts.js';
12
12
  import { markChanged, markMenuChanged, CHANGED_CLASS } from './changed-mark.js';
13
13
  // ---------------------------------------------------------------------------------------------
14
14
  // Pure logic (no `document`, testable in node)
15
15
  // ---------------------------------------------------------------------------------------------
16
- /** A key is selectable when it exists in the catalog and is not marked `.off` (licence pending, etc.). */
16
+ /**
17
+ * A key is selectable when it exists in the catalog, is not marked `.off` (licence pending, etc.) — and is
18
+ * `geral`.
19
+ *
20
+ * ⚠️ O TERCEIRO TERMO ENTROU EM 2026-09-07 (issue #87), e sem ele o resto da mudança seria decoração: o menu
21
+ * deixaria de OFERECER as caligráficas e elas continuariam SELECIONÁVEIS por qualquer outro caminho — o
22
+ * `resolveFontKey` de uma chave guardada, um `data-font` num markup de consumidor. «Não está na lista» e «não
23
+ * pode ser escolhida» têm de ser a mesma afirmação, ou a lista é só uma sugestão.
24
+ */
17
25
  export function isSelectableFont(k) {
18
26
  const it = FONT_BY_KEY[k];
19
- return !!it && !it.off;
27
+ return !!it && !it.off && papelDaFonte(it) === 'geral';
20
28
  }
21
29
  // A semantica da chave (validacao + migracao da chave antiga) mora em ui/fonts.ts, que e o dono do
22
30
  // catalogo; re-exportada aqui para quem ja consome este modulo. Uma implementacao, nao duas.
@@ -38,35 +46,73 @@ export function fontCssTarget(k, it) {
38
46
  const suffix = it.fb === 'serif' ? ',Georgia,serif' : it.fb === 'cursive' ? ',cursive' : '';
39
47
  return { fonte: 'custom', customFamily: `'${it.fam}'${suffix}` };
40
48
  }
49
+ /**
50
+ * UMA linha da lista, a partir de uma face do catálogo. Extraída em 2026-09-07 (issue #87) porque o gate do
51
+ * mecanismo `.off` precisava de a exercitar com uma face de MENTIRA: as duas únicas entradas desligadas
52
+ * saíram do roster, e um caso que dependa da composição do catálogo reprova sempre que o roster muda.
53
+ *
54
+ * O mecanismo fica, e é preciso: o item 4 da #87 usa-o para a **Ronde**, que só pode ser oferecida se uma de
55
+ * três faces estiver instalada, porque duas delas são gratuitas apenas para uso pessoal e não podem ser
56
+ * empacotadas.
57
+ */
58
+ export function linhaDaFonte(it, fontKey) {
59
+ const disabled = !!it.off;
60
+ // `d` e `off` também guardam CHAVE. O travessão que junta os dois é pontuação, não frase — as duas
61
+ // metades são independentes e cada uma traduz por si.
62
+ const desc = it.d ? t(it.d) : '', motivo = it.off ? t(it.off) : '';
63
+ const note = desc ? desc + (disabled ? ' — ' + motivo : '') : disabled ? motivo : '';
64
+ return { key: it.k, fam: it.fam, selected: fontKey === it.k, disabled, note };
65
+ }
41
66
  /** Pure view-model for the typography list: which row is selected/disabled and its note, per catalog group. */
42
67
  export function typoGroups(fontKey) {
68
+ // ⚠️ SÓ AS GERAIS ENTRAM NA LISTA (emenda do ADR-0012, issue #87). As caligráficas existem para a criança
69
+ // APRENDER a ler letra cursiva — isso é matéria, e vive DENTRO das atividades, em botões próprios. Oferecê-
70
+ // las aqui é dar-lhe a matéria como obstáculo em todo lugar onde ela só quer navegar o menu.
71
+ //
72
+ // Um grupo que fique sem nenhuma face geral desaparece da lista, em vez de aparecer como título vazio.
43
73
  return FONT_GROUPS.map((g) => ({
44
74
  g: t(g.g), // `g` guarda CHAVE i18n desde o item 14 (ver ui/fonts)
45
- rows: g.items.map((it) => {
46
- const disabled = !!it.off;
47
- // `d` e `off` também guardam CHAVE. O travessão que junta os dois é pontuação, não frase — as duas
48
- // metades são independentes e cada uma traduz por si.
49
- const desc = it.d ? t(it.d) : '', motivo = it.off ? t(it.off) : '';
50
- const note = desc ? desc + (disabled ? ' — ' + motivo : '') : disabled ? motivo : '';
51
- return { key: it.k, fam: it.fam, selected: fontKey === it.k, disabled, note };
52
- }),
53
- }));
75
+ rows: g.items.filter((it) => papelDaFonte(it) === 'geral').map((it) => linhaDaFonte(it, fontKey)),
76
+ })).filter((grupo) => grupo.rows.length > 0);
54
77
  }
78
+ /**
79
+ * ⚠️ A MARCA DE SELEÇÃO, e ela existe porque COR NÃO É ESTADO.
80
+ *
81
+ * O `.mode-btn.is-on` pinta o botão com `var(--accent)` — o fundo amarelo que a emenda do ADR-0012 pede
82
+ * por extenso. Mas quem não distingue a cor não vê estado nenhum, e é a mesma razão pela qual
83
+ * `ui/activities-menu.ts:656` já emite ☑/☐ ao lado do `aria-checked`: «o estado em DUAS formas, e nenhuma
84
+ * delas é cor».
85
+ */
86
+ const MARCA_ESCOLHIDA = '●';
87
+ const MARCA_ALTERNATIVA = '○';
55
88
  function rowHTML(row) {
56
89
  const noteHTML = row.note
57
90
  ? `<br><span class="opt-hint" style="margin:0;font-family:var(--font)">${row.note}</span>`
58
91
  : '';
59
92
  const ariaLabel = row.fam + (row.note ? ' — ' + row.note : '');
60
- const stateLabel = toggleLabel(row.selected);
61
93
  return (`<div class="ctrl-row"><span style="font-family:'${row.fam}'"><strong>${row.fam}</strong>${noteHTML}</span>` +
62
- `<button class="mode-btn switch${row.selected ? ' is-on' : ''}" data-font="${row.key}" type="button"` +
63
- `${row.disabled ? ' disabled' : ''} aria-pressed="${row.selected}" aria-label="${ariaLabel}">${stateLabel}</button></div>`);
94
+ `<button class="mode-btn${row.selected ? ' is-on' : ''}" data-font="${row.key}" type="button" role="radio"` +
95
+ `${row.disabled ? ' disabled' : ''} aria-checked="${row.selected}" aria-label="${ariaLabel}">` +
96
+ `${row.selected ? MARCA_ESCOLHIDA : MARCA_ALTERNATIVA}</button></div>`);
64
97
  }
65
- /** Full innerHTML for #typo-list, given the currently selected key. Pure string building — no DOM. */
98
+ /**
99
+ * Full innerHTML for #typo-list, given the currently selected key. Pure string building — no DOM.
100
+ *
101
+ * ⚠️ É UM GRUPO DE RÁDIO SÓ, ATRAVESSANDO AS TRÊS SECÇÕES, e isso é a decisão e não um detalhe de
102
+ * marcação: a exclusividade é do MENU inteiro — uma fonte activa no total —, não de cada família. Três
103
+ * grupos diriam a quem escuta que dá para ter uma sans E uma serif ao mesmo tempo.
104
+ *
105
+ * ⚠️ E ISTO DEIXOU DE SER UM INTERRUPTOR EM 2026-09-07. Era `class="mode-btn switch"` + `aria-pressed` +
106
+ * `toggleLabel()`, ou seja dezassete interruptores independentes anunciando «Ligado»/«Desligado» para
107
+ * escolher UMA fonte. A emenda do ADR-0012 diz o contrário em tantas palavras: «THE MENU IS A CHOICE, NOT
108
+ * A TOGGLE […] One font is active; the others are alternatives, not switches.» O `switch` do
109
+ * `style.css:427` desenha uma chave de 52×28 px com bolinha — era o desenho de um estado que não existe.
110
+ */
66
111
  export function typoListHTML(fontKey) {
67
- return typoGroups(fontKey)
112
+ const grupos = typoGroups(fontKey)
68
113
  .map((group) => `<h3 class="panel-sub">${group.g}</h3>` + group.rows.map(rowHTML).join(''))
69
114
  .join('');
115
+ return `<div role="radiogroup" aria-label="${t('font.grupo.rotulo')}">${grupos}</div>`;
70
116
  }
71
117
  // ---------------------------------------------------------------------------------------------
72
118
  // DOM-facing (thin) — requires `document`/injected ctx
@@ -50,7 +50,14 @@ export interface SettingsVisualCtx {
50
50
  /** Desenha UMA lista de rádio de modos visuais (+ abas por jogador). O MESMO helper que o painel de
51
51
  * empatia usa — de propósito: as três correções mudaram de menu, e mudar junto a aparência delas faria a
52
52
  * criança ter de reaprender um controle que ela já conhecia. */
53
- renderVizGroup: (listSel: string, tabsSel: string, modes: readonly VizMode[]) => void;
53
+ /**
54
+ * Os DOIS eixos deste painel (#104). Substituiu o `renderVizGroup`, que fica com o painel de EMPATIA.
55
+ *
56
+ * ⚠️ A lista de sete que este painel oferecia era a forma honesta de contar uma exclusividade REAL, e o
57
+ * `VISUAL_MODES` explica-a em prosa logo acima. Ela deixou de existir: o estado tem dois eixos, e os
58
+ * escritores por eixo mexem num sem tocar no outro.
59
+ */
60
+ renderEixosVisuais: (listSel: string, tabsSel: string) => void;
54
61
  setLq: (t: number) => void;
55
62
  setOwnerColors: (on: boolean) => void;
56
63
  setCbSafe: (on: boolean) => void;
@@ -58,6 +65,19 @@ export interface SettingsVisualCtx {
58
65
  setOutlineBg: (level: number) => void;
59
66
  setRoleColor: (key: RoleKey, hex: string) => void;
60
67
  resetRoleColors: () => void;
68
+ /**
69
+ * Move a prosa das linhas para o rodapé (`ui/settings-panel` → `fillExplain`). Chamado a CADA render.
70
+ *
71
+ * ⚠️ NÃO É OPCIONAL POR ELEGÂNCIA: `fillExplain` roda uma vez quando o overlay é frontalizado e move o
72
+ * `.opt-hint` de dentro de cada linha para o rodapé. Este painel RECONSTRÓI as linhas, e as linhas novas
73
+ * voltam com a prosa lá dentro — então a explicação aparece duas vezes, no rodapé e sob o rótulo, a
74
+ * partir do primeiro clique. O `CLAUDE.md` §4 regista exatamente isto, e a issue #109 já o consertou
75
+ * uma vez noutros painéis.
76
+ *
77
+ * Opcional na assinatura porque um consumidor pode montar o painel sem a casca (um teste, o segundo
78
+ * consumidor): sem casca não há rodapé para duplicar.
79
+ */
80
+ fillExplain?: (card: HTMLElement | null) => void;
61
81
  }
62
82
  /**
63
83
  * `viz` quando ele é um modo DESTE menu (contraste ou correção), senão 'normal'.