@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
@@ -19,11 +19,14 @@
19
19
  // módulo rodar no project `node` sem importar PIXI. Mesmo precedente de render/scene-sky e game/traffic.
20
20
  // SEM I/O no import: initVizSetters(ctx) só fecha closures, não chama nada.
21
21
  import { VIZ_MODES, VIZ_BY_KEY, VIZ_FILTER, simulatesDisability } from './viz-modes.js';
22
+ import { migrarVisual, filtroChave, chaveDeTextura, chaveLegada, ehSimulacao, ehBaixaVisao, ehCego, temAltoContraste, PADRAO, } from './viz-axes.js';
22
23
  import { t } from '../core/i18n.js'; // VIZ_MODES guarda CHAVE i18n desde o item 14; quem exibe resolve
24
+ import { eixosHtml, escolhaDoBotao, ROTULO_DO_TEMA, ROTULO_DA_CORRECAO, } from '../ui/visual-axes-panel.js';
25
+ import { recusaDaSimulacao } from '../ui/simulation-refusal.js';
23
26
  import { DIRECT_CFG, worldTexFor, spriteTexFor, clearWorldTexCache, clearSpriteTexCache } from './high-contrast.js';
24
27
  import { pupTexFor, resetPupTexCache } from './textures.js';
25
28
  import { lqFilter } from './lq-filter.js';
26
- import { setVizModeValue } from '../core/state.js';
29
+ import { setVizModeValue, setModoCegoValue } from '../core/state.js';
27
30
  import * as store from '../platform/storage.js';
28
31
  /* ===================== PURO (sem PIXI, sem DOM) — o que rende teste de verdade ===================== */
29
32
  /**
@@ -38,6 +41,29 @@ export function alcanceDoModo(mode) {
38
41
  export function resolveViz(key) {
39
42
  return VIZ_BY_KEY[key] || VIZ_BY_KEY.normal;
40
43
  }
44
+ /**
45
+ * O ESTADO VISUAL GUARDADO deste jogador, de qualquer das duas formas (issue #104).
46
+ *
47
+ * ⚠️ ESTA FUNÇÃO É A CAIXA «um ajuste salvo antes da divisão restaura o mesmo estado visível» da definition
48
+ * of done, e a ordem das duas leituras é a decisão inteira:
49
+ *
50
+ * 1. a chave NOVA (`visualP`), que é a única que sabe dizer dois eixos;
51
+ * 2. na falta dela, a chave VELHA (`vizP`), que guarda a string única — e é aqui que mora o ajuste de toda
52
+ * criança que já jogou este jogo antes de hoje;
53
+ * 3. na falta das duas, o padrão.
54
+ *
55
+ * ⚠️ O RECUO NÃO É ZELO: sem ele, a primeira sessão depois da actualização apagaria o modo visual que ela
56
+ * escolheu — e quem escolheu `fix-deuter` ou `hc-direto-7` escolheu-o porque enxerga assim. É a diferença
57
+ * entre migrar e recomeçar.
58
+ *
59
+ * `migrarVisual` aceita as duas formas e é idempotente, então isto pode correr quantas vezes for preciso.
60
+ */
61
+ export function lerVisualGuardado(i) {
62
+ const novo = store.getJSON(store.KEYS.visualP(i), null);
63
+ if (novo !== null)
64
+ return migrarVisual(novo);
65
+ return migrarVisual(store.get(store.KEYS.vizP(i), null));
66
+ }
41
67
  /** É um dos três níveis de Renderização Direta (alto contraste)? Mesmo teste do original: `!!DIRECT_CFG[mode]`. */
42
68
  export function isDirectMode(mode) { return !!DIRECT_CFG[mode]; }
43
69
  /** Filtro CSS da canvas no SOLO: simulação/correção de visão + realce L→Q, compostos e sem vazios. */
@@ -116,20 +142,58 @@ export function initVizSetters(ctx) {
116
142
  }
117
143
  function applyVpFilters() {
118
144
  const spr = ctx.getVpSpr(), players = ctx.getPlayers();
145
+ // ⚠️ `filtroChave` E NÃO `p.viz` (#104). É a outra metade do par que a etapa 0 mediu: o TEMA vai pela
146
+ // textura (`playerVizTex`/`applySharedTextures`) e a CORREÇÃO ou SIMULAÇÃO vai pelo FILTRO — e é
147
+ // exactamente por serem dois caminhos que os dois eixos podem coexistir. `null` (sem filtro) entra como
148
+ // `'normal'`, que é a chave que o `pixiFilterFor` já usa para «nenhum», e ele cacheia por ela.
119
149
  for (let i = 0; i < ctx.getNumPlayers(); i++) {
150
+ const p = players[i];
120
151
  if (spr[i])
121
- spr[i].filters = ctx.pixiFilterFor(players[i].viz);
152
+ spr[i].filters = ctx.pixiFilterFor((p.visual && filtroChave(p.visual)) || 'normal');
122
153
  }
123
154
  }
155
+ /**
156
+ * O ESCRITOR DE VERDADE desde a etapa 4 da #104: recebe o ESTADO, não uma chave.
157
+ *
158
+ * ⚠️ E É AQUI QUE O ESPELHO MUDA DE SIGNIFICADO, o que estava previsto e escrito. Enquanto os controles
159
+ * escreviam um valor de cada vez, `viz` conseguia ser «o modo equivalente». Com dois eixos não há chave
160
+ * única que descreva `hc7 + fix-deuter`, então o espelho passa a ser exactamente o que ele ainda consegue
161
+ * ser com honestidade: **a CHAVE DE TEXTURA** — `simulação ?? tema ?? normal`, o que mais muda o que se vê.
162
+ *
163
+ * Isso não é uma perda escondida: é a mesma chave que o `setVizModeValue` já escreve, e o invariante do
164
+ * gate passou a afirmá-la nesses termos. Um leitor antigo continua a ver algo verdadeiro sobre a tela; o
165
+ * que ele deixa de ver é a metade que a forma antiga nunca soube dizer.
166
+ */
167
+ function setVisualDoJogador(i, v) {
168
+ const p = ctx.getPlayers()[i];
169
+ p.visual = v;
170
+ p.viz = chaveLegada(v);
171
+ store.set(store.KEYS.vizP(i), p.viz); // legada: um leitor antigo faria `VIZ_BY_KEY[v]` e recusaria JSON
172
+ store.setJSON(store.KEYS.visualP(i), v); // nova: os dois eixos, que a chave velha não sabe dizer
173
+ aplicarVisualDoJogador(i, v);
174
+ }
175
+ /** Muda SÓ o tema deste jogador. A correção e a simulação ficam onde estavam — é o ponto da #104. */
176
+ function setTemaDoJogador(i, tema) {
177
+ const p = ctx.getPlayers()[i];
178
+ setVisualDoJogador(i, { ...(p.visual ?? PADRAO), tema });
179
+ }
180
+ /** Muda SÓ a correção de cor deste jogador. O tema e a simulação ficam onde estavam. */
181
+ function setCorrecaoDoJogador(i, correcao) {
182
+ const p = ctx.getPlayers()[i];
183
+ setVisualDoJogador(i, { ...(p.visual ?? PADRAO), correcao });
184
+ }
185
+ /** A API antiga, por chave única. Continua a valer: um jogo que escolhe um modo inteiro passa por aqui. */
124
186
  function setPlayerViz(i, mode) {
125
- const m = resolveViz(mode);
126
- ctx.getPlayers()[i].viz = m.key;
127
- store.set(store.KEYS.vizP(i), m.key);
187
+ setVisualDoJogador(i, migrarVisual(resolveViz(mode).key));
188
+ }
189
+ /** Os efeitos colaterais de ter mudado o visual de um jogador. Separados do ESCREVER de propósito: os dois
190
+ * escritores por eixo e o antigo por chave partilham-nos, e uma cópia a mais seria uma cópia a divergir. */
191
+ function aplicarVisualDoJogador(i, v) {
128
192
  ctx.invalidateSharedViz();
129
- if (m.kind === 'blind')
130
- ctx.setModoCego(true); // empatia cegueira total liga o modo cego (áudio) por padrão
193
+ if (ehCego(v))
194
+ (ctx.setModoCego ?? setModoCegoValue)(true); // empatia cegueira total liga o modo cego (áudio) por padrão
131
195
  if (ctx.getNumPlayers() <= 1 && i === 0) {
132
- applyVizGlobal(m.key);
196
+ applyVizGlobal(v);
133
197
  }
134
198
  else {
135
199
  applyVpFilters();
@@ -139,36 +203,60 @@ export function initVizSetters(ctx) {
139
203
  ctx.renderVisualPanel();
140
204
  ctx.renderEmpathyPanel();
141
205
  }
142
- function applyVizGlobal(mode) {
143
- const m = resolveViz(mode);
144
- mode = m.key;
145
- setVizModeValue(mode); // core/state: valor + persistência (incl_viz) + evento
206
+ /**
207
+ * ⚠️ AQUI É QUE OS DOIS EIXOS PASSAM A COEXISTIR (#104, ADR-0076), e a função nem cresceu — ela SEPAROU-SE.
208
+ *
209
+ * Enquanto o estado era uma chave só, cada linha abaixo perguntava a mesma coisa (`mode`, `m.kind`) e a
210
+ * resposta tinha de ser uma. Com dois eixos, as mesmas linhas dividem-se em três grupos que nunca se
211
+ * tocaram — e essa é a razão de a composição já ser mecanicamente possível, como o `viz-axes` regista:
212
+ *
213
+ * · TEMA (contraste) → textura e classe de DOM. `chaveDeTextura` e `temAltoContraste`.
214
+ * · CORREÇÃO ou SIMULAÇÃO → filtro CSS. `filtroChave`.
215
+ * · SIMULAÇÃO → as classes do corpo, o overlay, os controles de toque, a bolinha.
216
+ *
217
+ * ⚠️ E O ALCANCE DO FILTRO CONTINUA A DEPENDER DE SIMULAR OU CORRIGIR, que é a distinção do ADR-0046: uma
218
+ * CORREÇÃO alcança os menus, porque a criança precisa dela para LER o menu; uma SIMULAÇÃO fica no mundo,
219
+ * porque quem simula tem de conseguir sair.
220
+ *
221
+ * `setVizModeValue` continua a escrever a chave ÚNICA legada, e a que ele escreve é a de TEXTURA — que é
222
+ * `simulação ?? tema ?? normal`, ou seja o que mais muda o que se vê. É espelho, não fonte: o estado a
223
+ * sério são os dois eixos, e esta linha sai quando o último leitor da chave velha sair.
224
+ */
225
+ function applyVizGlobal(v) {
226
+ const filtro = filtroChave(v);
227
+ const textura = chaveDeTextura(v);
228
+ // ⚠️ `chaveLegada` E NÃO `textura`: a de textura devolve `normal` para uma correção de cor, e escrevê-la
229
+ // aqui faria um leitor antigo da chave global perder a correção da criança. Ver a nota em `chaveLegada`.
230
+ setVizModeValue(chaveLegada(v)); // core/state: valor + persistência (incl_viz) + evento — espelho legado
146
231
  // ANTES daqui saía também `ctx.setHcMode(m.kind === 'hcnew')`, alimentando um `let hcMode` no game.js cujo
147
232
  // único leitor era o gancho window.__incl. Era `vizMode` reescrito com outro nome: derivar de VIZ_BY_KEY
148
233
  // custa uma comparação e não pode divergir. (O inicializador daquele `let` usava OUTRA fórmula,
149
234
  // `vizMode!=='normal'`, e discordava do setter — sem efeito, porque applyVizGlobal roda no boot antes de
150
235
  // o gancho existir, mas é o sintoma clássico de cópia de estado.)
151
- ctx.aplicarFiltroCss(cssFilterFor(mode, lqFilter()), alcanceDoModo(mode));
152
- ctx.aplicarAltoContrasteNoDom(m.kind === 'hcnew'); // não é filtro: ver `AplicarAltoContrasteNoDom` // sim. daltonismo/baixa-visão/cegueira + realce L/Q compostos
153
- ctx.camera.filters = (m.kind === 'hcnew') ? ctx.pixiFilterFor(mode) : null; // solo: alto contraste experimental = filtro GPU na câmera
154
- ctx.setFrontDim(!!DIRECT_CFG[mode]); // HC: frente (carros/placas/semáforo) escurece como fundo
155
- ctx.worldSprite.texture = worldTexFor(mode); // alto contraste direto = Renderização Direta · resto=normal
156
- ctx.parallaxLayers.forEach((ts, i) => { ts.texture = ctx.parallaxTexFor(i, mode); });
157
- ctx.decoSprites.forEach((s) => { s.texture = ctx.treeTexFor(mode); });
236
+ // --- eixo CORREÇÃO/SIMULAÇÃO: o filtro CSS ---
237
+ ctx.aplicarFiltroCss(cssFilterFor(filtro ?? '', lqFilter()), ehSimulacao(v) ? 'mundo' : 'mundo-e-menus');
238
+ // --- eixo TEMA: DOM e textura. Não é filtro (ver `AplicarAltoContrasteNoDom`), e é por isso que compõe.
239
+ ctx.aplicarAltoContrasteNoDom(temAltoContraste(v));
240
+ ctx.camera.filters = temAltoContraste(v) ? ctx.pixiFilterFor(textura) : null; // solo: alto contraste na câmera
241
+ ctx.setFrontDim(temAltoContraste(v)); // HC: frente (carros/placas/semáforo) escurece como fundo
242
+ ctx.worldSprite.texture = worldTexFor(textura); // alto contraste direto = Renderização Direta · resto=normal
243
+ ctx.parallaxLayers.forEach((ts, i) => { ts.texture = ctx.parallaxTexFor(i, textura); });
244
+ ctx.decoSprites.forEach((s) => { s.texture = ctx.treeTexFor(textura); });
158
245
  ctx.rebuildExtras();
159
246
  ctx.rebuildCoins();
160
- // baixa visão = névoa+manchas (overlay) + bolinha verde; cegueira = tela preta (filtro) + esconde controles + bolinha branca
161
- ctx.body.classList.toggle('lowvision-mode', m.kind === 'lowvision');
162
- ctx.body.classList.toggle('blind-mode', m.kind === 'blind');
247
+ // --- SIMULAÇÃO: baixa visão = névoa+manchas (overlay) + bolinha verde; cegueira = tela preta + esconde
248
+ // controles + bolinha branca. Nenhuma delas olha para o tema, e é por isso que o tema não as apaga.
249
+ ctx.body.classList.toggle('lowvision-mode', ehBaixaVisao(v));
250
+ ctx.body.classList.toggle('blind-mode', ehCego(v));
163
251
  const ov = ctx.$('#viz-overlay');
164
252
  if (ov) {
165
- ov.hidden = (m.kind !== 'lowvision');
166
- ov.className = lvOverlayClassFor(m);
253
+ ov.hidden = !ehBaixaVisao(v);
254
+ ov.className = ehBaixaVisao(v) ? 'lv-' + String(v.simulacao).slice(3) : '';
167
255
  }
168
- if (m.kind === 'blind') {
256
+ if (ehCego(v)) {
169
257
  ctx.hideTouchControls('cegueira');
170
258
  }
171
- updateVizIndicator(m.kind);
259
+ updateVizIndicator(ehCego(v) ? 'blind' : ehBaixaVisao(v) ? 'lowvision' : 'normal');
172
260
  ctx.reflectVizButtons(); // (a guarda `typeof ...==='function'` do original morreu: era declaração de função, sempre verdadeira)
173
261
  ctx.renderVisualPanel();
174
262
  ctx.renderEmpathyPanel();
@@ -188,7 +276,7 @@ export function initVizSetters(ctx) {
188
276
  function reapplyVizAll() {
189
277
  ctx.invalidateSharedViz();
190
278
  if (ctx.getNumPlayers() <= 1) {
191
- applyVizGlobal(ctx.getPlayers()[0].viz);
279
+ applyVizGlobal(ctx.getPlayers()[0].visual ?? PADRAO);
192
280
  }
193
281
  else {
194
282
  ctx.aplicarFiltroCss(lqFilter(), 'mundo-e-menus'); // realce L/Q é melhoria: alcança o menu
@@ -209,7 +297,7 @@ export function initVizSetters(ctx) {
209
297
  ctx.clearPlayerDirectCache();
210
298
  ctx.invalidateSharedViz();
211
299
  if (ctx.getNumPlayers() <= 1)
212
- applyVizGlobal(ctx.getPlayers()[0].viz);
300
+ applyVizGlobal(ctx.getPlayers()[0].visual ?? PADRAO);
213
301
  else
214
302
  applyVpFilters();
215
303
  }
@@ -232,13 +320,81 @@ export function initVizSetters(ctx) {
232
320
  }));
233
321
  }
234
322
  const players = ctx.getPlayers(), sel = ctx.getSelVizPlayer();
235
- const cur = players[sel] ? players[sel].viz : 'normal';
323
+ const v = players[sel]?.visual ?? PADRAO;
324
+ const cur = chaveLegada(v);
236
325
  el.innerHTML = vizGroupHtml(modes, cur);
237
- el.querySelectorAll('button[data-viz]').forEach((btn) => btn.addEventListener('click', () => {
326
+ // ⚠️ A RECUSA DA SIMULAÇÃO (#104, ADR-0076 §4). Com qualquer dos dois eixos fora do padrão, uma
327
+ // demonstração não mostra a deficiência — mostra o AJUSTE por cima do qual ela corre, e isso ensina uma
328
+ // coisa falsa. A linha CONTINUA na tela, desabilitada e com o motivo: sumir ensinaria que a coisa não
329
+ // existe, e um adulto concluiria que ela foi tirada em vez de perceber que foi ele que ligou o contraste.
330
+ //
331
+ // A prosa entra num `.opt-hint`, que é o que a casca (`ui/settings-panel.fillExplain`) MOVE para o rodapé
332
+ // — a regra das três zonas do CLAUDE.md: a explicação mora no rodapé, nunca na linha.
333
+ //
334
+ // ⚠️ SÓ AS LINHAS QUE SIMULAM. Esta função desenha hoje a lista de simulações (o painel visual passou a
335
+ // usar o `renderEixosVisuais`), mas ela continua a receber os modos por parâmetro — e uma correção de
336
+ // cor nesta lista não deve ser recusada por causa do eixo dela própria.
337
+ const recusa = recusaDaSimulacao(v);
338
+ el.querySelectorAll('button[data-viz]').forEach((btn) => {
238
339
  const key = btn.dataset.viz;
239
- setPlayerViz(ctx.getSelVizPlayer(), key);
240
- ctx.srSay(vizGroupSay(ctx.getNumPlayers(), ctx.getSelVizPlayer(), t(VIZ_MODES.find((m) => m.key === key).nome)));
340
+ if (recusa && simulatesDisability(key)) {
341
+ btn.setAttribute('aria-disabled', 'true');
342
+ const dica = btn.closest('.ctrl-row')?.querySelector('.opt-hint');
343
+ if (dica)
344
+ dica.textContent = `${dica.textContent} ${t(recusa.chave)}`.trim();
345
+ return; // sem ouvinte: aceitar o clique e ignorá-lo é a outra metade do que o ADR proíbe
346
+ }
347
+ btn.addEventListener('click', () => {
348
+ setPlayerViz(ctx.getSelVizPlayer(), key);
349
+ ctx.srSay(vizGroupSay(ctx.getNumPlayers(), ctx.getSelVizPlayer(), t(VIZ_MODES.find((m) => m.key === key).nome)));
350
+ });
351
+ });
352
+ }
353
+ /**
354
+ * OS DOIS EIXOS no painel VISUAL (#104). Irmão do `renderVizGroup`, e SEPARADO dele de propósito.
355
+ *
356
+ * ⚠️ QUASE FIZ ISTO DENTRO DO `renderVizGroup`, E TERIA PARTIDO O PAINEL DE EMPATIA. Aquela função serve os
357
+ * DOIS painéis — `#visual-modes` com os sete modos e `#empathy-list` com as nove simulações —, e trocar o
358
+ * corpo dela teria posto os dois eixos na lista de simulações. Ali o rádio único continua CERTO: as
359
+ * simulações são mesmo exclusivas entre si, e o que deixou de ser exclusivo foi outra coisa.
360
+ *
361
+ * ⚠️ E OS DOIS GRUPOS ENTRAM NO CONTENEDOR QUE JÁ EXISTE, sem markup nova do hospedeiro. Exigir um elemento
362
+ * a mais faria cada um dos 300 jogos ter de se lembrar dele — a forma de defeito que o ADR-0106 acabou de
363
+ * medir em cinco jogos sem barra de acessibilidade nenhuma.
364
+ */
365
+ function renderEixosVisuais(listSel, tabsSel) {
366
+ const el = ctx.$(listSel);
367
+ if (!el)
368
+ return;
369
+ if (ctx.getSelVizPlayer() >= ctx.getNumPlayers())
370
+ ctx.setSelVizPlayer(0);
371
+ const tabs = ctx.$(tabsSel);
372
+ if (tabs) {
373
+ tabs.hidden = true;
374
+ tabs.innerHTML = '';
375
+ }
376
+ const sel = ctx.getSelVizPlayer();
377
+ const v = ctx.getPlayers()[sel]?.visual ?? PADRAO;
378
+ el.innerHTML = eixosHtml(v, t);
379
+ el.querySelectorAll('button[data-eixo]').forEach((btn) => btn.addEventListener('click', () => {
380
+ const escolha = escolhaDoBotao(btn.dataset);
381
+ if (!escolha)
382
+ return; // botão de outro assunto, ou um `data-` editado à mão: não se adivinha
383
+ const i = ctx.getSelVizPlayer();
384
+ if (escolha.eixo === 'tema') {
385
+ setTemaDoJogador(i, escolha.valor);
386
+ ctx.srSay(vizGroupSay(ctx.getNumPlayers(), i, t(ROTULO_DO_TEMA[escolha.valor])));
387
+ }
388
+ else {
389
+ setCorrecaoDoJogador(i, escolha.valor);
390
+ ctx.srSay(vizGroupSay(ctx.getNumPlayers(), i, t(ROTULO_DA_CORRECAO[escolha.valor])));
391
+ }
241
392
  }));
242
393
  }
243
- return { applySharedTextures, updateVpDots, applyVpFilters, setPlayerViz, applyVizGlobal, reapplyVizAll, updateVizIndicator, rebakeDirect, renderVizGroup };
394
+ return {
395
+ applySharedTextures, updateVpDots, applyVpFilters, setPlayerViz, applyVizGlobal, reapplyVizAll,
396
+ updateVizIndicator, rebakeDirect, renderVizGroup, renderEixosVisuais,
397
+ // Os DOIS escritores por eixo (#104): é o que um painel de dois controles chama.
398
+ setVisualDoJogador, setTemaDoJogador, setCorrecaoDoJogador,
399
+ };
244
400
  }
@@ -1,4 +1,5 @@
1
1
  // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ import { type VisualState } from './viz-axes.js';
2
3
  import type { PlayerView } from '../core/entity.js';
3
4
  /** Minimal PIXI.Graphics surface these draws use (structural → module stays PIXI-free for node tests). */
4
5
  export interface DrawGraphics {
@@ -10,10 +11,17 @@ export interface DrawGraphics {
10
11
  drawCircle(x: number, y: number, r: number): unknown;
11
12
  }
12
13
  /** Cadeira e bengala: onde o jogador está, para que lado olha, e em que modo de visão desenhar. */
13
- type DrawablePlayer = PlayerView<'facing' | 'x' | 'y' | 'viz'>;
14
- /** Cane color: green for low-vision, white otherwise (blind). */
14
+ type DrawablePlayer = PlayerView<'facing' | 'x' | 'y' | 'visual'>;
15
+ /**
16
+ * Cor da bengala: verde na baixa visão, branca no resto (cegueira).
17
+ *
18
+ * ⚠️ LÊ O `visual` E NÃO O `viz` desde a #104. A pergunta não mudou — «isto é baixa visão?» — mas quem a
19
+ * responde passou a ser o `ehBaixaVisao` do modelo de dois eixos, em vez de uma consulta ao `kind` da tabela
20
+ * antiga. É a mesma resposta para os dezasseis modos de hoje (a rede da etapa 0 afirma-o, chave a chave), e
21
+ * passa a ser a resposta CERTA para um estado que a tabela antiga não sabe exprimir.
22
+ */
15
23
  export declare function caneColor(pl: {
16
- viz: string;
24
+ visual: VisualState;
17
25
  }): number;
18
26
  /** Rigid half-block cane (~8px): front-hand extension, fixed to the body (does not swing on its own). */
19
27
  export declare function drawCane(g: DrawGraphics, pl: DrawablePlayer): void;
@@ -3,11 +3,17 @@
3
3
  // low-vision), the running cane (item), and the wheelchair. Stateless PIXI drawing (verbatim from game.js):
4
4
  // each takes a Graphics `g` + the player. The layers (caneLayer/chairLayer) stay in game.js and are passed in.
5
5
  // `caneColor` (pure) moves here too — it was only used by the cane draws. See docs/5-Refactoring/plano-modularizacao-mapa.md.
6
- import { VIZ_BY_KEY } from './viz-modes.js';
7
- /** Cane color: green for low-vision, white otherwise (blind). */
6
+ import { ehBaixaVisao } from './viz-axes.js';
7
+ /**
8
+ * Cor da bengala: verde na baixa visão, branca no resto (cegueira).
9
+ *
10
+ * ⚠️ LÊ O `visual` E NÃO O `viz` desde a #104. A pergunta não mudou — «isto é baixa visão?» — mas quem a
11
+ * responde passou a ser o `ehBaixaVisao` do modelo de dois eixos, em vez de uma consulta ao `kind` da tabela
12
+ * antiga. É a mesma resposta para os dezasseis modos de hoje (a rede da etapa 0 afirma-o, chave a chave), e
13
+ * passa a ser a resposta CERTA para um estado que a tabela antiga não sabe exprimir.
14
+ */
8
15
  export function caneColor(pl) {
9
- const m = VIZ_BY_KEY[pl.viz];
10
- return m && m.kind === 'lowvision' ? 0x35d06a : 0xf2f2f2;
16
+ return ehBaixaVisao(pl.visual) ? 0x35d06a : 0xf2f2f2;
11
17
  }
12
18
  /** Rigid half-block cane (~8px): front-hand extension, fixed to the body (does not swing on its own). */
13
19
  export function drawCane(g, pl) {
@@ -13,8 +13,26 @@ import { t } from '../core/i18n.js';
13
13
  // seleciona. `Element` e o padrao do lib.dom porque `querySelector` tambem serve para SVG e MathML —
14
14
  // nenhum seletor deste projeto pega um desses (conferido). Quem precisar de um tipo mais estreito passa
15
15
  // o parametro: `$<HTMLSelectElement>('#pad-design')`.
16
- export const $ = (s) => document.querySelector(s);
17
- export const $$ = (s) => [...document.querySelectorAll(s)];
16
+ /**
17
+ * ⚠️ RESOLVIDO POR `globalThis` E NÃO PELO GLOBAL CRU, e a diferença é entre devolver `null` e LANÇAR.
18
+ *
19
+ * `document.querySelector(...)` com `document` inexistente dá `ReferenceError` — não `undefined` —, e a
20
+ * assinatura destas duas funções promete `T | null`. Uma consulta que lança onde promete `null` é um defeito
21
+ * pela própria assinatura, e ele viajava longe: medido em 2026-09-08, o `core/a11y-sr.srAlert` chama o `$`
22
+ * daqui, e o `createGame` chama o `srAlert` ao mostrar o aviso de alcance — logo bootar a engine contra um
23
+ * documento INJECTADO (um iframe, um editor ao lado do jogo, um teste) rebentava o boot inteiro num anúncio.
24
+ *
25
+ * 📌 É o ACHADO 15 do `boot/create-game` outra vez, e sobreviveu pela mesma razão: enquanto toda raiz era um
26
+ * `main.ts` num navegador, o global ERA o documento certo. `globalThis.document` é a mesma coisa onde ele
27
+ * existe, e é `undefined` — em vez de explosão — onde não existe.
28
+ *
29
+ * ⚠️ E ELAS CONTINUAM A OLHAR PARA O GLOBAL, de propósito: quem precisa de consultar OUTRO documento injecta
30
+ * o seu (`create-game` tem um `$` próprio ligado ao `doc` do hospedeiro, e o `ui/pause-icons` tem o
31
+ * `docDaMontagem`). O que este conserto muda não é ONDE se procura — é o que acontece quando não há onde.
32
+ */
33
+ const docGlobal = () => globalThis.document;
34
+ export const $ = (s) => docGlobal()?.querySelector(s) ?? null;
35
+ export const $$ = (s) => [...(docGlobal()?.querySelectorAll(s) ?? [])];
18
36
  /**
19
37
  * Reflects an on/off state onto a toggle button: the visual class AND `aria-pressed`.
20
38
  * Lives here because the two must never drift apart — a button that looks pressed but does not
@@ -4,12 +4,42 @@
4
4
  * descrição, não o texto: mesma decisão de `VIZ_MODES` e `RM_LABEL`, e pelo mesmo motivo — uma tabela de
5
5
  * `const` com texto resolve uma vez, no import, e fica congelada no idioma do boot.
6
6
  */
7
+ /**
8
+ * O PAPEL de uma face, e é a emenda do ADR-0012 (27/08) posta em dado (issue #87).
9
+ *
10
+ * ⚠️ NÃO É O MESMO QUE O GRUPO. O grupo (`sans`/`serif`/`hand`) é APARÊNCIA e serve para ler a lista; o papel
11
+ * é ONDE A FACE PODE SER USADA, e é regra:
12
+ *
13
+ * · `geral` — em qualquer lugar. São as únicas que o menu de tipografia oferece.
14
+ * · `caligrafica` — **só DENTRO das atividades escolares**, nunca no HUD nem nos menus, e por isso **não
15
+ * aparecem no menu de fonte**. Elas existem para a criança APRENDER a ler letra cursiva, o que é matéria;
16
+ * usá-las como interface é dar-lhe a matéria como obstáculo em todos os lugares onde ela só quer navegar.
17
+ * · `jogo` — a face que o JOGO usa no HUD, no título e em rótulos curtos de arcade. Também não aparece no
18
+ * menu, e pelo mesmo tipo de razão que as caligráficas: uma face de pixel de 8 bits é desenhada para
19
+ * dizer POUCAS palavras em tamanho grande. Como face de interface ela contradiz o argumento que faz a
20
+ * Atkinson Hyperlegible ser o padrão — pouca diferenciação entre letras, avanço largo, nenhuma variação
21
+ * de altura. É certa no HUD de um jogo de pixel-art e errada num menu que a criança precisa de LER.
22
+ *
23
+ * ⚠️ E O CORTE NÃO É O GRUPO `hand`. O `comicneue` está lá por aparência — a face é de propósito geral e é
24
+ * frequentemente recomendada para dislexia. Tirá-la do menu removeria uma opção legitimamente acessível. A
25
+ * definição boa é a lista do item 2 da #87, que nomeia as caligráficas dando-lhes tamanho mínimo.
26
+ */
27
+ export type FontRole = 'geral' | 'caligrafica' | 'jogo';
7
28
  export type FontItem = {
8
29
  k: string;
9
30
  fam: string;
10
31
  fb: string;
11
32
  d?: string;
12
33
  off?: string;
34
+ /** Ausente = `geral`. Só as caligráficas se declaram, porque são a excepção. */
35
+ papel?: FontRole;
36
+ /**
37
+ * O menor tamanho, em px, em que esta face ainda é legível (item 2 da #87, números do Dev).
38
+ *
39
+ * ⚠️ Abaixo disto a face deixa de ser DIFÍCIL e passa a ser ILEGÍVEL, que são coisas diferentes: a
40
+ * dificuldade é o exercício, a ilegibilidade é a criança a desistir. Por isso é gate e não recomendação.
41
+ */
42
+ minPx?: number;
13
43
  };
14
44
  /** Um grupo do catálogo. `g` também guarda CHAVE ('font.group.sans'), pelo mesmo motivo. */
15
45
  export type FontGroup = {
@@ -17,6 +47,34 @@ export type FontGroup = {
17
47
  items: FontItem[];
18
48
  };
19
49
  export declare const FONT_GROUPS: FontGroup[];
50
+ /** O papel de uma face; ausente no catálogo quer dizer `geral`. */
51
+ export declare function papelDaFonte(it: FontItem): FontRole;
52
+ /**
53
+ * AS FAMÍLIAS QUE UMA FACE ACEITA, do `fam` que pode ser uma PILHA.
54
+ *
55
+ * 📌 A Ronde declara três (`'Ronde Script, OPTIFrench-Script, Merveille'`) porque qualquer uma delas serve —
56
+ * são três desenhos da mesma letra de mão, e um adulto instala a que encontrar. As outras faces declaram uma
57
+ * só, e para elas isto devolve uma lista de um.
58
+ */
59
+ export declare function familiasDaFace(it: FontItem): string[];
60
+ /**
61
+ * ESTA FACE PODE SER USADA AGORA? — o `off` deixa de ser uma sentença e passa a ser uma CONDIÇÃO.
62
+ *
63
+ * O ADR-0012 decidiu que a opção da ronde «fica DESABILITADA enquanto nenhuma fonte estiver presente», e o
64
+ * ADR-0108 §4 acrescentou o que ela diz. A palavra «enquanto» é o que esta função constrói: uma face `off`
65
+ * volta a ficar disponível no instante em que o adulto instala uma das que a mensagem nomeia.
66
+ *
67
+ * ⚠️ O DETECTOR É INJECTADO, e nunca `document.fonts` lido daqui: este módulo é o catálogo, corre em node nos
68
+ * gates, e ler um global do navegador aqui é o ACHADO 15 outra vez — o `srAlert` que rebentou o boot contra um
69
+ * documento injectado.
70
+ * 📌 E o PADRÃO É «não instalada», que é seguro por uma razão que não vale para todos os padrões deste
71
+ * repositório: sem detector a opção fica desabilitada COM a mensagem, e a mensagem diz ao adulto exactamente
72
+ * o que fazer. O silêncio não decide nada contra a criança — ele mantém o estado que já existia e que é
73
+ * accionável. É o oposto do `seguraTeclas`, onde os dois lados do padrão erravam.
74
+ */
75
+ export declare function faceDisponivel(it: FontItem, instalada?: (familia: string) => boolean): boolean;
76
+ /** As faces que o MENU pode oferecer: só as gerais (emenda do ADR-0012). */
77
+ export declare const OFERECIVEIS: FontItem[];
20
78
  export declare const FONT_BY_KEY: Record<string, FontItem>;
21
79
  /** Narrow store shape these need — lets a caller inject a fake without touching real storage. */
22
80
  export interface FontStore {
@@ -34,7 +92,19 @@ export declare const FONT_KEY_LEGACY = "incl_fonte";
34
92
  * porque duas cópias de um padrão são duas chances de o reset devolver algo que o jogo nunca usou.
35
93
  */
36
94
  export declare const DEFAULT_FONT_KEY = "atkinson";
37
- /** Boot choice: validated persisted key (ignores .off fonts) -> legacy-key migration -> DEFAULT_FONT_KEY. */
95
+ /**
96
+ * Boot choice: validated persisted key (ignores .off fonts) -> legacy-key migration -> DEFAULT_FONT_KEY.
97
+ *
98
+ * ⚠️ E UMA CALIGRÁFICA GUARDADA VOLTA AO PADRÃO (issue #87, 2026-09-07). Antes da emenda do ADR-0012 o menu
99
+ * oferecia as sete caligráficas, então há crianças com `pinyon` ou `ufmag` guardados — e o valor salvo
100
+ * continua a ser escolha delas, que não é nossa para desfazer sem motivo. O motivo existe: a emenda diz que
101
+ * uma caligráfica **não pode ser a face da interface**, e o menu deixou de a oferecer. Deixá-la valer daria
102
+ * uma interface inteira em letra cursiva a quem já não tem como sair dela pelo menu — uma armadilha, e das
103
+ * silenciosas.
104
+ *
105
+ * ⚠️ E UMA CHAVE APAGADA TAMBÉM VOLTA, pelo mesmo caminho: as quatro faces que saíram do roster já não estão
106
+ * no `FONT_BY_KEY`, então quem tinha `greatvibes` guardado cai no padrão em vez de ficar sem face nenhuma.
107
+ */
38
108
  export declare function resolveFontKey(s: FontStore): string;
39
109
  export declare function persistFontKey(s: FontStore, k: string): void;
40
110
  export declare function loadFontKey(): string;
@@ -8,6 +8,12 @@ export const FONT_GROUPS = [
8
8
  { k: 'lexend', fam: 'Lexend', fb: 'sans', d: 'font.desc.lexend' },
9
9
  { k: 'quattro', fam: 'iA Writer Quattro', fb: 'sans', d: 'font.desc.quattro' },
10
10
  { k: 'andika', fam: 'Andika', fb: 'sans', d: 'font.desc.andika' },
11
+ // ⚠️ A OPENDYSLEXIC ENTRA SEM NENHUMA ALEGAÇÃO DE EFICÁCIA, e a restrição é da issue #87 item 3 e do
12
+ // `docs/game-design/typography.md`, que diz por extenso: «Ofereça a Dyslexie e a OpenDyslexic apenas como
13
+ // escolha do usuário. A pesquisa não mostra ganho de leitura com elas.» A descrição dela fala do DESENHO
14
+ // (hastes pesadas em baixo), nunca do efeito — prometer leitura melhor seria vender a uma criança
15
+ // disléxica uma coisa que a evidência não sustenta, e ela é quem menos pode pagar por isso.
16
+ { k: 'opendyslexic', fam: 'OpenDyslexic', fb: 'sans', d: 'font.desc.opendyslexic' },
11
17
  { k: 'sourcesans', fam: 'Source Sans 3', fb: 'sans' },
12
18
  { k: 'inter', fam: 'Inter', fb: 'sans' },
13
19
  { k: 'opensans', fam: 'Open Sans', fb: 'sans' },
@@ -18,16 +24,101 @@ export const FONT_GROUPS = [
18
24
  { k: 'sourceserif', fam: 'Source Serif 4', fb: 'serif' },
19
25
  { k: 'newsreader', fam: 'Newsreader', fb: 'serif' }
20
26
  ] },
27
+ // ⚠️ QUATRO FACES SAÍRAM DAQUI EM 2026-09-07 (issue #87, item 3, decisão do Dev):
28
+ // · `greatvibes` (44 KB) e `ufcook` (20 KB) — peso que o roster não paga;
29
+ // · `learningcurve` e `kindergarten` — eram entradas `.off` SEM FICHEIRO, isto é, o menu oferecia-as
30
+ // desabilitadas e nada existia por trás. Uma linha que só serve para dizer «ainda não» é uma linha que
31
+ // a criança lê e não pode usar.
21
32
  { g: 'font.group.hand', items: [
22
- { k: 'greatvibes', fam: 'Great Vibes', fb: 'cursive', d: 'font.desc.greatvibes' },
23
- { k: 'pinyon', fam: 'Pinyon Script', fb: 'cursive', d: 'font.desc.pinyon' },
24
- { k: 'ufcook', fam: 'UnifrakturCook', fb: 'cursive', d: 'font.desc.ufcook' },
25
- { k: 'ufmag', fam: 'UnifrakturMaguntia', fb: 'cursive', d: 'font.desc.ufmag' },
33
+ { k: 'pinyon', fam: 'Pinyon Script', fb: 'cursive', d: 'font.desc.pinyon', papel: 'caligrafica', minPx: 24 },
34
+ { k: 'ufmag', fam: 'UnifrakturMaguntia', fb: 'cursive', d: 'font.desc.ufmag', papel: 'caligrafica', minPx: 20 },
35
+ // Fondamento entra pela emenda do ADR-0012 (#87 item 3) com o mínimo que o Dev fixou. Caligráfica, logo
36
+ // fora do menu — ela é para os botões DENTRO das atividades escolares, não para a interface.
37
+ { k: 'fondamento', fam: 'Fondamento', fb: 'cursive', d: 'font.desc.fondamento', papel: 'caligrafica', minPx: 20 },
38
+ // ⚠️ `comicneue` NÃO é caligráfica, e está neste grupo só por aparência: é uma face de propósito geral,
39
+ // frequentemente recomendada para dislexia. Marcá-la como caligráfica tirá-la-ia do menu — removendo uma
40
+ // opção legitimamente acessível pelo formato do grupo em vez de pelo papel.
26
41
  { k: 'comicneue', fam: 'Comic Neue', fb: 'cursive', d: 'font.desc.comicneue' },
27
- { k: 'learningcurve', fam: 'Learning Curve', fb: 'cursive', d: 'font.desc.learningcurve', off: 'font.off.pending' },
28
- { k: 'kindergarten', fam: 'Kindergarten Pro', fb: 'cursive', d: 'font.desc.kindergarten', off: 'font.off.negotiating' }
42
+ /*
43
+ * A RONDE FRANCESA — o item 4 da #87, decidido no ADR-0108 §4. Ela NUNCA é empacotada: as três faces são
44
+ * livres só para uso PESSOAL (ADR-0012), e distribuí-las seria distribuir o que não foi licenciado para
45
+ * distribuição. O que muda é que a opção passa a FALAR.
46
+ *
47
+ * ⚠️ E ISTO NÃO É A ENTRADA `.off` QUE ESTE CATÁLOGO JÁ REMOVEU. O cabeçalho acima tirou a `learningcurve`
48
+ * e a `kindergarten` com a razão certa — «uma linha que só serve para dizer "ainda não" é uma linha que a
49
+ * criança lê e não pode usar». A diferença é ACCIONABILIDADE, e é a razão que o ADR-0108 dá por extenso:
50
+ * aquelas diziam «ainda não», que ninguém pode resolver; esta diz QUAIS TRÊS FONTES INSTALAR, que um
51
+ * adulto resolve numa tarde. 📌 «Instale uma fonte ronde» seria o defeito de volta — um adulto não age
52
+ * sobre uma categoria —, e é por isso que a mensagem nomeia as três.
53
+ *
54
+ * ⚠️ `papel` AUSENTE, logo `geral`, e é deliberado apesar de a ronde ser caligráfica por natureza: as
55
+ * caligráficas são filtradas do menu (`papelDaFonte === 'geral'`), e uma linha filtrada não pode dizer
56
+ * nada a ninguém. Marcar o papel «certo» aqui apagaria a única coisa que este item existe para fazer.
57
+ */
58
+ /*
59
+ * AS OITO PLAYWRITE — o item 3 da #87, decidido no ADR-0108 §2 e entregue em 2026-09-09.
60
+ *
61
+ * ⚠️ `papel: caligrafica`, logo FORA DO MENU: elas são a mão que se aprende a escrever, para os botões
62
+ * DENTRO das atividades escolares, e não uma opção de interface. É a divisão do item 1 desta issue.
63
+ *
64
+ * 📌 `minPx: 20` é o mesmo piso que as outras três cursivas carregam. A lista de mínimos da #87 não
65
+ * nomeia a Playwrite — o número é o das irmãs, e não uma medição própria; corrigir-se com uma linha.
66
+ */
67
+ { k: 'pwbr', fam: 'Playwrite BR', fb: 'cursive', d: 'font.desc.pw.br', papel: 'caligrafica', minPx: 20 },
68
+ { k: 'pwustrad', fam: 'Playwrite US Trad', fb: 'cursive', d: 'font.desc.pw.ustrad', papel: 'caligrafica', minPx: 20 },
69
+ { k: 'pwusmod', fam: 'Playwrite US Modern', fb: 'cursive', d: 'font.desc.pw.usmod', papel: 'caligrafica', minPx: 20 },
70
+ { k: 'pwca', fam: 'Playwrite CA', fb: 'cursive', d: 'font.desc.pw.ca', papel: 'caligrafica', minPx: 20 },
71
+ { k: 'pwmx', fam: 'Playwrite MX', fb: 'cursive', d: 'font.desc.pw.mx', papel: 'caligrafica', minPx: 20 },
72
+ { k: 'pwar', fam: 'Playwrite AR', fb: 'cursive', d: 'font.desc.pw.ar', papel: 'caligrafica', minPx: 20 },
73
+ { k: 'pwcl', fam: 'Playwrite CL', fb: 'cursive', d: 'font.desc.pw.cl', papel: 'caligrafica', minPx: 20 },
74
+ { k: 'pwco', fam: 'Playwrite CO', fb: 'cursive', d: 'font.desc.pw.co', papel: 'caligrafica', minPx: 20 },
75
+ { k: 'ronde', fam: 'Ronde Script, OPTIFrench-Script, Merveille', fb: 'cursive',
76
+ d: 'font.desc.ronde', off: 'font.off.ronde' }
77
+ ] },
78
+ // ⚠️ A FACE DO JOGO, e ela tem grupo próprio porque não é nem sans, nem serifada, nem manuscrita — é uma
79
+ // face de PIXEL, e pô-la em qualquer um dos três diria a coisa errada sobre ela na lista.
80
+ //
81
+ // Ela NÃO aparece no menu (`papel:'jogo'`), e o ficheiro veio do `SP-the-inclusionist-whackwhack`, onde já
82
+ // estava verificado. Ver a nota do `@font-face` em `vendor/fonts.css`: o subconjunto errado desta família
83
+ // carrega, declara-se e reporta-se como certo, e não desenha uma única letra latina.
84
+ { g: 'font.group.arcade', items: [
85
+ { k: 'pressstart', fam: 'Press Start 2P', fb: 'monospace', d: 'font.desc.pressstart', papel: 'jogo' }
29
86
  ] },
30
87
  ];
88
+ /** O papel de uma face; ausente no catálogo quer dizer `geral`. */
89
+ export function papelDaFonte(it) { return it.papel ?? 'geral'; }
90
+ /**
91
+ * AS FAMÍLIAS QUE UMA FACE ACEITA, do `fam` que pode ser uma PILHA.
92
+ *
93
+ * 📌 A Ronde declara três (`'Ronde Script, OPTIFrench-Script, Merveille'`) porque qualquer uma delas serve —
94
+ * são três desenhos da mesma letra de mão, e um adulto instala a que encontrar. As outras faces declaram uma
95
+ * só, e para elas isto devolve uma lista de um.
96
+ */
97
+ export function familiasDaFace(it) {
98
+ return it.fam.split(',').map((f) => f.trim().replace(/^['"]|['"]$/g, '')).filter(Boolean);
99
+ }
100
+ /**
101
+ * ESTA FACE PODE SER USADA AGORA? — o `off` deixa de ser uma sentença e passa a ser uma CONDIÇÃO.
102
+ *
103
+ * O ADR-0012 decidiu que a opção da ronde «fica DESABILITADA enquanto nenhuma fonte estiver presente», e o
104
+ * ADR-0108 §4 acrescentou o que ela diz. A palavra «enquanto» é o que esta função constrói: uma face `off`
105
+ * volta a ficar disponível no instante em que o adulto instala uma das que a mensagem nomeia.
106
+ *
107
+ * ⚠️ O DETECTOR É INJECTADO, e nunca `document.fonts` lido daqui: este módulo é o catálogo, corre em node nos
108
+ * gates, e ler um global do navegador aqui é o ACHADO 15 outra vez — o `srAlert` que rebentou o boot contra um
109
+ * documento injectado.
110
+ * 📌 E o PADRÃO É «não instalada», que é seguro por uma razão que não vale para todos os padrões deste
111
+ * repositório: sem detector a opção fica desabilitada COM a mensagem, e a mensagem diz ao adulto exactamente
112
+ * o que fazer. O silêncio não decide nada contra a criança — ele mantém o estado que já existia e que é
113
+ * accionável. É o oposto do `seguraTeclas`, onde os dois lados do padrão erravam.
114
+ */
115
+ export function faceDisponivel(it, instalada) {
116
+ if (!it.off)
117
+ return true;
118
+ return !!instalada && familiasDaFace(it).some((f) => instalada(f));
119
+ }
120
+ /** As faces que o MENU pode oferecer: só as gerais (emenda do ADR-0012). */
121
+ export const OFERECIVEIS = FONT_GROUPS.flatMap((g) => g.items).filter((it) => papelDaFonte(it) === 'geral');
31
122
  export const FONT_BY_KEY = {};
32
123
  FONT_GROUPS.forEach((g) => g.items.forEach((it) => { FONT_BY_KEY[it.k] = it; }));
33
124
  export const FONT_KEY = 'incl_font_k';
@@ -41,10 +132,22 @@ export const FONT_KEY_LEGACY = 'incl_fonte'; // pre-Fase-2: 'alfabetizacao' | 'd
41
132
  * porque duas cópias de um padrão são duas chances de o reset devolver algo que o jogo nunca usou.
42
133
  */
43
134
  export const DEFAULT_FONT_KEY = 'atkinson';
44
- /** Boot choice: validated persisted key (ignores .off fonts) -> legacy-key migration -> DEFAULT_FONT_KEY. */
135
+ /**
136
+ * Boot choice: validated persisted key (ignores .off fonts) -> legacy-key migration -> DEFAULT_FONT_KEY.
137
+ *
138
+ * ⚠️ E UMA CALIGRÁFICA GUARDADA VOLTA AO PADRÃO (issue #87, 2026-09-07). Antes da emenda do ADR-0012 o menu
139
+ * oferecia as sete caligráficas, então há crianças com `pinyon` ou `ufmag` guardados — e o valor salvo
140
+ * continua a ser escolha delas, que não é nossa para desfazer sem motivo. O motivo existe: a emenda diz que
141
+ * uma caligráfica **não pode ser a face da interface**, e o menu deixou de a oferecer. Deixá-la valer daria
142
+ * uma interface inteira em letra cursiva a quem já não tem como sair dela pelo menu — uma armadilha, e das
143
+ * silenciosas.
144
+ *
145
+ * ⚠️ E UMA CHAVE APAGADA TAMBÉM VOLTA, pelo mesmo caminho: as quatro faces que saíram do roster já não estão
146
+ * no `FONT_BY_KEY`, então quem tinha `greatvibes` guardado cai no padrão em vez de ficar sem face nenhuma.
147
+ */
45
148
  export function resolveFontKey(s) {
46
149
  const k = s.get(FONT_KEY, null);
47
- if (k && FONT_BY_KEY[k] && !FONT_BY_KEY[k].off)
150
+ if (k && FONT_BY_KEY[k] && !FONT_BY_KEY[k].off && papelDaFonte(FONT_BY_KEY[k]) === 'geral')
48
151
  return k;
49
152
  const leg = s.get(FONT_KEY_LEGACY, null);
50
153
  if (leg === 'alfabetizacao')