@the-inclusionist/engine 10.0.0 → 11.0.0

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 (528) hide show
  1. package/README.md +32 -1
  2. package/app/css/style.css +216 -42
  3. package/app/public/vendor/fonts-licences/NOTICE.txt +166 -0
  4. package/app/public/vendor/fonts-licences/OFL-1.1.txt +86 -0
  5. package/app/public/vendor/fonts.css +11 -1175
  6. package/dist-pkg/boot/create-game.d.ts +84 -33
  7. package/dist-pkg/boot/create-game.js +708 -307
  8. package/dist-pkg/core/a11y-sr.d.ts +1 -1
  9. package/dist-pkg/core/a11y-sr.js +3 -5
  10. package/dist-pkg/core/accommodations.d.ts +26 -8
  11. package/dist-pkg/core/accommodations.js +27 -10
  12. package/dist-pkg/core/actions.d.ts +39 -10
  13. package/dist-pkg/core/actions.js +28 -6
  14. package/dist-pkg/core/contract.d.ts +0 -2
  15. package/dist-pkg/core/contract.js +0 -2
  16. package/dist-pkg/core/i18n.d.ts +36 -71
  17. package/dist-pkg/core/i18n.js +131 -198
  18. package/dist-pkg/core/session-clock.d.ts +23 -0
  19. package/dist-pkg/core/session-clock.js +37 -0
  20. package/dist-pkg/i18n/en.js +44 -49
  21. package/dist-pkg/i18n/es.js +44 -49
  22. package/dist-pkg/i18n/pt.js +49 -54
  23. package/dist-pkg/input/gamepad.d.ts +42 -11
  24. package/dist-pkg/input/gamepad.js +50 -15
  25. package/dist-pkg/input/key-default.d.ts +30 -0
  26. package/dist-pkg/input/key-default.js +60 -0
  27. package/dist-pkg/input/keyboard-runtime.d.ts +0 -4
  28. package/dist-pkg/input/keyboard.d.ts +0 -1
  29. package/dist-pkg/input/keydown.d.ts +5 -11
  30. package/dist-pkg/input/keydown.js +3 -3
  31. package/dist-pkg/input/latch-edge.d.ts +10 -2
  32. package/dist-pkg/input/latch-edge.js +4 -3
  33. package/dist-pkg/input/latch-scope.d.ts +27 -22
  34. package/dist-pkg/input/latch-scope.js +25 -37
  35. package/dist-pkg/input/latch-store.d.ts +9 -12
  36. package/dist-pkg/input/latch-store.js +13 -18
  37. package/dist-pkg/input/latch-sync.d.ts +5 -4
  38. package/dist-pkg/input/latch-sync.js +5 -5
  39. package/dist-pkg/input/pad-reading.js +7 -2
  40. package/dist-pkg/input/pad-wizard.d.ts +4 -1
  41. package/dist-pkg/input/pad-wizard.js +60 -9
  42. package/dist-pkg/input/switch-scan.d.ts +29 -12
  43. package/dist-pkg/input/switch-scan.js +15 -2
  44. package/dist-pkg/input/touch-bindings.d.ts +3 -6
  45. package/dist-pkg/input/touch.d.ts +17 -2
  46. package/dist-pkg/input/touch.js +55 -17
  47. package/dist-pkg/input/transport-in-use.d.ts +5 -5
  48. package/dist-pkg/input/transport-in-use.js +5 -5
  49. package/dist-pkg/input/transports.d.ts +0 -9
  50. package/dist-pkg/input/transports.js +1 -1
  51. package/dist-pkg/input/virtual-controller.d.ts +39 -3
  52. package/dist-pkg/input/virtual-controller.js +32 -4
  53. package/dist-pkg/input/voice-map.d.ts +27 -11
  54. package/dist-pkg/input/voice-map.js +41 -43
  55. package/dist-pkg/platform/audio-ambient.d.ts +0 -1
  56. package/dist-pkg/platform/audio-ambient.js +3 -1
  57. package/dist-pkg/platform/audio-mixer.js +4 -9
  58. package/dist-pkg/platform/audio-sonar.d.ts +8 -94
  59. package/dist-pkg/platform/audio-sonar.js +22 -207
  60. package/dist-pkg/platform/audio.d.ts +10 -1
  61. package/dist-pkg/platform/audio.js +35 -2
  62. package/dist-pkg/platform/choose-by-voice.d.ts +70 -0
  63. package/dist-pkg/platform/choose-by-voice.js +157 -0
  64. package/dist-pkg/platform/font-library.d.ts +77 -0
  65. package/dist-pkg/platform/font-library.js +98 -0
  66. package/dist-pkg/platform/font-library.json +721 -0
  67. package/dist-pkg/platform/heavy-catalogue.d.ts +46 -0
  68. package/dist-pkg/platform/heavy-catalogue.js +47 -14
  69. package/dist-pkg/platform/heavy-mirror.d.ts +3 -9
  70. package/dist-pkg/platform/heavy-mirror.js +14 -17
  71. package/dist-pkg/platform/heavy.d.ts +40 -8
  72. package/dist-pkg/platform/heavy.js +177 -22
  73. package/dist-pkg/platform/interruptible-speech.d.ts +15 -8
  74. package/dist-pkg/platform/interruptible-speech.js +37 -13
  75. package/dist-pkg/platform/listener-scope.js +22 -13
  76. package/dist-pkg/platform/locale-host.d.ts +7 -3
  77. package/dist-pkg/platform/locale-host.js +13 -2
  78. package/dist-pkg/platform/reading-in-worker.d.ts +24 -0
  79. package/dist-pkg/platform/reading-in-worker.js +38 -0
  80. package/dist-pkg/platform/reading-model.d.ts +21 -0
  81. package/dist-pkg/platform/reading-model.js +25 -0
  82. package/dist-pkg/platform/reading-runtime.js +13 -8
  83. package/dist-pkg/platform/reading.d.ts +12 -1
  84. package/dist-pkg/platform/reading.js +9 -1
  85. package/dist-pkg/platform/speech-recognition.d.ts +10 -1
  86. package/dist-pkg/platform/speech-recognition.js +29 -9
  87. package/dist-pkg/platform/speech.d.ts +10 -1
  88. package/dist-pkg/platform/speech.js +24 -13
  89. package/dist-pkg/platform/tts.d.ts +37 -4
  90. package/dist-pkg/platform/tts.js +279 -57
  91. package/dist-pkg/platform/voice-listener.d.ts +6 -2
  92. package/dist-pkg/platform/voice-listener.js +1 -1
  93. package/dist-pkg/platform/voice-plan.d.ts +13 -0
  94. package/dist-pkg/platform/voice-plan.js +57 -2
  95. package/dist-pkg/platform/vosk-runtime.d.ts +25 -0
  96. package/dist-pkg/platform/vosk-runtime.js +59 -1
  97. package/dist-pkg/platform/vosk-vocabulary.d.ts +6 -0
  98. package/dist-pkg/platform/vosk-vocabulary.js +178 -0
  99. package/dist-pkg/render/crt.d.ts +11 -9
  100. package/dist-pkg/render/crt.js +6 -10
  101. package/dist-pkg/render/cvd-matrices.d.ts +5 -1
  102. package/dist-pkg/render/cvd-matrices.js +5 -1
  103. package/dist-pkg/render/high-contrast.d.ts +1 -2
  104. package/dist-pkg/render/high-contrast.js +5 -1
  105. package/dist-pkg/render/viz-axes.d.ts +9 -0
  106. package/dist-pkg/render/viz-axes.js +15 -2
  107. package/dist-pkg/ui/aac-sets.d.ts +29 -24
  108. package/dist-pkg/ui/aac-sets.js +22 -30
  109. package/dist-pkg/ui/audio-choices.d.ts +10 -5
  110. package/dist-pkg/ui/audio-choices.js +14 -10
  111. package/dist-pkg/ui/camera-control.d.ts +47 -0
  112. package/dist-pkg/ui/camera-control.js +35 -1
  113. package/dist-pkg/ui/declared-words.d.ts +21 -0
  114. package/dist-pkg/ui/declared-words.js +66 -0
  115. package/dist-pkg/ui/drawing-problems.js +4 -3
  116. package/dist-pkg/ui/eye-control.js +17 -17
  117. package/dist-pkg/ui/face-control.js +15 -15
  118. package/dist-pkg/ui/fonts.d.ts +42 -8
  119. package/dist-pkg/ui/fonts.js +58 -24
  120. package/dist-pkg/ui/footer-scroll-driver.d.ts +14 -0
  121. package/dist-pkg/ui/footer-scroll-driver.js +201 -0
  122. package/dist-pkg/ui/footer-scroll.d.ts +38 -0
  123. package/dist-pkg/ui/footer-scroll.js +55 -0
  124. package/dist-pkg/ui/game-options.d.ts +14 -9
  125. package/dist-pkg/ui/game-options.js +41 -21
  126. package/dist-pkg/ui/hand-control.js +15 -15
  127. package/dist-pkg/ui/help-panel.d.ts +19 -8
  128. package/dist-pkg/ui/help-panel.js +15 -5
  129. package/dist-pkg/ui/hud-bands.d.ts +29 -12
  130. package/dist-pkg/ui/hud-bands.js +63 -25
  131. package/dist-pkg/ui/hud-row.d.ts +34 -0
  132. package/dist-pkg/ui/hud-row.js +80 -0
  133. package/dist-pkg/ui/hud.d.ts +0 -3
  134. package/dist-pkg/ui/layout.js +5 -2
  135. package/dist-pkg/ui/libras-avatar-clip.d.ts +30 -0
  136. package/dist-pkg/ui/libras-avatar-clip.js +86 -0
  137. package/dist-pkg/ui/libras-avatar-load.d.ts +52 -0
  138. package/dist-pkg/ui/libras-avatar-load.js +134 -0
  139. package/dist-pkg/ui/libras-avatar-plan.d.ts +102 -0
  140. package/dist-pkg/ui/libras-avatar-plan.js +211 -0
  141. package/dist-pkg/ui/libras-avatar-player.d.ts +28 -0
  142. package/dist-pkg/ui/libras-avatar-player.js +226 -0
  143. package/dist-pkg/ui/libras-avatar-stage.d.ts +37 -0
  144. package/dist-pkg/ui/libras-avatar-stage.js +208 -0
  145. package/dist-pkg/ui/libras-glosses.d.ts +28 -0
  146. package/dist-pkg/ui/libras-glosses.js +161 -0
  147. package/dist-pkg/ui/menu-intent.d.ts +29 -0
  148. package/dist-pkg/ui/menu-intent.js +35 -1
  149. package/dist-pkg/ui/menu-items.d.ts +10 -0
  150. package/dist-pkg/ui/menu-items.js +29 -0
  151. package/dist-pkg/ui/menu-nav.d.ts +40 -4
  152. package/dist-pkg/ui/menu-nav.js +132 -47
  153. package/dist-pkg/ui/panel-widgets.d.ts +34 -0
  154. package/dist-pkg/ui/panel-widgets.js +86 -0
  155. package/dist-pkg/ui/pause-icons.d.ts +30 -14
  156. package/dist-pkg/ui/pause-icons.js +106 -45
  157. package/dist-pkg/ui/scan-overlay.d.ts +3 -3
  158. package/dist-pkg/ui/scan-overlay.js +11 -3
  159. package/dist-pkg/ui/screen-text.d.ts +25 -0
  160. package/dist-pkg/ui/screen-text.js +152 -0
  161. package/dist-pkg/ui/session-clock.d.ts +32 -0
  162. package/dist-pkg/ui/session-clock.js +67 -0
  163. package/dist-pkg/ui/settings-aac.d.ts +3 -1
  164. package/dist-pkg/ui/settings-aac.js +32 -13
  165. package/dist-pkg/ui/settings-audio.d.ts +10 -5
  166. package/dist-pkg/ui/settings-audio.js +101 -35
  167. package/dist-pkg/ui/settings-controls.d.ts +0 -4
  168. package/dist-pkg/ui/settings-mobility.d.ts +3 -6
  169. package/dist-pkg/ui/settings-mobility.js +4 -46
  170. package/dist-pkg/ui/settings-motion.js +2 -2
  171. package/dist-pkg/ui/settings-panel.d.ts +10 -0
  172. package/dist-pkg/ui/settings-panel.js +25 -1
  173. package/dist-pkg/ui/settings-typo.d.ts +5 -6
  174. package/dist-pkg/ui/settings-typo.js +20 -11
  175. package/dist-pkg/ui/settings-visual.js +2 -2
  176. package/dist-pkg/ui/shell.d.ts +1 -3
  177. package/dist-pkg/ui/shell.js +8 -4
  178. package/dist-pkg/ui/title.d.ts +2 -11
  179. package/dist-pkg/ui/title.js +39 -6
  180. package/dist-pkg/ui/top-band.d.ts +5 -3
  181. package/dist-pkg/ui/top-band.js +21 -25
  182. package/dist-pkg/ui/typo-choices.d.ts +3 -3
  183. package/dist-pkg/ui/typo-choices.js +9 -8
  184. package/dist-pkg/ui/vlibras.d.ts +71 -27
  185. package/dist-pkg/ui/vlibras.js +116 -44
  186. package/dist-pkg/ui/voice-control.d.ts +23 -1
  187. package/dist-pkg/ui/voice-control.js +149 -16
  188. package/dist-pkg/ui/voice-settings.d.ts +2 -3
  189. package/dist-pkg/ui/voice-settings.js +53 -3
  190. package/dist-pkg/ui/where-the-child-is.js +2 -2
  191. package/docs/CREDITS.md +122 -10
  192. package/docs/LICENSES.md +36 -7
  193. package/package.json +34 -9
  194. package/scripts/check-cartridge.mjs +78 -0
  195. package/scripts/game-build.d.mts +25 -0
  196. package/scripts/game-build.mjs +144 -0
  197. package/scripts/heavy-into-the-delivery.mjs +284 -40
  198. package/scripts/libras-avatar.json +680 -0
  199. package/scripts/libras-avatar.mjs +246 -0
  200. package/scripts/libras-glosses/gloss.py +95 -0
  201. package/scripts/libras-glosses/pyproject.toml +32 -0
  202. package/scripts/libras-glosses/uv.lock +781 -0
  203. package/scripts/libras-glosses.mjs +351 -0
  204. package/scripts/licences/OFL-1.1.txt +86 -0
  205. package/scripts/licences/UFL-1.0.txt +96 -0
  206. package/scripts/licences/fonts.mjs +389 -0
  207. package/scripts/licences/third-party.mjs +35 -21
  208. package/app/public/vendor/fonts/bitter-var-ext.woff2 +0 -0
  209. package/app/public/vendor/fonts/bitter-var.woff2 +0 -0
  210. package/app/public/vendor/fonts/bodonimoda-var-ext.woff2 +0 -0
  211. package/app/public/vendor/fonts/bodonimoda-var.woff2 +0 -0
  212. package/app/public/vendor/fonts/cat-abril_fatface-400-ext.woff2 +0 -0
  213. package/app/public/vendor/fonts/cat-abril_fatface-400.woff2 +0 -0
  214. package/app/public/vendor/fonts/cat-adlam_display-400-ext.woff2 +0 -0
  215. package/app/public/vendor/fonts/cat-adlam_display-400.woff2 +0 -0
  216. package/app/public/vendor/fonts/cat-agu_display-400-ext.woff2 +0 -0
  217. package/app/public/vendor/fonts/cat-agu_display-400.woff2 +0 -0
  218. package/app/public/vendor/fonts/cat-alfa_slab_one-400-ext.woff2 +0 -0
  219. package/app/public/vendor/fonts/cat-alfa_slab_one-400.woff2 +0 -0
  220. package/app/public/vendor/fonts/cat-are_you_serious-400-ext.woff2 +0 -0
  221. package/app/public/vendor/fonts/cat-are_you_serious-400.woff2 +0 -0
  222. package/app/public/vendor/fonts/cat-asimovian-400-ext.woff2 +0 -0
  223. package/app/public/vendor/fonts/cat-asimovian-400.woff2 +0 -0
  224. package/app/public/vendor/fonts/cat-atkinson_hyperlegible_next-400-ext.woff2 +0 -0
  225. package/app/public/vendor/fonts/cat-atkinson_hyperlegible_next-400.woff2 +0 -0
  226. package/app/public/vendor/fonts/cat-bakbak_one-400-ext.woff2 +0 -0
  227. package/app/public/vendor/fonts/cat-bakbak_one-400.woff2 +0 -0
  228. package/app/public/vendor/fonts/cat-bangers-400-ext.woff2 +0 -0
  229. package/app/public/vendor/fonts/cat-bangers-400.woff2 +0 -0
  230. package/app/public/vendor/fonts/cat-barriecito-400-ext.woff2 +0 -0
  231. package/app/public/vendor/fonts/cat-barriecito-400.woff2 +0 -0
  232. package/app/public/vendor/fonts/cat-berkshire_swash-400-ext.woff2 +0 -0
  233. package/app/public/vendor/fonts/cat-berkshire_swash-400.woff2 +0 -0
  234. package/app/public/vendor/fonts/cat-bevan-400-ext.woff2 +0 -0
  235. package/app/public/vendor/fonts/cat-bevan-400.woff2 +0 -0
  236. package/app/public/vendor/fonts/cat-bitcount-400-ext.woff2 +0 -0
  237. package/app/public/vendor/fonts/cat-bitcount-400.woff2 +0 -0
  238. package/app/public/vendor/fonts/cat-bowlby_one_sc-400-ext.woff2 +0 -0
  239. package/app/public/vendor/fonts/cat-bowlby_one_sc-400.woff2 +0 -0
  240. package/app/public/vendor/fonts/cat-bungee_shade-400-ext.woff2 +0 -0
  241. package/app/public/vendor/fonts/cat-bungee_shade-400.woff2 +0 -0
  242. package/app/public/vendor/fonts/cat-charm-400-ext.woff2 +0 -0
  243. package/app/public/vendor/fonts/cat-charm-400.woff2 +0 -0
  244. package/app/public/vendor/fonts/cat-charm-700-ext.woff2 +0 -0
  245. package/app/public/vendor/fonts/cat-charm-700.woff2 +0 -0
  246. package/app/public/vendor/fonts/cat-cherry_bomb_one-400-ext.woff2 +0 -0
  247. package/app/public/vendor/fonts/cat-cherry_bomb_one-400.woff2 +0 -0
  248. package/app/public/vendor/fonts/cat-chokokutai-400-ext.woff2 +0 -0
  249. package/app/public/vendor/fonts/cat-chokokutai-400.woff2 +0 -0
  250. package/app/public/vendor/fonts/cat-chonburi-400-ext.woff2 +0 -0
  251. package/app/public/vendor/fonts/cat-chonburi-400.woff2 +0 -0
  252. package/app/public/vendor/fonts/cat-climate_crisis-400-ext.woff2 +0 -0
  253. package/app/public/vendor/fonts/cat-climate_crisis-400.woff2 +0 -0
  254. package/app/public/vendor/fonts/cat-cookie-400.woff2 +0 -0
  255. package/app/public/vendor/fonts/cat-dela_gothic_one-400-ext.woff2 +0 -0
  256. package/app/public/vendor/fonts/cat-dela_gothic_one-400.woff2 +0 -0
  257. package/app/public/vendor/fonts/cat-fascinate_inline-400-ext.woff2 +0 -0
  258. package/app/public/vendor/fonts/cat-fascinate_inline-400.woff2 +0 -0
  259. package/app/public/vendor/fonts/cat-ga_maamli-400-ext.woff2 +0 -0
  260. package/app/public/vendor/fonts/cat-ga_maamli-400.woff2 +0 -0
  261. package/app/public/vendor/fonts/cat-itim-400-ext.woff2 +0 -0
  262. package/app/public/vendor/fonts/cat-itim-400.woff2 +0 -0
  263. package/app/public/vendor/fonts/cat-jacquard_24-400-ext.woff2 +0 -0
  264. package/app/public/vendor/fonts/cat-jacquard_24-400.woff2 +0 -0
  265. package/app/public/vendor/fonts/cat-kablammo-400-ext.woff2 +0 -0
  266. package/app/public/vendor/fonts/cat-kablammo-400.woff2 +0 -0
  267. package/app/public/vendor/fonts/cat-kalam-400-ext.woff2 +0 -0
  268. package/app/public/vendor/fonts/cat-kalam-400.woff2 +0 -0
  269. package/app/public/vendor/fonts/cat-kalam-700-ext.woff2 +0 -0
  270. package/app/public/vendor/fonts/cat-kalam-700.woff2 +0 -0
  271. package/app/public/vendor/fonts/cat-katibeh-400-ext.woff2 +0 -0
  272. package/app/public/vendor/fonts/cat-katibeh-400.woff2 +0 -0
  273. package/app/public/vendor/fonts/cat-lemonada-400-ext.woff2 +0 -0
  274. package/app/public/vendor/fonts/cat-lemonada-400.woff2 +0 -0
  275. package/app/public/vendor/fonts/cat-lilita_one-400-ext.woff2 +0 -0
  276. package/app/public/vendor/fonts/cat-lilita_one-400.woff2 +0 -0
  277. package/app/public/vendor/fonts/cat-lobster-400-ext.woff2 +0 -0
  278. package/app/public/vendor/fonts/cat-lobster-400.woff2 +0 -0
  279. package/app/public/vendor/fonts/cat-luckiest_guy-400-ext.woff2 +0 -0
  280. package/app/public/vendor/fonts/cat-luckiest_guy-400.woff2 +0 -0
  281. package/app/public/vendor/fonts/cat-marhey-400-ext.woff2 +0 -0
  282. package/app/public/vendor/fonts/cat-marhey-400.woff2 +0 -0
  283. package/app/public/vendor/fonts/cat-modak-400-ext.woff2 +0 -0
  284. package/app/public/vendor/fonts/cat-modak-400.woff2 +0 -0
  285. package/app/public/vendor/fonts/cat-momo_signature-400-ext.woff2 +0 -0
  286. package/app/public/vendor/fonts/cat-momo_signature-400.woff2 +0 -0
  287. package/app/public/vendor/fonts/cat-monofett-400-ext.woff2 +0 -0
  288. package/app/public/vendor/fonts/cat-monofett-400.woff2 +0 -0
  289. package/app/public/vendor/fonts/cat-monomakh-400-ext.woff2 +0 -0
  290. package/app/public/vendor/fonts/cat-monomakh-400.woff2 +0 -0
  291. package/app/public/vendor/fonts/cat-monoton-400-ext.woff2 +0 -0
  292. package/app/public/vendor/fonts/cat-monoton-400.woff2 +0 -0
  293. package/app/public/vendor/fonts/cat-nerko_one-400-ext.woff2 +0 -0
  294. package/app/public/vendor/fonts/cat-nerko_one-400.woff2 +0 -0
  295. package/app/public/vendor/fonts/cat-noto_sans_adlam-400-ext.woff2 +0 -0
  296. package/app/public/vendor/fonts/cat-noto_sans_adlam-400.woff2 +0 -0
  297. package/app/public/vendor/fonts/cat-noto_sans_arabic-400-ext.woff2 +0 -0
  298. package/app/public/vendor/fonts/cat-noto_sans_arabic-400.woff2 +0 -0
  299. package/app/public/vendor/fonts/cat-noto_sans_devanagari-400-ext.woff2 +0 -0
  300. package/app/public/vendor/fonts/cat-noto_sans_devanagari-400.woff2 +0 -0
  301. package/app/public/vendor/fonts/cat-noto_sans_hebrew-400-ext.woff2 +0 -0
  302. package/app/public/vendor/fonts/cat-noto_sans_hebrew-400.woff2 +0 -0
  303. package/app/public/vendor/fonts/cat-noto_sans_jp-400-ext.woff2 +0 -0
  304. package/app/public/vendor/fonts/cat-noto_sans_jp-400.woff2 +0 -0
  305. package/app/public/vendor/fonts/cat-noto_sans_khmer-400-ext.woff2 +0 -0
  306. package/app/public/vendor/fonts/cat-noto_sans_khmer-400.woff2 +0 -0
  307. package/app/public/vendor/fonts/cat-noto_sans_kr-400-ext.woff2 +0 -0
  308. package/app/public/vendor/fonts/cat-noto_sans_kr-400.woff2 +0 -0
  309. package/app/public/vendor/fonts/cat-noto_sans_sc-400-ext.woff2 +0 -0
  310. package/app/public/vendor/fonts/cat-noto_sans_sc-400.woff2 +0 -0
  311. package/app/public/vendor/fonts/cat-noto_sans_thai-400-ext.woff2 +0 -0
  312. package/app/public/vendor/fonts/cat-noto_sans_thai-400.woff2 +0 -0
  313. package/app/public/vendor/fonts/cat-ole-400-ext.woff2 +0 -0
  314. package/app/public/vendor/fonts/cat-ole-400.woff2 +0 -0
  315. package/app/public/vendor/fonts/cat-pacifico-400-ext.woff2 +0 -0
  316. package/app/public/vendor/fonts/cat-pacifico-400.woff2 +0 -0
  317. package/app/public/vendor/fonts/cat-patrick_hand-400-ext.woff2 +0 -0
  318. package/app/public/vendor/fonts/cat-patrick_hand-400.woff2 +0 -0
  319. package/app/public/vendor/fonts/cat-petit_formal_script-400-ext.woff2 +0 -0
  320. package/app/public/vendor/fonts/cat-petit_formal_script-400.woff2 +0 -0
  321. package/app/public/vendor/fonts/cat-pixelify_sans-400-ext.woff2 +0 -0
  322. package/app/public/vendor/fonts/cat-pixelify_sans-400.woff2 +0 -0
  323. package/app/public/vendor/fonts/cat-plaster-400-ext.woff2 +0 -0
  324. package/app/public/vendor/fonts/cat-plaster-400.woff2 +0 -0
  325. package/app/public/vendor/fonts/cat-playwrite_ar_guides-400-0.woff2 +0 -0
  326. package/app/public/vendor/fonts/cat-playwrite_at-400-0.woff2 +0 -0
  327. package/app/public/vendor/fonts/cat-playwrite_at_guides-400-0.woff2 +0 -0
  328. package/app/public/vendor/fonts/cat-playwrite_au_nsw-400-0.woff2 +0 -0
  329. package/app/public/vendor/fonts/cat-playwrite_au_nsw_guides-400-0.woff2 +0 -0
  330. package/app/public/vendor/fonts/cat-playwrite_au_qld-400-0.woff2 +0 -0
  331. package/app/public/vendor/fonts/cat-playwrite_au_qld_guides-400-0.woff2 +0 -0
  332. package/app/public/vendor/fonts/cat-playwrite_au_sa-400-0.woff2 +0 -0
  333. package/app/public/vendor/fonts/cat-playwrite_au_sa_guides-400-0.woff2 +0 -0
  334. package/app/public/vendor/fonts/cat-playwrite_au_tas-400-0.woff2 +0 -0
  335. package/app/public/vendor/fonts/cat-playwrite_au_tas_guides-400-0.woff2 +0 -0
  336. package/app/public/vendor/fonts/cat-playwrite_au_vic-400-0.woff2 +0 -0
  337. package/app/public/vendor/fonts/cat-playwrite_au_vic_guides-400-0.woff2 +0 -0
  338. package/app/public/vendor/fonts/cat-playwrite_be_vlg-400-0.woff2 +0 -0
  339. package/app/public/vendor/fonts/cat-playwrite_be_vlg_guides-400-0.woff2 +0 -0
  340. package/app/public/vendor/fonts/cat-playwrite_be_wal-400-0.woff2 +0 -0
  341. package/app/public/vendor/fonts/cat-playwrite_be_wal_guides-400-0.woff2 +0 -0
  342. package/app/public/vendor/fonts/cat-playwrite_br_guides-400-0.woff2 +0 -0
  343. package/app/public/vendor/fonts/cat-playwrite_ca_guides-400-0.woff2 +0 -0
  344. package/app/public/vendor/fonts/cat-playwrite_cl_guides-400-0.woff2 +0 -0
  345. package/app/public/vendor/fonts/cat-playwrite_co_guides-400-0.woff2 +0 -0
  346. package/app/public/vendor/fonts/cat-playwrite_cu_guides-400-0.woff2 +0 -0
  347. package/app/public/vendor/fonts/cat-playwrite_cz-400-0.woff2 +0 -0
  348. package/app/public/vendor/fonts/cat-playwrite_cz_guides-400-0.woff2 +0 -0
  349. package/app/public/vendor/fonts/cat-playwrite_de_grund-400-0.woff2 +0 -0
  350. package/app/public/vendor/fonts/cat-playwrite_de_grund_guides-400-0.woff2 +0 -0
  351. package/app/public/vendor/fonts/cat-playwrite_de_la-400-0.woff2 +0 -0
  352. package/app/public/vendor/fonts/cat-playwrite_de_la_guides-400-0.woff2 +0 -0
  353. package/app/public/vendor/fonts/cat-playwrite_de_sas-400-0.woff2 +0 -0
  354. package/app/public/vendor/fonts/cat-playwrite_de_sas_guides-400-0.woff2 +0 -0
  355. package/app/public/vendor/fonts/cat-playwrite_de_va-400-0.woff2 +0 -0
  356. package/app/public/vendor/fonts/cat-playwrite_de_va_guides-400-0.woff2 +0 -0
  357. package/app/public/vendor/fonts/cat-playwrite_dk_loopet-400-0.woff2 +0 -0
  358. package/app/public/vendor/fonts/cat-playwrite_dk_loopet_guides-400-0.woff2 +0 -0
  359. package/app/public/vendor/fonts/cat-playwrite_dk_uloopet-400-0.woff2 +0 -0
  360. package/app/public/vendor/fonts/cat-playwrite_dk_uloopet_guides-400-0.woff2 +0 -0
  361. package/app/public/vendor/fonts/cat-playwrite_es_deco_guides-400-0.woff2 +0 -0
  362. package/app/public/vendor/fonts/cat-playwrite_es_guides-400-0.woff2 +0 -0
  363. package/app/public/vendor/fonts/cat-playwrite_fr_moderne-400-0.woff2 +0 -0
  364. package/app/public/vendor/fonts/cat-playwrite_fr_moderne_guides-400-0.woff2 +0 -0
  365. package/app/public/vendor/fonts/cat-playwrite_fr_trad-400-0.woff2 +0 -0
  366. package/app/public/vendor/fonts/cat-playwrite_fr_trad_guides-400-0.woff2 +0 -0
  367. package/app/public/vendor/fonts/cat-playwrite_gb_j_guides-400-0.woff2 +0 -0
  368. package/app/public/vendor/fonts/cat-playwrite_gb_s_guides-400-0.woff2 +0 -0
  369. package/app/public/vendor/fonts/cat-playwrite_hr-400-0.woff2 +0 -0
  370. package/app/public/vendor/fonts/cat-playwrite_hr_guides-400-0.woff2 +0 -0
  371. package/app/public/vendor/fonts/cat-playwrite_hr_lijeva-400-0.woff2 +0 -0
  372. package/app/public/vendor/fonts/cat-playwrite_hr_lijeva_guides-400-0.woff2 +0 -0
  373. package/app/public/vendor/fonts/cat-playwrite_hu-400-0.woff2 +0 -0
  374. package/app/public/vendor/fonts/cat-playwrite_hu_guides-400-0.woff2 +0 -0
  375. package/app/public/vendor/fonts/cat-playwrite_id-400-0.woff2 +0 -0
  376. package/app/public/vendor/fonts/cat-playwrite_id_guides-400-0.woff2 +0 -0
  377. package/app/public/vendor/fonts/cat-playwrite_ie-400-0.woff2 +0 -0
  378. package/app/public/vendor/fonts/cat-playwrite_ie_guides-400-0.woff2 +0 -0
  379. package/app/public/vendor/fonts/cat-playwrite_in-400-0.woff2 +0 -0
  380. package/app/public/vendor/fonts/cat-playwrite_in_guides-400-0.woff2 +0 -0
  381. package/app/public/vendor/fonts/cat-playwrite_is-400-0.woff2 +0 -0
  382. package/app/public/vendor/fonts/cat-playwrite_is_guides-400-0.woff2 +0 -0
  383. package/app/public/vendor/fonts/cat-playwrite_it_moderna-400-0.woff2 +0 -0
  384. package/app/public/vendor/fonts/cat-playwrite_it_moderna_guides-400-0.woff2 +0 -0
  385. package/app/public/vendor/fonts/cat-playwrite_it_trad-400-0.woff2 +0 -0
  386. package/app/public/vendor/fonts/cat-playwrite_it_trad_guides-400-0.woff2 +0 -0
  387. package/app/public/vendor/fonts/cat-playwrite_mx_guides-400-0.woff2 +0 -0
  388. package/app/public/vendor/fonts/cat-playwrite_ng_modern-400-0.woff2 +0 -0
  389. package/app/public/vendor/fonts/cat-playwrite_ng_modern_guides-400-0.woff2 +0 -0
  390. package/app/public/vendor/fonts/cat-playwrite_nl-400-0.woff2 +0 -0
  391. package/app/public/vendor/fonts/cat-playwrite_nl_guides-400-0.woff2 +0 -0
  392. package/app/public/vendor/fonts/cat-playwrite_no-400-0.woff2 +0 -0
  393. package/app/public/vendor/fonts/cat-playwrite_no_guides-400-0.woff2 +0 -0
  394. package/app/public/vendor/fonts/cat-playwrite_nz-400-0.woff2 +0 -0
  395. package/app/public/vendor/fonts/cat-playwrite_nz_basic-400-0.woff2 +0 -0
  396. package/app/public/vendor/fonts/cat-playwrite_nz_basic_guides-400-0.woff2 +0 -0
  397. package/app/public/vendor/fonts/cat-playwrite_nz_guides-400-0.woff2 +0 -0
  398. package/app/public/vendor/fonts/cat-playwrite_pe_guides-400-0.woff2 +0 -0
  399. package/app/public/vendor/fonts/cat-playwrite_pl-400-0.woff2 +0 -0
  400. package/app/public/vendor/fonts/cat-playwrite_pl_guides-400-0.woff2 +0 -0
  401. package/app/public/vendor/fonts/cat-playwrite_pt_guides-400-0.woff2 +0 -0
  402. package/app/public/vendor/fonts/cat-playwrite_ro-400-0.woff2 +0 -0
  403. package/app/public/vendor/fonts/cat-playwrite_ro_guides-400-0.woff2 +0 -0
  404. package/app/public/vendor/fonts/cat-playwrite_sk-400-0.woff2 +0 -0
  405. package/app/public/vendor/fonts/cat-playwrite_sk_guides-400-0.woff2 +0 -0
  406. package/app/public/vendor/fonts/cat-playwrite_tz-400-0.woff2 +0 -0
  407. package/app/public/vendor/fonts/cat-playwrite_tz_guides-400-0.woff2 +0 -0
  408. package/app/public/vendor/fonts/cat-playwrite_us_modern_guides-400-0.woff2 +0 -0
  409. package/app/public/vendor/fonts/cat-playwrite_us_trad_guides-400-0.woff2 +0 -0
  410. package/app/public/vendor/fonts/cat-playwrite_vn-400-0.woff2 +0 -0
  411. package/app/public/vendor/fonts/cat-playwrite_vn_guides-400-0.woff2 +0 -0
  412. package/app/public/vendor/fonts/cat-playwrite_za-400-0.woff2 +0 -0
  413. package/app/public/vendor/fonts/cat-playwrite_za_guides-400-0.woff2 +0 -0
  414. package/app/public/vendor/fonts/cat-potta_one-400-ext.woff2 +0 -0
  415. package/app/public/vendor/fonts/cat-potta_one-400.woff2 +0 -0
  416. package/app/public/vendor/fonts/cat-ranchers-400-ext.woff2 +0 -0
  417. package/app/public/vendor/fonts/cat-ranchers-400.woff2 +0 -0
  418. package/app/public/vendor/fonts/cat-ranga-400-ext.woff2 +0 -0
  419. package/app/public/vendor/fonts/cat-ranga-400.woff2 +0 -0
  420. package/app/public/vendor/fonts/cat-ranga-700-ext.woff2 +0 -0
  421. package/app/public/vendor/fonts/cat-ranga-700.woff2 +0 -0
  422. package/app/public/vendor/fonts/cat-risque-400-ext.woff2 +0 -0
  423. package/app/public/vendor/fonts/cat-risque-400.woff2 +0 -0
  424. package/app/public/vendor/fonts/cat-rowdies-400-ext.woff2 +0 -0
  425. package/app/public/vendor/fonts/cat-rowdies-400.woff2 +0 -0
  426. package/app/public/vendor/fonts/cat-rowdies-700-ext.woff2 +0 -0
  427. package/app/public/vendor/fonts/cat-rowdies-700.woff2 +0 -0
  428. package/app/public/vendor/fonts/cat-ruge_boogie-400-ext.woff2 +0 -0
  429. package/app/public/vendor/fonts/cat-ruge_boogie-400.woff2 +0 -0
  430. package/app/public/vendor/fonts/cat-ruslan_display-400-ext.woff2 +0 -0
  431. package/app/public/vendor/fonts/cat-ruslan_display-400.woff2 +0 -0
  432. package/app/public/vendor/fonts/cat-sacramento-400-ext.woff2 +0 -0
  433. package/app/public/vendor/fonts/cat-sacramento-400.woff2 +0 -0
  434. package/app/public/vendor/fonts/cat-sancreek-400-ext.woff2 +0 -0
  435. package/app/public/vendor/fonts/cat-sancreek-400.woff2 +0 -0
  436. package/app/public/vendor/fonts/cat-sankofa_display-400-ext.woff2 +0 -0
  437. package/app/public/vendor/fonts/cat-sankofa_display-400.woff2 +0 -0
  438. package/app/public/vendor/fonts/cat-sedgwick_ave_display-400-ext.woff2 +0 -0
  439. package/app/public/vendor/fonts/cat-sedgwick_ave_display-400.woff2 +0 -0
  440. package/app/public/vendor/fonts/cat-shantell_sans-400-ext.woff2 +0 -0
  441. package/app/public/vendor/fonts/cat-shantell_sans-400.woff2 +0 -0
  442. package/app/public/vendor/fonts/cat-smokum-400-ext.woff2 +0 -0
  443. package/app/public/vendor/fonts/cat-smokum-400.woff2 +0 -0
  444. package/app/public/vendor/fonts/cat-solitreo-400-ext.woff2 +0 -0
  445. package/app/public/vendor/fonts/cat-solitreo-400.woff2 +0 -0
  446. package/app/public/vendor/fonts/cat-spicy_rice-400-ext.woff2 +0 -0
  447. package/app/public/vendor/fonts/cat-spicy_rice-400.woff2 +0 -0
  448. package/app/public/vendor/fonts/cat-sriracha-400-ext.woff2 +0 -0
  449. package/app/public/vendor/fonts/cat-sriracha-400.woff2 +0 -0
  450. package/app/public/vendor/fonts/cat-srisakdi-400-ext.woff2 +0 -0
  451. package/app/public/vendor/fonts/cat-srisakdi-400.woff2 +0 -0
  452. package/app/public/vendor/fonts/cat-srisakdi-700-ext.woff2 +0 -0
  453. package/app/public/vendor/fonts/cat-srisakdi-700.woff2 +0 -0
  454. package/app/public/vendor/fonts/cat-stix_two_math-400-0.woff2 +0 -0
  455. package/app/public/vendor/fonts/cat-tillana-400-ext.woff2 +0 -0
  456. package/app/public/vendor/fonts/cat-tillana-400.woff2 +0 -0
  457. package/app/public/vendor/fonts/cat-tillana-700-ext.woff2 +0 -0
  458. package/app/public/vendor/fonts/cat-tillana-700.woff2 +0 -0
  459. package/app/public/vendor/fonts/cat-titan_one-400-ext.woff2 +0 -0
  460. package/app/public/vendor/fonts/cat-titan_one-400.woff2 +0 -0
  461. package/app/public/vendor/fonts/cat-yatra_one-400-ext.woff2 +0 -0
  462. package/app/public/vendor/fonts/cat-yatra_one-400.woff2 +0 -0
  463. package/app/public/vendor/fonts/cat-yomogi-400-ext.woff2 +0 -0
  464. package/app/public/vendor/fonts/cat-yomogi-400.woff2 +0 -0
  465. package/app/public/vendor/fonts/dmserifdisplay-400-ext.woff2 +0 -0
  466. package/app/public/vendor/fonts/dmserifdisplay-400.woff2 +0 -0
  467. package/app/public/vendor/fonts/domine-var-ext.woff2 +0 -0
  468. package/app/public/vendor/fonts/domine-var.woff2 +0 -0
  469. package/app/public/vendor/fonts/fondamento-400-ext.woff2 +0 -0
  470. package/app/public/vendor/fonts/fondamento-400.woff2 +0 -0
  471. package/app/public/vendor/fonts/fraunces-var-ext.woff2 +0 -0
  472. package/app/public/vendor/fonts/fraunces-var.woff2 +0 -0
  473. package/app/public/vendor/fonts/fredoka-var-ext.woff2 +0 -0
  474. package/app/public/vendor/fonts/fredoka-var.woff2 +0 -0
  475. package/app/public/vendor/fonts/inter-400.woff2 +0 -0
  476. package/app/public/vendor/fonts/inter-700.woff2 +0 -0
  477. package/app/public/vendor/fonts/jakarta-var-ext.woff2 +0 -0
  478. package/app/public/vendor/fonts/jakarta-var.woff2 +0 -0
  479. package/app/public/vendor/fonts/lato-400.woff2 +0 -0
  480. package/app/public/vendor/fonts/lato-700.woff2 +0 -0
  481. package/app/public/vendor/fonts/literata-400.woff2 +0 -0
  482. package/app/public/vendor/fonts/literata-700.woff2 +0 -0
  483. package/app/public/vendor/fonts/lora-var-ext.woff2 +0 -0
  484. package/app/public/vendor/fonts/lora-var.woff2 +0 -0
  485. package/app/public/vendor/fonts/merriweather-var-ext.woff2 +0 -0
  486. package/app/public/vendor/fonts/merriweather-var.woff2 +0 -0
  487. package/app/public/vendor/fonts/newsreader-400.woff2 +0 -0
  488. package/app/public/vendor/fonts/newsreader-700.woff2 +0 -0
  489. package/app/public/vendor/fonts/notosans-var-ext.woff2 +0 -0
  490. package/app/public/vendor/fonts/notosans-var.woff2 +0 -0
  491. package/app/public/vendor/fonts/nunito-var-ext.woff2 +0 -0
  492. package/app/public/vendor/fonts/nunito-var.woff2 +0 -0
  493. package/app/public/vendor/fonts/opendyslexic-400.woff2 +0 -0
  494. package/app/public/vendor/fonts/opensans-400.woff2 +0 -0
  495. package/app/public/vendor/fonts/opensans-700.woff2 +0 -0
  496. package/app/public/vendor/fonts/pinyon-400.woff2 +0 -0
  497. package/app/public/vendor/fonts/playfair-var-ext.woff2 +0 -0
  498. package/app/public/vendor/fonts/playfair-var.woff2 +0 -0
  499. package/app/public/vendor/fonts/pressstart-400.woff2 +0 -0
  500. package/app/public/vendor/fonts/quattro-400.woff2 +0 -0
  501. package/app/public/vendor/fonts/quattro-700.woff2 +0 -0
  502. package/app/public/vendor/fonts/quicksand-var-ext.woff2 +0 -0
  503. package/app/public/vendor/fonts/quicksand-var.woff2 +0 -0
  504. package/app/public/vendor/fonts/robotoflex-var-ext.woff2 +0 -0
  505. package/app/public/vendor/fonts/robotoflex-var.woff2 +0 -0
  506. package/app/public/vendor/fonts/sora-var-ext.woff2 +0 -0
  507. package/app/public/vendor/fonts/sora-var.woff2 +0 -0
  508. package/app/public/vendor/fonts/sourcesans-400.woff2 +0 -0
  509. package/app/public/vendor/fonts/sourcesans-700.woff2 +0 -0
  510. package/app/public/vendor/fonts/sourceserif-400.woff2 +0 -0
  511. package/app/public/vendor/fonts/sourceserif-700.woff2 +0 -0
  512. package/app/public/vendor/fonts/spacegrotesk-var-ext.woff2 +0 -0
  513. package/app/public/vendor/fonts/spacegrotesk-var.woff2 +0 -0
  514. package/app/public/vendor/fonts/spectral-400-ext.woff2 +0 -0
  515. package/app/public/vendor/fonts/spectral-400.woff2 +0 -0
  516. package/app/public/vendor/fonts/spectral-700-ext.woff2 +0 -0
  517. package/app/public/vendor/fonts/spectral-700.woff2 +0 -0
  518. package/app/public/vendor/fonts/teachers-var-ext.woff2 +0 -0
  519. package/app/public/vendor/fonts/teachers-var.woff2 +0 -0
  520. package/app/public/vendor/fonts/ubuntu-400-ext.woff2 +0 -0
  521. package/app/public/vendor/fonts/ubuntu-400.woff2 +0 -0
  522. package/app/public/vendor/fonts/ubuntu-700-ext.woff2 +0 -0
  523. package/app/public/vendor/fonts/ubuntu-700.woff2 +0 -0
  524. package/app/public/vendor/fonts/ufmag-400.woff2 +0 -0
  525. package/dist-pkg/platform/guide-intensity.d.ts +0 -39
  526. package/dist-pkg/platform/guide-intensity.js +0 -71
  527. package/dist-pkg/ui/latch-refusal.d.ts +0 -33
  528. package/dist-pkg/ui/latch-refusal.js +0 -60
@@ -22,11 +22,10 @@
22
22
  //
23
23
  // And one finding that only appeared when this function tried to boot against an injected document:
24
24
  //
25
- // · FINDING 15 — `initI18n()` called `applyDom(document)`, the GLOBAL, underneath whoever called it. In a browser
26
- // it makes no difference, which is how it survived; in a pure-logic test it is the difference between booting and
27
- // not, and with two documents (an engine in an iframe, an editor beside the game) it would be the difference
28
- // between translating the right document and the other one. Hence `initI18n(root)`, with the global as default,
29
- // exactly as `applyDom` already did.
25
+ // · FINDING 15 — the language's boot called `applyDom(document)`, the GLOBAL, underneath whoever called it. In a
26
+ // browser it makes no difference, which is how it survived; in a pure-logic test it is the difference between booting
27
+ // and not, and with two documents (an engine in an iframe, an editor beside the game) it would be the difference
28
+ // between translating the right document and the other one. Hence `translator.init(root)`: the document comes in.
30
29
  //
31
30
  // ========================= DECLINING IS NOT LYING =========================
32
31
  // The quiz declined the sonar and the pad instead of inventing tiles and a fake collision box, and that distinction
@@ -44,7 +43,7 @@
44
43
  // game with a title screen. What it covers is what is the SAME in every game: language, screen reader, mixer, voice,
45
44
  // the dialog stack, the colour filters, the remappable keyboard, menu navigation, the pause card and the accessibility
46
45
  // bar, the settings panels, the navigation sonar, and every input transport.
47
- import i18nObject, { initI18n, loadLocale, createTranslator } from '../core/i18n.js';
46
+ import { createTranslator, availableLocales } from '../core/i18n.js';
48
47
  import { localeHostHooks, exposeI18n } from '../platform/locale-host.js';
49
48
  import { createInputState } from '../input/state.js';
50
49
  import { initTouch, mountTouchControls, touchGaps } from '../input/touch.js';
@@ -58,20 +57,22 @@ import { accommodationAnswersProblems, subjectWord } from '../core/accommodation
58
57
  import { genreProblems, genreWarning } from '../core/genres.js';
59
58
  import { cartridgeProblems } from '../core/cartridge-problems.js';
60
59
  import { contractSubjects } from '../core/accommodation-subjects.js';
61
- import { presetActions, startClaimProblem, selectClaimProblem, labellerFrom, shortLabellerFrom, ACTIONS } from '../core/actions.js';
60
+ import { presetActions, startClaimProblem, selectClaimProblem, labellerFrom, shortLabellerFrom, wordsOf, ACTIONS } from '../core/actions.js';
62
61
  import { createAnnouncer } from '../core/a11y-sr.js';
63
62
  import { createEyeControl, videoFeed } from '../ui/eye-control.js';
64
63
  import { createFaceControl } from '../ui/face-control.js';
65
64
  import { createHandControl } from '../ui/hand-control.js';
66
65
  import { checkedCacheHas, sha256With } from '../platform/heavy.js';
67
- import { createBundleLoader } from '../platform/vosk-runtime.js';
66
+ import { createBundleLoader, keepVoskModels, loadVoskRuntime } from '../platform/vosk-runtime.js';
67
+ import { createVoiceChooser, openWithCommandRecogniser } from '../platform/choose-by-voice.js';
68
68
  import { followCameraMode } from '../ui/camera-control.js';
69
69
  import { initPauseIcons, wireBarCaption, showPauseOptions } from '../ui/pause-icons.js';
70
70
  // 📌 The bar's markup is a pure string builder and lives with the rest of the pause markup (ADR-0221, issue #203); what this
71
71
  // root asks `ui/pause-icons` for is the WIRING — the icons this game can actually act on, and the reflection of their state.
72
72
  import { iconsMarkup } from '../ui/pause-markup.js';
73
73
  import { accessibleLabel } from '../core/accessible-label.js';
74
- import { helpRows, mountSlides, showSlide, animateFigure, howToPlayProblems } from '../ui/help-panel.js';
74
+ import { helpRows, mountSlides, showSlide, animateFigure, howToPlayProblems, playSlidesOf } from '../ui/help-panel.js';
75
+ import { missingDeclaredKeys } from '../ui/declared-words.js';
75
76
  import { initSettingsControls } from '../ui/settings-controls.js';
76
77
  import { keyName } from '../ui/control-choices.js';
77
78
  import { reserveTopBand } from '../ui/top-band.js';
@@ -79,7 +80,9 @@ import { reserveTopBand } from '../ui/top-band.js';
79
80
  import { createSettingsStore } from '../core/state.js';
80
81
  import { DEFAULTS, defaultReducedMotion } from '../core/setting-defaults.js';
81
82
  import { CAMERA_CONTROLS } from '../core/camera-cycle.js';
82
- import { createLibras } from '../ui/vlibras.js';
83
+ import { createDeafMode } from '../ui/vlibras.js';
84
+ import { createLibrasAvatarInterpreter } from '../ui/libras-avatar-player.js';
85
+ import { screenText, unreadableWorldProblems, menuSonarPress } from '../ui/screen-text.js';
83
86
  import { conformanceProblems } from '../core/contract.js';
84
87
  import { createSceneStack } from '../core/scenes.js';
85
88
  import { createTts } from '../platform/tts.js';
@@ -88,11 +91,12 @@ import { createAudio } from '../platform/audio.js';
88
91
  import { createAudioSonar } from '../platform/audio-sonar.js';
89
92
  // The root is the layer that MAY know both axes: `render/` is below it, and it is the root's job to answer
90
93
  // `platform/audio-sonar`, which cannot import from here without inverting an edge (#104).
91
- import { isBlind, isLowVision, DEFAULT_VISUAL, filterKey, simulationUnavailable } from '../render/viz-axes.js';
94
+ import { isBlind, isLowVision, hasHighContrast, onlyColourVision, DEFAULT_VISUAL, filterKey, simulationUnavailable } from '../render/viz-axes.js';
92
95
  // 📌 The mode → `url(#...)` table, which `render/cvd-matrices` installs and the `consumer-quiz` consumes.
93
96
  import { VIZ_FILTER } from '../render/viz-modes.js';
94
97
  import { createPadWizard, createPadMaps } from '../input/pad-wizard.js';
95
- import { typographyCycle, CYCLE_START, FONT_BY_KEY } from '../ui/fonts.js';
98
+ import { typographyCycle, CYCLE_START, FONT_BY_KEY, ENGINE_FAMILIES } from '../ui/fonts.js';
99
+ import { declaredFamilies, startLibraryFonts } from '../platform/font-library.js';
96
100
  // 📏 The drawing reporters (`barIntruders`, `belowFloor`, `minimumTarget` and their types) live in `ui/drawing-problems`
97
101
  // (ADR-0221, issue #203), and the root does not know them. An import that can be deleted is coupling that no longer
98
102
  // exists, and that is how this debt is paid: by subject.
@@ -105,18 +109,22 @@ import { LOGICAL_W } from '../core/constants.js';
105
109
  import { captionDuration, CAPTION_RATES } from '../core/caption-duration.js';
106
110
  import { initSettingsPanel } from '../ui/settings-panel.js';
107
111
  import { mountPanel } from '../ui/mount-panel.js';
108
- // 📌 No `latchRefusal` or `setMoveLatch` here: the sticky keys are written by the bar's ☝️, which already resolves both
109
- // the device's refusal and the two stored keys.
112
+ // 📌 No `setMoveLatch` here: the sticky keys are written by the bar's ☝️, which already writes both stored keys. What the root
113
+ // does is RESOLVE the latch when any transport presses (ADR-0109 rule 3, ADR-0249) — `createLatchedEdge`, below.
114
+ import { createLatchedEdge } from '../input/latch-edge.js';
110
115
  import { stampSource, sourceOfEvent } from '../input/synthetic-source.js';
111
116
  import { createVirtualController } from '../input/virtual-controller.js';
112
- import { createSwitchScan, SWITCH_SCAN_DEFAULTS } from '../input/switch-scan.js';
117
+ import { keyGoesToGame, keyPressesOwnControl, keyTypedIntoField } from '../input/key-default.js';
118
+ import { createSwitchScan, playScanList, SWITCH_SCAN_DEFAULTS } from '../input/switch-scan.js';
119
+ import { menuScanFor, menuStepKeys } from '../ui/menu-intent.js';
113
120
  import { mountScanOverlay, scanItemText } from '../ui/scan-overlay.js';
114
121
  import { createVoiceControl } from '../ui/voice-control.js';
115
- import { mountSteps, updateSteps, nextStep, controlRow, labelRow } from '../ui/panel-widgets.js';
122
+ import { mountSteps, updateSteps, nextStep, controlRow, labelRow, mountChoice } from '../ui/panel-widgets.js';
116
123
  import { PERSONAS_DO_PAD, closestPersona } from '../input/touch.js';
117
124
  import { initSettingsTypo } from '../ui/settings-typo.js';
118
125
  import { initSettingsMotion } from '../ui/settings-motion.js';
119
- import { readStoredScene, storeScene } from '../ui/motion-scene.js';
126
+ import { readStoredScene, storeScene, SCENE_KEYS } from '../ui/motion-scene.js';
127
+ import { watchFooterScroll } from '../ui/footer-scroll-driver.js';
120
128
  import { initSettingsVisual } from '../ui/settings-visual.js';
121
129
  import { initSettingsEmpathy } from '../ui/settings-empathy.js';
122
130
  import { HC_ROLE_DEF } from '../render/hc-role-data.js';
@@ -129,6 +137,8 @@ import { createEmpathyFilter } from '../input/empathy-filter.js';
129
137
  import { createInputCooldown, COOLDOWN_MS } from '../input/input-cooldown.js';
130
138
  import { markChanged } from '../ui/changed-mark.js';
131
139
  import { mountHudBands, hudNumbersProblems } from '../ui/hud-bands.js';
140
+ import { mountSessionClock } from '../ui/session-clock.js';
141
+ import { mountHudRow, reserveBottomBand, followThePad } from '../ui/hud-row.js';
132
142
  import { gameOptionsProblems, drawGameOptions } from '../ui/game-options.js';
133
143
  import { createStorage, keysOutsideScopes } from '../platform/storage.js';
134
144
  import { KEYS } from '../platform/storage-keys.js';
@@ -142,7 +152,7 @@ import { showOnlyRowsThatApply } from '../ui/audio-rows-that-apply.js';
142
152
  import { initKeyboardRuntime } from '../input/keyboard-runtime.js';
143
153
  import { createKeyboardConfig } from '../input/keyboard.js';
144
154
  import { createPadTable } from '../input/pad-defaults.js';
145
- import { downloadHeavy, heavyAtBoot } from '../platform/heavy.js';
155
+ import { downloadHeavy, heavyAtBoot, deliveryPath } from '../platform/heavy.js';
146
156
  import { installCvdFilters } from '../render/cvd-matrices.js';
147
157
  /**
148
158
  * The backend the host lent, else its window's `localStorage` — or `null` where the window has none or reading it throws
@@ -158,6 +168,13 @@ function hostStorage(host) {
158
168
  return null;
159
169
  }
160
170
  }
171
+ /**
172
+ * The interpreter the host lent — it always wins — else the one the DELIVERY carries (`delivered`, built only when needed): the
173
+ * free Libras player where the delivery was built with `--libras`, which answers as `NO_INTERPRETER` where it was not.
174
+ */
175
+ function hostInterpreter(host, delivered) {
176
+ return host.interpreter ?? delivered();
177
+ }
161
178
  /*
162
179
  * ONE SENTENCE FOR BOTH REFUSALS.
163
180
  *
@@ -168,56 +185,39 @@ function hostStorage(host) {
168
185
  function refuseDeclaration(who, problemas) {
169
186
  throw new Error(`${who}: declaração malformada — ${problemas.join('; ')}`);
170
187
  }
171
- /*
172
- * «start» BELONGS TO THE PAUSE, AND A CARTRIDGE DOES NOT TAKE IT (ADR-0144 §4).
188
+ /**
189
+ * EVERY LINE `createGame` AND `mount()` REFUSE A CARTRIDGE WITH — one list, read by the boot, by `mount()` and by the
190
+ * cartridge checker the engine ships (`inclusionist-check-cartridge`, ADR-0253). EMPTY means the cartridge holds.
173
191
  *
174
- * ⚠️ THE SENTENCE LIVES IN `core/actions`, not here, for two reasons that point the same way: that is where the
175
- * validity of an `ActionPreset` lives, and that is where the raw-prose ledger already answers for messages read by
176
- * whoever WRITES a preset. A new sentence in this module would raise its cap to pay for text that belongs to the input
177
- * vocabulary, not to the boot.
192
+ * 🎯 ONE LIST AND NOT A CHECKER'S COPY OF IT: a checker that re-listed these rules would drift from the boot the first time
193
+ * a rule was added here, and it would then accept a cartridge the platform refuses — the drift ADR-0253 exists to end.
178
194
  *
179
- * ⚠️ IT THROWS, by the rubric of the two refusals above: this is a PROGRAM defect — the game declared a word for a
180
- * position that is not its own — and not a gap of the host. `problems` is for what still lets the child play.
195
+ * ⚠️ EACH ROW THROWS AT BOOT, by the rubric of ADR-0169: every one is a PROGRAM defect, never a gap of the host, and
196
+ * `problems` is for what still lets the child play.
197
+ * · the declaration's shape (`core/contract`, ADR-0030);
198
+ * · «start» and «select» belong to the pause, and a cartridge does not take them (ADR-0144 §4, ADR-0155) — the sentences
199
+ * live in `core/actions`, where the validity of a preset lives;
200
+ * · the accommodations are ANSWERED, all of them (ADR-0153): the engine would not know which rows to mount;
201
+ * · a genre outside the engine's list, or Casino game (ADR-0156 §2, §4); an absent genre is conformant;
202
+ * · a malformed `hud`, game options or `howToPlay` (ADR-0169): the engine would not know what to place, draw or show.
181
203
  */
182
- function refuseIfItClaimsStart(who, preset) {
183
- // 📌 AND SELECT TOO, since ADR-0155: the two system positions are the two doors of the pause.
184
- const problemas = [startClaimProblem(preset), selectClaimProblem(preset)].filter((x) => x !== null);
185
- if (problemas.length)
186
- refuseDeclaration(who, problemas);
187
- }
188
- /**
189
- * Did the cartridge ANSWER its accommodations? (ADR-0153.) Same rubric as the contract: a missing or incomplete answer is
190
- * a precondition, not a gap — `problems` is for what still lets the child play, and here the engine would not know which
191
- * rows to mount.
192
- */
193
- function refuseIfNoAnswer(who, answers) {
194
- const problemas = accommodationAnswersProblems(answers);
195
- if (problemas.length)
196
- refuseDeclaration(who, problemas);
197
- }
198
- /** A genre outside the engine's list, or Casino game, refuses the boot (ADR-0156 §2, §4); an absent genre is conformant. */
199
- function refuseIfGenreRefused(who, genre) {
200
- const problemas = genreProblems(genre);
201
- if (problemas.length)
202
- refuseDeclaration(who, problemas);
203
- }
204
- /** A malformed `hud` is a program defect, refused like the declaration (ADR-0169): the engine would not know what to place. */
205
- function refuseIfHudMalformed(who, hud) {
206
- const problemas = hudNumbersProblems(hud);
207
- if (problemas.length)
208
- refuseDeclaration(who, problemas);
204
+ export function cartridgeRefusals(declaration, hooks) {
205
+ const h = hooks ?? {};
206
+ return [
207
+ ...conformanceProblems(declaration),
208
+ ...[startClaimProblem(h.preset), selectClaimProblem(h.preset)].filter((x) => x !== null),
209
+ ...accommodationAnswersProblems(h.accommodations),
210
+ ...genreProblems(h.genre),
211
+ ...hudNumbersProblems(h.hud),
212
+ ...gameOptionsProblems(h.gameOptions),
213
+ ...howToPlayProblems(h.howToPlay),
214
+ ];
209
215
  }
210
- /** Malformed game options are a program defect, refused like the declaration (ADR-0169): the engine would not know what to draw. */
211
- function refuseIfOptionsMalformed(who, options) {
212
- const problemas = gameOptionsProblems(options);
213
- if (problemas.length)
214
- refuseDeclaration(who, problemas);
215
- }
216
- /** A malformed `howToPlay` is a program defect, refused like the declaration (ADR-0169): the help would not know what to show. */
217
- function refuseIfHowToPlayMalformed(who, slides) {
218
- const problemas = howToPlayProblems(slides);
219
- if (problemas.length)
220
- refuseDeclaration(who, problemas);
216
+ /** Throws with every refusal at once: an author fixes the whole list in one pass instead of meeting it line by line. */
217
+ function refuseCartridge(who, declaration, hooks) {
218
+ const problems = cartridgeRefusals(declaration, hooks);
219
+ if (problems.length)
220
+ refuseDeclaration(who, problems);
221
221
  }
222
222
  /** The ids the engine announces and draws into. Without them a child who listens hears nothing (finding 6). */
223
223
  const REQUIRED_MARKUP = ['#game-region', '#sr-status', '#sr-alert'];
@@ -229,6 +229,12 @@ const REQUIRED_MARKUP = ['#game-region', '#sr-status', '#sr-alert'];
229
229
  * bar, and its own sentence can say WHAT is lost, which is what makes it useful to whoever reads it first.
230
230
  */
231
231
  const A11Y_BAR_SELECTOR = '#title-icons';
232
+ /**
233
+ * The engine's own controls that stay pressable by key IN PLAY: the accessibility bar's icons, in `#title-icons` and in the
234
+ * HUD. A key that activates one of them is that control's, and play does not hear it (`input/key-default`, ADR-0111 errata of
235
+ * 2026-09-26).
236
+ */
237
+ const ENGINE_CONTROLS_IN_PLAY = '.pi-btn';
232
238
  /**
233
239
  * Switches the engine on for a declared game.
234
240
  *
@@ -245,16 +251,7 @@ export function createGame(o) {
245
251
  * tied to the argument would leave a root serving several cartridges with the first of them forever.
246
252
  */
247
253
  let cartridge = o;
248
- const contractProblems = conformanceProblems(cartridge.declaration);
249
- if (contractProblems.length) {
250
- refuseDeclaration('createGame', contractProblems);
251
- }
252
- refuseIfItClaimsStart('createGame', cartridge.preset);
253
- refuseIfNoAnswer('createGame', cartridge.accommodations);
254
- refuseIfGenreRefused('createGame', cartridge.genre);
255
- refuseIfHudMalformed('createGame', cartridge.hud);
256
- refuseIfOptionsMalformed('createGame', cartridge.gameOptions);
257
- refuseIfHowToPlayMalformed('createGame', cartridge.howToPlay);
254
+ refuseCartridge('createGame', cartridge.declaration, cartridge);
258
255
  const { doc } = o.host;
259
256
  /*
260
257
  * 🔴 THE WINDOW THIS ROOT LISTENS ON IS A SCOPED ONE, and it is not plumbing: a root installs about thirty listeners on the
@@ -275,21 +272,32 @@ export function createGame(o) {
275
272
  const whenDisposed = (release) => { endOfLife.push(release); };
276
273
  const stateOn = (evt, fn) => { const off = state.on(evt, fn); whenDisposed(off); return off; };
277
274
  /*
278
- * THE ROOT'S TRANSLATOR (ADR-0232 D3): the page's language, and this root's `t`, markup pass and door to a language change.
279
- * `localeOn` is that door — like `stateOn`, whatever subscribes through it is released by `dispose()` (ADR-0220).
275
+ * 🔴 THE PAGE'S ONE STORE IS BUILT HERE, from what the HOST lends (ADR-0232 point 2, issue #207): the backend the host
276
+ * passed, or its window's `localStorage`. Every module below that persists receives THIS store; none reaches the global.
277
+ * Reading `win.localStorage` can itself THROW (file://, some private modes), which is a host with no storage: `null`.
278
+ */
279
+ const store = createStorage(hostStorage(o.host));
280
+ /*
281
+ * THE ROOT'S TRANSLATOR (ADR-0232 D3): the page's language as this root speaks it, the game's dictionary, and this root's
282
+ * `t`, markup pass and door to a language change. `core/i18n` holds none of it (erratum of 2026-09-25).
283
+ * 🔴 ITS PORT IS THE STORED LANGUAGE **AND** THE BROWSER'S (ADR-0221 step 7g): the page effects — writing `<html lang>`,
284
+ * dispatching on the window, reading `navigator.language`, hearing another root switch — come in through `localeHostHooks`,
285
+ * which is THIS root's host speaking: the document and the scoped window it received, never the globals.
286
+ * `localeOn` is its door — like `stateOn`, whatever subscribes through it is released by `dispose()` (ADR-0220).
280
287
  */
281
- const translator = createTranslator();
288
+ const translator = createTranslator({
289
+ ...store, KEYS, ...localeHostHooks(doc, win, (root) => { translator.applyDom(root); }),
290
+ });
282
291
  for (const [code, entries] of Object.entries(o.dictionaries ?? {}))
283
292
  translator.registerDict(code, entries);
284
293
  // 📌 The root's own words go through ITS translator, which reads this game's dictionary (ADR-0232 D3).
285
- const { t, bcp47 } = translator;
294
+ const { t, bcp47, word } = translator;
286
295
  const localeOn = (react) => { const off = translator.onChange(react); whenDisposed(off); return off; };
287
- /*
288
- * 🔴 THE PAGE'S ONE STORE IS BUILT HERE, from what the HOST lends (ADR-0232 point 2, issue #207): the backend the host
289
- * passed, or its window's `localStorage`. Every module below that persists receives THIS store; none reaches the global.
290
- * Reading `win.localStorage` can itself THROW (file://, some private modes), which is a host with no storage: `null`.
296
+ /**
297
+ * THE GAME'S POSITIONS IN WORDS, resolved NOW from its keys (ADR-0232 D3, erratum of 2026-09-25). A function and never a
298
+ * value: every surface that shows a position asks at its drawing, so a language change reaches all of them at once.
291
299
  */
292
- const store = createStorage(hostStorage(o.host));
300
+ const actionWords = () => (cartridge.preset ? wordsOf(cartridge.preset, word) : {});
293
301
  // THE CHILD'S STORED SETTINGS, FIRST (ADR-0178): nothing below reads or writes one before this.
294
302
  // ⚠️ The port of ADR-0178 carries the key names beside the store, so `core` names no storage place itself.
295
303
  const state = createSettingsStore({ ...store, KEYS });
@@ -297,15 +305,8 @@ export function createGame(o) {
297
305
  // Destructured so the call sites below read as they did; a game reads the same object as `Engine.input`.
298
306
  const input = createInputState();
299
307
  const { inputOf, keys, markKeyFrom, releaseKey, playerEdge, letGoOfTheKeyboard } = input;
300
- /*
301
- * 🔴 THE STORED LANGUAGE **AND** THE BROWSER'S (ADR-0221 step 7g). `core/i18n` keeps the decisions; the page effects —
302
- * writing `<html lang>`, dispatching on the window, reading `navigator.language` — come in through these two functions,
303
- * which are THIS root's host speaking: the document and window it received, never the globals. `core` is what the
304
- * engine IS without a browser.
305
- */
306
- loadLocale({ ...store, KEYS, ...localeHostHooks(doc, win, translator.applyDom) });
307
- // 📌 And the debugging exposure: whoever HAS a window is this root.
308
- exposeI18n(win, i18nObject);
308
+ // 📌 And the debugging exposure: whoever HAS a window is this root, and what it exposes is its translator.
309
+ exposeI18n(win, translator);
309
310
  /*
310
311
  * ⚠️ A READER AND NOT A SNAPSHOT. `declines` belongs to the GAME's half (ADR-0139: the cartridge declares what it does
311
312
  * NOT have), so a `const` taken at boot would return, after a `mount()`, the previous cartridge's declines — and a
@@ -319,17 +320,34 @@ export function createGame(o) {
319
320
  const $ = (sel) => doc.querySelector(sel);
320
321
  const $$ = (sel) => [...doc.querySelectorAll(sel)];
321
322
  /*
322
- * THIS ROOT'S ANNOUNCER AND DEAF MODE (ADR-0232 D4): the screen reader's two regions written in the HOST's document on the
323
- * host's frames, and the Libras interpreter over its store. A window with no frames (a test double) is the announcer's to answer.
324
- * 📌 DD1: the root does NOT mirror its announcements into Libras — a game connects that sink (`Engine.mirrorAnnouncements`),
325
- * as before; mirroring everything would be `announcer.mirrorTo(libras.say)` here.
323
+ * THIS ROOT'S ANNOUNCER AND DEAF MODE (ADR-0232 D4, ADR-0234): the screen reader's two regions written in the HOST's
324
+ * document on the host's frames, and deaf mode over its store. A window with no frames (a test double) is the announcer's to answer.
325
+ * 📌 Deaf mode hands the interpreter what the SONAR reads — the text on screen (`ui/screen-text`) — never the announcements,
326
+ * and that text is spoken with the mode off. The interpreter is the host's; else the free Libras player the DELIVERY shipped
327
+ * (`ui/libras-avatar-player`, ADR-0234 route B), opened from the page's own origin at the first request — three.js arrives
328
+ * with it, never before; and where the delivery shipped none, that player answers as `NO_INTERPRETER` does — the sonar's text
329
+ * is captioned, and «signing unavailable» goes to `problems` and to the child. The ports it calls later (`tts`, the caption,
330
+ * `measuredProblems`) are read when it calls.
326
331
  */
327
332
  const announcer = createAnnouncer({
328
333
  doc,
329
334
  raf: win.requestAnimationFrame, // as the host has it: the announcer answers a window with none (bound by the listener scope)
330
335
  });
331
336
  const { say: srSay, alert: srAlert } = announcer;
332
- const libras = createLibras({ doc, win, store, now: () => Date.now() });
337
+ const deafMode = createDeafMode({
338
+ store,
339
+ captionsSetting: () => state.captionsOn,
340
+ t: translator.t,
341
+ interpreter: hostInterpreter(o.host, () => createLibrasAvatarInterpreter({
342
+ doc, win, fetch: win.fetch, base: doc.baseURI, title: () => t('sr.deaf.interpreter'),
343
+ })),
344
+ // a player's sonar waits for another player's reading and cuts its own (ADR-0234, errata 2026-09-26)
345
+ speak: (text, seat) => { tts.narrate(text, { seat }); },
346
+ caption: (text) => { writeSoundCaption(text); },
347
+ tell: srSay,
348
+ report: (line) => { measuredProblems.push(line); },
349
+ });
350
+ whenDisposed(deafMode.dispose);
333
351
  for (const sel of REQUIRED_MARKUP) {
334
352
  if (!$(sel))
335
353
  hostProblems.push(`the page lacks ${sel}: the engine announces and draws into it, and without it a child who listens hears nothing — add it to the page`);
@@ -380,8 +398,7 @@ export function createGame(o) {
380
398
  }
381
399
  // 1. LANGUAGE BEFORE EVERYTHING. The interface cannot be built before the language is known. The document goes in:
382
400
  // see finding 15.
383
- initI18n(doc);
384
- translator.applyDom(doc); // the host's markup, with THIS game's dictionary too — the module's pass reads only the page's
401
+ translator.init(doc); // the host's markup, with THIS game's dictionary too, and the stored or preferred language asked for
385
402
  // 2. MIXER BEFORE VOICE. Finding 3 turned into sequence: `createAudio` loads the mixer, and the voice reads it.
386
403
  // 🔴 THE BROWSER'S SOUND AND SPEECH ARE LENT HERE, from the host's window (ADR-0232 D4): the audio context — made at the
387
404
  // first sound, never at boot, so it is born inside the child's gesture and the browser lets it run — and the speech
@@ -396,6 +413,31 @@ export function createGame(o) {
396
413
  synth: () => speechHost.speechSynthesis ?? null,
397
414
  utterance: (text) => new speechHost.SpeechSynthesisUtterance(text),
398
415
  };
416
+ /*
417
+ * THE DEVICE'S VOICES ARRIVE LATE, AND TWO THINGS FOLLOW THEM (ADR-0185 §4, erratum 2026-09-26): the hearing panel's speech rows
418
+ * and the 🗣 of the bars. The page has ONE `onvoiceschanged` slot, so this root takes it once and hands each change to every
419
+ * listener. The last root takes the slot, and an ended root empties it only if it is still its own (ADR-0220).
420
+ */
421
+ const voicesListeners = [];
422
+ const voicesChanged = () => { for (const again of voicesListeners)
423
+ again(); };
424
+ const whenVoicesChange = (again) => {
425
+ voicesListeners.push(again);
426
+ if (voicesListeners.length > 1)
427
+ return;
428
+ try {
429
+ if (speechHost.speechSynthesis)
430
+ speechHost.speechSynthesis.onvoiceschanged = voicesChanged;
431
+ }
432
+ catch (e) { /* noop */ }
433
+ whenDisposed(() => {
434
+ try {
435
+ if (speechHost.speechSynthesis?.onvoiceschanged === voicesChanged)
436
+ speechHost.speechSynthesis.onvoiceschanged = null;
437
+ }
438
+ catch (e) { /* noop */ }
439
+ });
440
+ };
399
441
  /*
400
442
  * THE ENGINE'S OWN KOKORO LOADER (ADR-0216 §1 and §5) — the Dev, 2026-09-21: «O jogo não deve precisar saber como isso funciona».
401
443
  * ⚠️ IMPORTED AT THE FIRST NEURAL UTTERANCE AND NOT BEFORE: `kokoro-runtime` is what names espeak-ng and the ONNX runtime, so a
@@ -420,6 +462,9 @@ export function createGame(o) {
420
462
  createAudio: () => doc.createElement('audio'),
421
463
  speech,
422
464
  now: () => win.performance.now(),
465
+ // a part in a language the device has no voice for (ADR-0243 §3); `measuredProblems` is declared below and read when it calls
466
+ report: (line) => { if (!measuredProblems.includes(line))
467
+ measuredProblems.push(line); },
423
468
  });
424
469
  // 📌 The neural-voice line lives in `measureCartridgeProblems()`: the decline that silences it is the game's.
425
470
  /**
@@ -488,19 +533,27 @@ export function createGame(o) {
488
533
  const simulation = simulationSuspended() ? null : worldState.simulacao;
489
534
  simulationOverWorld.onlyInPlay(simulation ? (VIZ_FILTER[simulation] ?? '') : '', enhancement);
490
535
  simulationOverWorld.drawLayer(simulation);
491
- applyCrt(); // the decorative CRT yields to every visual mode, and comes back when none is on (ADR-0047)
536
+ applyCrt(); // the decorative CRT yields to the visual modes, and comes back when none is on (ADR-0047, ADR-0241)
492
537
  }
538
+ /*
539
+ * WHICH VISUAL MODES ARE ON — decided here, once: a colour correction or a simulation (the world filter), the game's
540
+ * high-contrast theme, the contrast enhancement. A colour-vision mode ON ITS OWN only changes hue (ADR-0241).
541
+ */
542
+ const visualModeOn = () => filterKey(worldState) !== null || hasHighContrast(worldState) || lq.t() > 0;
543
+ const onlyHueChanges = () => lq.t() === 0 && onlyColourVision(worldState);
493
544
  /*
494
545
  * THE CRT, applied by the engine (study items A5, B1). 📏 Measured: the panel said «Scanlines: on» and the region had no
495
- * CRT class until a toggle was pressed — `render/crt` was never started under `createGame`. It yields to a colour
496
- * correction, a simulation and the contrast enhancement; its scanlines are re-anchored to real pixels at every scale.
546
+ * CRT class until a toggle was pressed — `render/crt` was never started under `createGame`. The vignette yields to every
547
+ * visual mode; the scanline to every one but a colour-vision mode on its own (ADR-0241). Its scanlines are re-anchored
548
+ * to real pixels at every scale.
497
549
  */
498
550
  const crt = createCrt({
499
551
  region: () => $('#game-region'),
500
552
  win,
501
553
  // `cartucho` and not `players()`: this runs at boot, above the `players` declaration (temporal dead zone)
502
554
  numPlayers: () => Math.max(1, (cartridge.players ?? []).length),
503
- a11yVisualOn: () => filterKey(worldState) !== null || lq.t() > 0,
555
+ scanlineYields: () => visualModeOn() && !onlyHueChanges(),
556
+ vignetteYields: visualModeOn,
504
557
  store,
505
558
  });
506
559
  const { apply: applyCrt, scanVars: crtScanVars } = crt;
@@ -585,6 +638,12 @@ export function createGame(o) {
585
638
  // The screen footer (see `screenFooter`): declared HERE, before the first `changePhase`, which already clears it.
586
639
  let footer = null;
587
640
  let barExplanation = null;
641
+ /*
642
+ * WHO HOLDS THE EXPLANATION BAND (ADR-0244): the GAME's text is the band's resting text (`Engine.explain`); an engine item —
643
+ * a quick-bar icon, a pause-card item, a locked item's reason — takes the band while it is pointed and gives it back.
644
+ */
645
+ let gameExplanation = null;
646
+ let itemExplanation = null;
588
647
  // The quick-pause state and the button legend, declared before the first `changePhase` too: its `pauseControls.hide`
589
648
  // refreshes the legend (ADR-0164 rule 3), and reading them earlier would be a temporal-dead-zone error at boot.
590
649
  const inQuickPause = new Set();
@@ -618,6 +677,8 @@ export function createGame(o) {
618
677
  * protect what does not exist.
619
678
  */
620
679
  engineActions.quit = () => changePhase('title');
680
+ /** Print mode is armed: the next key is its way out, and not play's (read by the keyboard conductor). */
681
+ let printArmed = false;
621
682
  /*
622
683
  * PRINT — «ver a tela sem menus», and any button comes back.
623
684
  *
@@ -632,6 +693,11 @@ export function createGame(o) {
632
693
  * neither to the game nor to the pause, it is the way out. 🎯 And it does not collide with the `start` hook (ADR-0144),
633
694
  * which bubbles: `goBack` reveals the card in the capture, and when the bubbling one arrives the card-already-open
634
695
  * guard sends it away.
696
+ *
697
+ * 🔴 AND THE KEY THAT ENDS IT IS NOT PLAYED (ADR-0111 erratum of 2026-09-26, one key one action). `goBack` is added after
698
+ * the keyboard conductor, on the same node and phase, so the conductor runs first and saw no menu: 📏 on the served quiz
699
+ * the arrow that ended print mode also moved the quiz's cursor. The conductor reads `printArmed` instead. Not
700
+ * `stopImmediatePropagation`: the cool-down, the motor filter and the pad's hiding listen after, and must still see the key.
635
701
  */
636
702
  engineActions.print = () => {
637
703
  const findPauseCard = () => $('#vp-pause-0');
@@ -640,6 +706,7 @@ export function createGame(o) {
640
706
  return;
641
707
  eventTarget.hidden = true;
642
708
  const goBack = (e) => {
709
+ printArmed = false;
643
710
  if (e && typeof e.preventDefault === 'function') {
644
711
  try {
645
712
  e.preventDefault();
@@ -653,23 +720,15 @@ export function createGame(o) {
653
720
  c.hidden = false;
654
721
  };
655
722
  win.setTimeout(() => {
723
+ printArmed = true;
656
724
  win.addEventListener('keydown', goBack, true);
657
725
  win.addEventListener('pointerdown', goBack, true);
658
726
  }, 80);
659
727
  srSay(t('sr.print.on'));
660
728
  };
661
729
  /**
662
- * THE HEARING PANEL, resolved late and read early — the same laziness as `engineActions` above, for the same reason:
663
- * `initPauseIcons` runs here and the panels mount further below.
664
- *
665
- * 🔴 IT IS A `let` BECAUSE OF A DEFECT KEPT VERBATIM. `ui/pause-icons` documents it: in the monolith the call that
666
- * refreshed the narration row sat behind `typeof reflectTTS === 'function'`, a symbol that no longer existed, «so it
667
- * never fires». The guard was ported as `reflectTtsPanelEnabled`, defaulting to `false`, so as not to fix it
668
- * silently — and with a field to switch it back on.
669
- *
670
- * 🎯 THE ENGINE CAN: it mounts the panel, so it has the `reflectTts` to hand over. Without it, the child turns
671
- * narration on with the bar's 🗣 icon and the panel goes on saying it is off — the family of defect where a control
672
- * lies about its state.
730
+ * THE HEARING PANEL, resolved late and read early — the same laziness as `engineActions` above, for the same reason: the
731
+ * mixer's listener (`onCatChange`, below) is subscribed before the panels mount further below, and reads this when told.
673
732
  */
674
733
  let audio = null;
675
734
  /*
@@ -750,6 +809,7 @@ export function createGame(o) {
750
809
  */
751
810
  const sceneMotion = readStoredScene(store, defaultReducedMotion(win.matchMedia));
752
811
  const saveSceneMotion = () => { storeScene(store, sceneMotion); };
812
+ const setGameTheme = cartridge.setPlayerTheme;
753
813
  const pauseIcons = initPauseIcons({
754
814
  translator, store,
755
815
  settings: state, // the page's settings store itself: its live bindings are the reads the bar asks for (ADR-0232)
@@ -814,11 +874,13 @@ export function createGame(o) {
814
874
  * edge between layers to save one argument. Composition is this file's job.
815
875
  */
816
876
  transportInUse: (i) => inputOf(i).inUse,
817
- // ✅ The monolith's dead guard works again — see the note on `audio`, above.
818
- reflectTtsPanel: () => { audio?.reflectTts(); },
819
- reflectTtsPanelEnabled: true,
820
- isLibrasOn: libras.isOpen,
821
- toggleLibras: () => libras.toggle(translator.t),
877
+ // ⚠️ OFF, and not the monolith's dead guard come back: the 🗣's write is told by the mixer, and the root's one listener to
878
+ // it (`onCatChange`, below) repaints the panel — a second path here would be two answers to «who reflects narration»
879
+ // (ADR-0247).
880
+ reflectTtsPanel: () => { },
881
+ reflectTtsPanelEnabled: false,
882
+ isLibrasOn: deafMode.isOn,
883
+ toggleLibras: deafMode.toggle,
822
884
  /*
823
885
  * ⚠️ WHAT THE GAME HANDS OVER: the three fields are optional on both sides. Absent, the pause card holds only what the
824
886
  * engine acts on, and the two visual icons follow their own rules. See the notes in `CreateGameOptions`.
@@ -865,7 +927,16 @@ export function createGame(o) {
865
927
  reserveBarBand(); // the name line under the bar grows with the text (issue #160)
866
928
  return FONT_BY_KEY[position.font]?.fam ?? null;
867
929
  } : undefined,
868
- ...(cartridge.setPlayerTheme ? { setPlayerTheme: cartridge.setPlayerTheme } : {}),
930
+ /*
931
+ * 🌗 THE THEME IS THE GAME'S TO DRAW (ADR-0148 erratum) AND THE ROOT'S TO KNOW: the decorative CRT yields to high
932
+ * contrast (ADR-0047), and a writer handed straight through left the root blind to it — the scanlines and the vignette
933
+ * stayed over a high-contrast screen.
934
+ */
935
+ ...(setGameTheme ? { setPlayerTheme: (i, theme) => {
936
+ setGameTheme(i, theme);
937
+ worldState = { ...worldState, tema: theme };
938
+ applyCrt();
939
+ } } : {}),
869
940
  /*
870
941
  * 🚥 COLOUR-VISION CORRECTION HAS AN ENGINE DEFAULT (ADR-0148 §1), so the icon is never missing for want of a writer.
871
942
  *
@@ -948,7 +1019,7 @@ export function createGame(o) {
948
1019
  /*
949
1020
  * ⚠️ AND AGAIN WHEN THE BOOT LANGUAGE ARRIVES — without it the bar stays in the FALLBACK language.
950
1021
  *
951
- * `initI18n` applies pt synchronously (so the page is never blank) and, if the preferred language is another, ASKS
1022
+ * `translator.init` applies pt synchronously (so the page is never blank) and, if the preferred language is another, ASKS
952
1023
  * for the switch — which is asynchronous, because en/es are on-demand chunks. This markup is born in that interval.
953
1024
  *
954
1025
  * 📌 IT IS THE `i18n:change` LISTENER near the pad that repaints it (study item C6, ADR-0031): the boot's preferred
@@ -961,7 +1032,8 @@ export function createGame(o) {
961
1032
  * `aria-pressed=false` after the child had turned it on — the control lying about its state.
962
1033
  *
963
1034
  * 📌 ONLY THE STATES WITH AN EVENT that can change elsewhere: blind mode, the camera control and the voice control
964
- * (`GameEvent`). The other icons reflect themselves on the click, which is the path by which they change.
1035
+ * (`GameEvent`), and NARRATION, which is the mixer's category and is told by the mixer (`onCatChange`, below). The other
1036
+ * icons reflect themselves on the click, which is the path by which they change.
965
1037
  */
966
1038
  stateOn('blindMode', () => { pauseIcons.reflectIconsIn(a11yBar, 0); });
967
1039
  // the 👀 changes elsewhere too: the eye control puts it back to off when the camera or the files are missing (ADR-0213)
@@ -971,6 +1043,26 @@ export function createGame(o) {
971
1043
  // is worse than a missing one (ADR-0106 §5), and the click path does not cover it, because this change comes from elsewhere.
972
1044
  stateOn('voiceControl', () => { pauseIcons.reflectIconsIn(a11yBar, 0); });
973
1045
  }
1046
+ // 🔴 AND THE 🗣 WHEN THE DEVICE'S VOICES CHANGE (ADR-0185 erratum 2026-09-26): they can arrive after load, or leave, and the
1047
+ // icon's greyed-out look is the lock the child sees. Silent: the press says why on its own, and nothing else changed.
1048
+ whenVoicesChange(() => {
1049
+ if (a11yBar && barUsable)
1050
+ pauseIcons.reflectIconsIn(a11yBar, 0);
1051
+ pauseIcons.reflectPauseIcons();
1052
+ });
1053
+ /*
1054
+ * 🔴 THE MIXER TELLS EVERY CHANGE, AND THIS IS THE ONE LISTENER (ADR-0247). The engine hands the mixer itself to the game
1055
+ * (`Engine.audio`), so a category — narration above all — is written by the bar's 🗣, by the panels, and by a game's own mute
1056
+ * button or cut-scene; without this, the bar and the panel said the state the child had left while the voice spoke or fell
1057
+ * silent (ADR-0159 rule 10: the state is said in words, and a stale word is a lie). Every writer is heard here and every
1058
+ * surface is drawn from here: the bar's icons for narration, the only category the bar shows, and the panels for any.
1059
+ * It is the ROOT's, so it ends with `dispose()`; `unmount()` keeps it, as the root and its surfaces live on (ADR-0142).
1060
+ */
1061
+ whenDisposed(mixer.onCatChange((k) => {
1062
+ if (k === 'tts' && a11yBar && barUsable)
1063
+ pauseIcons.reflectIconsIn(a11yBar, 0);
1064
+ audio?.reflectCategory(k);
1065
+ }));
974
1066
  // 4d. WHO OPENED THE PAUSE, when there is more than one seat — finding 3 of the `game-soccer` audit.
975
1067
  //
976
1068
  // ⚠️ THE KEYBOARD PANEL IS PARAMETERISED BY THE SEAT: `render(selPlayer)` draws the positions of THAT scheme, and the
@@ -998,6 +1090,11 @@ export function createGame(o) {
998
1090
  findPauseCard.id = 'vp-pause-0';
999
1091
  pauseMountPoint.appendChild(findPauseCard);
1000
1092
  }
1093
+ // THE LIBRARY FAMILIES THIS GAME DECLARED (`uses.fonts`, ADR-0255), the engine's own left out; a declaration that is not a list
1094
+ // of names is said in `problems`. Their faces are written and kept at the end of the boot, with the heavy files.
1095
+ const libraryFonts = declaredFamilies(o.uses?.fonts, ENGINE_FAMILIES);
1096
+ if (libraryFonts.problem)
1097
+ hostProblems.push(libraryFonts.problem);
1001
1098
  /*
1002
1099
  * 4f. THE SETTINGS PANELS (ADR-0106 §1).
1003
1100
  *
@@ -1038,12 +1135,10 @@ export function createGame(o) {
1038
1135
  t: translator.t, $, srSay, store, root: doc.documentElement,
1039
1136
  // The rows' prose goes to the footer at EVERY render, or it appears twice on the first click.
1040
1137
  fillExplain: overlays.fillExplain,
1041
- // ⚠️ `doc.fonts` IS A BROWSER GLOBAL — FINDING 15 of this file. Here it comes from the host's `doc` and is still
1042
- // asked whether it exists: a fake document has no `fonts`, and the absence has a declared answer (the row is
1043
- // disabled WITH the message that tells the adult which fonts solve it).
1044
- ...(typeof doc.fonts?.check === 'function'
1045
- ? { fontInstalled: (family) => doc.fonts.check(`16px "${family}"`) }
1046
- : {}),
1138
+ // 🔴 A LIBRARY FACE IS THERE WHEN THIS GAME DECLARED ITS FAMILY (ADR-0255), and that is the whole detector: the engine
1139
+ // writes the family's `@font-face` for exactly those. `document.fonts.check` is not asked, because it answers `true` for a
1140
+ // family NO rule declares (nothing to load) — it would offer every library row on every page.
1141
+ fontInstalled: (family) => libraryFonts.asked.includes(family),
1047
1142
  });
1048
1143
  /*
1049
1144
  * AAC — COMMUNICATION: THE PANEL IS NOT MOUNTED (ADR-0151).
@@ -1087,7 +1182,11 @@ export function createGame(o) {
1087
1182
  return;
1088
1183
  while (list.firstChild)
1089
1184
  list.removeChild(list.firstChild);
1090
- const slideContents = [...(cartridge.howToPlay ?? []), ...helpRows(cartridge.preset, (a) => keyboard.kbFor(0)[a], (code) => keyName(t, code))];
1185
+ // the game's words resolved NOW, in the page's language (ADR-0232 D3): a language change re-renders an open panel
1186
+ const slideContents = [
1187
+ ...playSlidesOf(cartridge.howToPlay ?? [], word),
1188
+ ...helpRows(actionWords(), (a) => keyboard.kbFor(0)[a], (code) => keyName(t, code)),
1189
+ ];
1091
1190
  const ctxDoSlide = { create: (tag) => doc.createElement(tag), t, title: t('menu.help') };
1092
1191
  const slides = mountSlides(ctxDoSlide);
1093
1192
  list.appendChild(slides);
@@ -1143,7 +1242,7 @@ export function createGame(o) {
1143
1242
  });
1144
1243
  gamePanel.shell.reset.hidden = true;
1145
1244
  redrawGameOptions = () => {
1146
- drawGameOptions({ ...panelCtx, say: srSay, t: translator.t }, gamePanel.shell.list, cartridge.gameOptions ?? []);
1245
+ drawGameOptions({ ...panelCtx, say: srSay, t: translator.t, word }, gamePanel.shell.list, cartridge.gameOptions ?? []);
1147
1246
  if (!gamePanel.shell.overlay.hidden)
1148
1247
  overlays.fillExplain(gamePanel.shell.card);
1149
1248
  };
@@ -1197,9 +1296,9 @@ export function createGame(o) {
1197
1296
  fillExplain: overlays.fillExplain,
1198
1297
  toggleBtn,
1199
1298
  // The «Personagem» section exists only if the GAME said it has one (ADR-0153). Read at every render: it changes on `mount()`.
1200
- hasCharacter: () => subjectWord(cartridge.accommodations, 'reducedCharacterMotion') !== null,
1201
- // and its title is the game's word for it (ADR-0153 confirmation)
1202
- characterLabel: () => subjectWord(cartridge.accommodations, 'reducedCharacterMotion')?.label ?? null,
1299
+ hasCharacter: () => subjectWord(cartridge.accommodations, 'reducedCharacterMotion', word) !== null,
1300
+ // and its title is the game's word for it (ADR-0153 confirmation), resolved at every render (ADR-0232 D3)
1301
+ characterLabel: () => subjectWord(cartridge.accommodations, 'reducedCharacterMotion', word)?.label ?? null,
1203
1302
  });
1204
1303
  engineActions.anim = animPanel.open;
1205
1304
  /*
@@ -1292,8 +1391,8 @@ export function createGame(o) {
1292
1391
  * Owner colours carries the game's word; the outlines are two positions of one subject, named by the engine.
1293
1392
  */
1294
1393
  const ownerSpec = () => {
1295
- const word = subjectWord(cartridge.accommodations, 'ownerColors');
1296
- return { id: 'opt-dono', label: word?.label ?? '', hint: word?.hint };
1394
+ const owner = subjectWord(cartridge.accommodations, 'ownerColors', word);
1395
+ return { id: 'opt-dono', label: owner?.label ?? '', hint: owner?.hint };
1297
1396
  };
1298
1397
  const { row: ownerRow, control: ownerButton } = controlRow(panelCtx, ownerSpec());
1299
1398
  const reflectOwner = () => {
@@ -1331,7 +1430,7 @@ export function createGame(o) {
1331
1430
  });
1332
1431
  // the hint is written BEFORE the panel's render, which runs `fillExplain`: written after, it stays inside the row
1333
1432
  const writeHint = () => {
1334
- explanation.textContent = subjectWord(cartridge.accommodations, 'contrastOutlines')?.hint ?? t(`visual.contorno.${plane}.dica`);
1433
+ explanation.textContent = subjectWord(cartridge.accommodations, 'contrastOutlines', word)?.hint ?? t(`visual.contorno.${plane}.dica`);
1335
1434
  };
1336
1435
  writeHint();
1337
1436
  const reflect = () => { updateSteps(stepper, spec()); };
@@ -1340,12 +1439,12 @@ export function createGame(o) {
1340
1439
  const fgOutline = outline('fg');
1341
1440
  const bgOutline = outline('bg');
1342
1441
  const offerOwnerAndOutlines = () => {
1343
- ownerRow.hidden = subjectWord(cartridge.accommodations, 'ownerColors') === null;
1442
+ ownerRow.hidden = subjectWord(cartridge.accommodations, 'ownerColors', word) === null;
1344
1443
  if (!ownerRow.hidden) {
1345
1444
  labelRow(ownerRow, ownerSpec());
1346
1445
  reflectOwner();
1347
1446
  }
1348
- const withoutOutlines = subjectWord(cartridge.accommodations, 'contrastOutlines') === null;
1447
+ const withoutOutlines = subjectWord(cartridge.accommodations, 'contrastOutlines', word) === null;
1349
1448
  for (const c of [fgOutline, bgOutline]) {
1350
1449
  c.row.hidden = withoutOutlines;
1351
1450
  if (!withoutOutlines)
@@ -1513,7 +1612,7 @@ export function createGame(o) {
1513
1612
  const audioPanel = mountPanel(panelCtx, {
1514
1613
  id: 'audio',
1515
1614
  // 📌 THE SHELL'S LIST IS NAVIGATION SOUND since ADR-0151: the taste categories went to «Áudio». The id
1516
- // `navsound-list` is the one `initSettingsAudio` fills with sonar, cane and guide.
1615
+ // `navsound-list` is the one `initSettingsAudio` fills with the sonar and the cane (`guard`/`guide` left, ADR-0258).
1517
1616
  listId: 'navsound-list',
1518
1617
  labels: () => ({
1519
1618
  title: t('menu.audio'),
@@ -1560,7 +1659,7 @@ export function createGame(o) {
1560
1659
  // is not wiring. ⚠️ Read at every opening: the topology is a function, and a game changes its demands between phases (ADR-0084).
1561
1660
  const hideRowsWithoutSubject = () => showOnlyRowsThatApply({
1562
1661
  find: $,
1563
- caneWord: () => subjectWord(cartridge.accommodations, 'caneSpacing'),
1662
+ caneWord: () => subjectWord(cartridge.accommodations, 'caneSpacing', word),
1564
1663
  hasNavigationSound: () => contractSubjects({
1565
1664
  declaration: cartridge.declaration,
1566
1665
  actions: cartridge.preset ? presetActions(cartridge.preset) : [],
@@ -1586,6 +1685,12 @@ export function createGame(o) {
1586
1685
  catch (e) {
1587
1686
  return [];
1588
1687
  } },
1688
+ /*
1689
+ * 🔴 THE SAMPLE SPEAKS THE PAGE'S LANGUAGE, read at every press: its text comes from the page's dictionary, so a fixed tag
1690
+ * asked the browser for a Portuguese voice to read English words. And a chosen voice of ANOTHER language is not handed
1691
+ * over (ADR-0185): the page's system-voice list offers every voice when none matches, and the one picked before a
1692
+ * language switch outlives it. So the voice goes only if the engine's own list for the language (`tts.voices()`) has it.
1693
+ */
1589
1694
  speakSample: (sample, chosen) => {
1590
1695
  try {
1591
1696
  const ss = win.speechSynthesis;
@@ -1593,8 +1698,8 @@ export function createGame(o) {
1593
1698
  return;
1594
1699
  ss.cancel();
1595
1700
  const u = speech.utterance(sample);
1596
- u.lang = 'pt-BR';
1597
- if (chosen)
1701
+ u.lang = bcp47();
1702
+ if (chosen && tts.voices().some((v) => v.voice === 'webspeech:' + chosen.name))
1598
1703
  u.voice = chosen;
1599
1704
  u.rate = 1;
1600
1705
  u.volume = 1;
@@ -1602,21 +1707,7 @@ export function createGame(o) {
1602
1707
  }
1603
1708
  catch (e) { /* the device refused to speak; the panel already says what it can do */ }
1604
1709
  },
1605
- whenVoicesChange: (again) => {
1606
- try {
1607
- if (win.speechSynthesis)
1608
- win.speechSynthesis.onvoiceschanged = again;
1609
- }
1610
- catch (e) { /* noop */ }
1611
- // one slot per page and the last root takes it: an ended root empties it only if it is still its own (ADR-0220)
1612
- whenDisposed(() => {
1613
- try {
1614
- if (win.speechSynthesis?.onvoiceschanged === again)
1615
- win.speechSynthesis.onvoiceschanged = null;
1616
- }
1617
- catch (e) { /* noop */ }
1618
- });
1619
- },
1710
+ whenVoicesChange,
1620
1711
  },
1621
1712
  audioOutputs: {
1622
1713
  canList: () => !!(win.navigator?.mediaDevices && win.navigator.mediaDevices.enumerateDevices),
@@ -1649,6 +1740,7 @@ export function createGame(o) {
1649
1740
  getVolume: () => mixer.volume,
1650
1741
  setVolume,
1651
1742
  getAudioCat: () => mixer.audioCat,
1743
+ // the bar follows what the panel writes through the mixer's own event, `onCatChange` above (ADR-0247)
1652
1744
  setCatGain,
1653
1745
  tts,
1654
1746
  getBlindMode: readBlindMode,
@@ -1662,18 +1754,17 @@ export function createGame(o) {
1662
1754
  engineActions.audio = audioPanel.open;
1663
1755
  engineActions.som = soundPanel.open;
1664
1756
  }
1665
- // 4b. NAVIGATION SOUND. Only the contract goes in: no tile, collision box or coin array.
1757
+ // 4b. THE SONAR. Only the contract goes in: no tile, collision box or coin array. Its WORDS are the text on screen at the
1758
+ // press, read from the page by `ui/screen-text` (ADR-0234: «sonar do que está na tela»); its tone points at the target.
1759
+ const screen = { doc, world: () => cartridge.declaration.world() };
1666
1760
  const sonar = createAudioSonar({
1761
+ screenText: () => screenText(screen),
1667
1762
  t: translator.t,
1668
1763
  topology: () => cartridge.declaration.topology(),
1669
1764
  targetsOf: (i) => cartridge.declaration.targetsOf(i),
1670
1765
  nameAt: (at) => cartridge.declaration.nameAt(at),
1671
- // Field 2 + the mixer bus: what the CONTINUOUS GUIDE needs and the beep did not (#84 item 2). `roleAt` is what lets
1672
- // the route go round a wall; `catNode`/`audioOut`/`getVolume` put a PERMANENT graph on the same volume slider as
1673
- // all the rest of the audio.
1674
- roleAt: (at) => cartridge.declaration.roleAt(at),
1675
- tonePan, srSay, narrate: (text) => tts.narrate(text),
1676
- catNode, audioOut, getVolume: () => mixer.volume,
1766
+ // what the sonar reads is spoken — or, in deaf mode, captioned and signed by the interpreter (ADR-0234)
1767
+ tonePan, srSay, narrate: deafMode.sonar,
1677
1768
  // ⚠️ THE ANSWER, NOT THE TABLE (#104): `platform/audio-sonar` does not know what a visual mode is, so it is answered
1678
1769
  // here — the root is the only layer that knows both axes AND may import from `render/`.
1679
1770
  visionImpaired: (pl) => {
@@ -1681,14 +1772,7 @@ export function createGame(o) {
1681
1772
  return !!v && (isBlind(v) || isLowVision(v));
1682
1773
  },
1683
1774
  getBlindMode: readBlindMode, LOGICAL_W,
1684
- // The player DERIVED from the focus: field 4 answering where the child is. A game that supplies no list still has
1685
- // the sonar, and that is what makes the accessibility stack not an accessory.
1686
- getPlayers: cartridge.sonarPlayers ?? (() => {
1687
- const f = cartridge.declaration.focusOf(0);
1688
- return f ? [{ i: 0, x: f.at.x, y: f.at.y, visual: DEFAULT_VISUAL }] : [];
1689
- }),
1690
1775
  getNumPlayers: () => (cartridge.players ?? [null]).length,
1691
- getAudioCtx: () => mixer.audioCtx, getSoundOn: () => mixer.soundOn, getAudioCat: () => mixer.audioCat,
1692
1776
  newContext: newAudioContext,
1693
1777
  });
1694
1778
  // 5. Remappable keyboard — the best cut of the base (finding 11): a key scheme, no world.
@@ -1768,6 +1852,8 @@ export function createGame(o) {
1768
1852
  // registered with the overlays, which cancels a running wizard without storing its map.
1769
1853
  closePadWiz: () => { overlays.closeById('padwiz'); },
1770
1854
  whichPlayer: (code) => keyboard.whichPlayer(code),
1855
+ // this root's one screen: another seat's keys steer none of its menus (below, at the rule)
1856
+ leadsTheScreen,
1771
1857
  actionOf: (code, i) => keyboard.actionOf(code, i),
1772
1858
  // Finding 12, SOLVED IN THE ENGINE: the port is generic over `WindowEventMap` (see `EventTargetLike` in
1773
1859
  // input/touch-bindings), so the real `window` goes straight in, with no adapter per consumer.
@@ -1794,6 +1880,7 @@ export function createGame(o) {
1794
1880
  * first is not what can be built first.
1795
1881
  */
1796
1882
  const blockKey = (e) => { e.preventDefault(); e.stopImmediatePropagation(); };
1883
+ /** Takes the item the scan shows, for a press from `source`; answers whether one-button scanning took it (ADR-0218 §4). */
1797
1884
  let scanPress = null;
1798
1885
  win.addEventListener('keydown', (e) => {
1799
1886
  if (!state.switchScan || !scanPress || keyboard.whichPlayer(e.code) < 0)
@@ -1807,6 +1894,36 @@ export function createGame(o) {
1807
1894
  if (state.switchScan && scanPress && keyboard.whichPlayer(e.code) >= 0)
1808
1895
  blockKey(e);
1809
1896
  }, true);
1897
+ /*
1898
+ * 🔴 AND EVERY TOUCH ON THE GAME REGION (ADR-0218 §4) — and on the on-screen pad, wherever the host put it. Registered here
1899
+ * for the key's reason: above the menus' own pointer listeners (a hold places the cursor, ADR-0159), the pad's and the game's,
1900
+ * so one touch is one take and nothing else. The click the browser fires after it is swallowed too; a click the ENGINE sends
1901
+ * — a menu's «confirm» pressing its item — is not, which is why `scanPress` clears the mark before every take.
1902
+ * 📌 THE QUICK BAR STAYS ITSELF: ☝️ on it is the way OUT of one-button mode, and 📷 and 👄 turn the camera and voice transports
1903
+ * on and off — the child's own switches, which a scan must not swallow (ADR-0218 erratum of 2026-09-26).
1904
+ */
1905
+ let swallowClick = false;
1906
+ const scanTakesTouch = (target) => {
1907
+ const el = target;
1908
+ if (!state.switchScan || !scanPress || !el || typeof el.closest !== 'function')
1909
+ return false;
1910
+ const onTheBar = !!el.closest(`${ENGINE_CONTROLS_IN_PLAY}, .pause-icons`) || !!a11yBar?.contains(el);
1911
+ return !onTheBar && !!el.closest('#game-region, #touch-controls');
1912
+ };
1913
+ win.addEventListener('pointerdown', (e) => {
1914
+ swallowClick = false;
1915
+ if (!scanTakesTouch(e.target))
1916
+ return;
1917
+ blockKey(e);
1918
+ scanPress?.('toque');
1919
+ swallowClick = true;
1920
+ }, true);
1921
+ win.addEventListener('pointerup', (e) => { if (scanTakesTouch(e.target))
1922
+ blockKey(e); }, true);
1923
+ win.addEventListener('click', (e) => { if (swallowClick) {
1924
+ swallowClick = false;
1925
+ blockKey(e);
1926
+ } }, true);
1810
1927
  // ⚠️ AND NOW THE MENU NAVIGATION SWITCHES ON — after the block above, and the ORDER IS THE BEHAVIOUR: listeners on one
1811
1928
  // node run in registration order, so the scan sees the key first and can stop it. The other way round, one press would
1812
1929
  // move the menu AND take an item (ADR-0218). 🔴 No type sees a lost call: browser cases hold that the menus answer the
@@ -1963,8 +2080,8 @@ export function createGame(o) {
1963
2080
  * 📌 The name line is counted whether or not a name is showing — reserving only while pointing would move the game
1964
2081
  * under the child's finger. Measured again at every scale and every typography step: both change the text's size.
1965
2082
  * ⚠️ Zero without a bar: nothing to reserve.
1966
- * 📌 With a HUD (ADR-0175) the room also holds the two top columns — points and mission on the left, power on the right —
1967
- * when either reaches lower than the bar's room; each is narrowed so it never reaches the bar.
2083
+ * 📌 With a HUD the room also holds the mission, centred just under the bar (ADR-0239 erratum), when it reaches lower than
2084
+ * the bar's room. The rest of the HUD is the row at the bottom, measured with it.
1968
2085
  */
1969
2086
  function reserveBarBand() {
1970
2087
  reserveTopBand({
@@ -1973,6 +2090,18 @@ export function createGame(o) {
1973
2090
  hud: hudMounted,
1974
2091
  ...(typeof win.getComputedStyle === 'function' ? { computedStyle: (el) => win.getComputedStyle(el) } : {}),
1975
2092
  });
2093
+ reserveRowBand();
2094
+ }
2095
+ /*
2096
+ * THE HUD ROW AT THE BOTTOM (ADR-0239; issue #94): learning bars · the session clock · the power over the score · the game's
2097
+ * map, left to right. The ROOT's, for its whole life — the clock lives in it and the session is the root's — and the bands
2098
+ * of each cartridge go into its cells. Measured (`--hud-row-h`, `--hud-row-bottom`) at every measure of the top and at every
2099
+ * tick of the clock: the on-screen pad shows and hides with the child's hands, and the row must stand above it.
2100
+ */
2101
+ const hudRow = mountHudRow(doc, $('#game-region'));
2102
+ whenDisposed(() => { hudRow?.row.parentNode?.removeChild(hudRow.row); });
2103
+ function reserveRowBand() {
2104
+ reserveBottomBand({ region: $('#game-region'), row: hudRow?.row ?? null, pad: $('#touch-controls') });
1976
2105
  }
1977
2106
  /*
1978
2107
  * THE HUD the engine mounts from the cartridge's `hud` (ADR-0168; issue #162). Read on every animation frame while mounted —
@@ -1981,13 +2110,27 @@ export function createGame(o) {
1981
2110
  */
1982
2111
  let hudMounted = null;
1983
2112
  let hudFrame = false;
2113
+ /*
2114
+ * THE SESSION CLOCK (ADR-0236, ADR-0239, ADR-0050 §3; issue #94): a label over the time left in digits and a Time Timer pie,
2115
+ * in the centre of the HUD row, for every cartridge — it measures the SESSION, which is this root's life, so a `mount()` does
2116
+ * not restart it and only `dispose()` takes it away. It always shows, measures one hour and turns red at the end: nothing on
2117
+ * the child's side sets it, and nothing about it is stored (ADR-0240).
2118
+ */
2119
+ const sessionClock = mountSessionClock({
2120
+ doc, slot: hudRow?.clock ?? null, t: translator.t,
2121
+ // as the host has them: a host with no clock mounts none, answered in the module (ADR-0232)
2122
+ performance: win.performance, setInterval: win.setInterval, clearInterval: win.clearInterval,
2123
+ announce: srSay, drawn: reserveRowBand,
2124
+ });
2125
+ whenDisposed(() => { sessionClock?.remove(); });
2126
+ localeOn(() => { sessionClock?.refresh(); });
1984
2127
  function mountHud() {
1985
2128
  hudMounted?.remove();
1986
2129
  hudMounted = null;
1987
2130
  const regionEl = $('#game-region');
1988
2131
  const numbers = cartridge.hud ?? [];
1989
2132
  if (numbers.length && regionEl && typeof regionEl.appendChild === 'function')
1990
- hudMounted = mountHudBands(translator.t, doc, regionEl, numbers);
2133
+ hudMounted = mountHudBands(translator, doc, regionEl, numbers, 0, hudRow ?? undefined);
1991
2134
  reserveBarBand();
1992
2135
  if (hudMounted && !hudFrame && typeof win.requestAnimationFrame === 'function') {
1993
2136
  hudFrame = true;
@@ -2215,8 +2358,15 @@ export function createGame(o) {
2215
2358
  const classes = records.map((r) => r.target.classList);
2216
2359
  if (classes.some((c) => c?.contains('screen-pause')))
2217
2360
  updateCaption();
2218
- if (classes.some((c) => c?.contains('screen-pause') || c?.contains('overlay') || c?.contains('pause-menu')))
2361
+ // the modal boundary follows the front card by ANY door, including a close that returns no focus (ADR-0130 rule 1)
2362
+ if (classes.some((c) => c?.contains('screen-pause') || c?.contains('overlay')))
2363
+ overlays.syncLayers?.();
2364
+ if (classes.some((c) => c?.contains('screen-pause') || c?.contains('overlay') || c?.contains('pause-menu'))) {
2219
2365
  announceContext();
2366
+ // 🔴 THE NAMES A CHILD CAN SAY CHANGED (ADR-0194 §1): this is the one signal that a card, one of its lists or a panel
2367
+ // showed or hid — without it no menu's names reach the recogniser, and no name can be said.
2368
+ voiceControl?.refreshGrammar();
2369
+ }
2220
2370
  // ADR-0166: the pad leaves when the card or a panel opens, and comes back when the last of them closes
2221
2371
  if (classes.some((c) => c?.contains('screen-pause') || c?.contains('overlay')))
2222
2372
  reflectPadInMenus();
@@ -2225,6 +2375,16 @@ export function createGame(o) {
2225
2375
  recomposeWorldFilter();
2226
2376
  }).observe(regionEl, { attributes: true, attributeFilter: ['hidden'], subtree: true });
2227
2377
  }
2378
+ // ADR-0245: a footer explanation longer than its lines scrolls inside them, at the child's caption rate; reduced motion is the
2379
+ // system's or the child's (any scene switch reduced: pages carry the same words, so a false «reduced» costs nothing)
2380
+ if (regionEl && Observer && typeof win.getComputedStyle === 'function' && typeof doc.createRange === 'function') {
2381
+ watchFooterScroll(regionEl, {
2382
+ doc, getComputedStyle: (el) => win.getComputedStyle(el), MutationObserver: Observer,
2383
+ setTimeout: (fn, ms) => win.setTimeout(fn, ms), clearTimeout: (id) => win.clearTimeout(id),
2384
+ ppm: () => state.captionPpm,
2385
+ reduced: () => defaultReducedMotion(win.matchMedia) || SCENE_KEYS.some((k) => sceneMotion[k]),
2386
+ });
2387
+ }
2228
2388
  }
2229
2389
  /*
2230
2390
  * THE SCREEN FOOTER: one column, at the bottom of the region, with the EXPLANATION of the pointed icon above the quick
@@ -2265,11 +2425,12 @@ export function createGame(o) {
2265
2425
  * «the sound caption above, the explanation below it»); `aria-hidden`, because whoever listens heard the sound itself.
2266
2426
  * 📌 Its time on screen is a child's reading time for its words, never under the 2600 ms the games had measured in play
2267
2427
  * (`core/caption-duration`, plan phase 5c); a new caption restarts it.
2428
+ * 📌 Written while captions are on OR deaf mode is: in deaf mode every sound gets its caption (ADR-0234).
2268
2429
  */
2269
2430
  let soundCaption = null;
2270
2431
  let clearSoundCaption = null;
2271
2432
  function writeSoundCaption(text) {
2272
- if (!state.captionsOn || !text)
2433
+ if (!deafMode.captionsOn() || !text)
2273
2434
  return;
2274
2435
  if (!soundCaption) {
2275
2436
  const home = screenFooter($('#game-region'));
@@ -2287,8 +2448,21 @@ export function createGame(o) {
2287
2448
  clearTimeout(clearSoundCaption);
2288
2449
  clearSoundCaption = setTimeout(() => { captionHome.hidden = true; captionHome.textContent = ''; }, captionDuration(text, state.captionPpm));
2289
2450
  }
2290
- /** The footer says ONE explanation at a time: the pointed icon's, or the reason a locked item is locked (ADR-0161). */
2451
+ /**
2452
+ * The footer says ONE explanation at a time: the pointed icon's, or the reason a locked item is locked (ADR-0161). `null` is
2453
+ * the item leaving — and the band goes back to the game's text, not to empty (ADR-0244 §2).
2454
+ */
2291
2455
  function writeInFooter(text) {
2456
+ itemExplanation = text || null;
2457
+ showExplanation();
2458
+ }
2459
+ /** The game's own explanation, its resting text (ADR-0244 §1); `null` clears it. Under a pointed engine item until it leaves. */
2460
+ function explainForGame(text) {
2461
+ gameExplanation = text || null;
2462
+ showExplanation();
2463
+ }
2464
+ function showExplanation() {
2465
+ const text = itemExplanation ?? gameExplanation;
2292
2466
  if (!barExplanation && text) {
2293
2467
  const home = screenFooter($('#game-region'));
2294
2468
  if (home) {
@@ -2330,30 +2504,61 @@ export function createGame(o) {
2330
2504
  if (!toOtherScreen)
2331
2505
  changePhase('playing');
2332
2506
  }
2333
- function toggleQuickPauseByStart(e) {
2334
- const seat = seatOfPosition(e.code, 'start');
2335
- if (seat === null)
2336
- return;
2507
+ /**
2508
+ * THE PAUSE IS PER SCREEN, AND A SHARED SCREEN'S IS ITS FIRST PLAYER'S (ADR-0144, erratum of 2026-09-26). This root draws ONE
2509
+ * screen — one region, one pause card (`#vp-pause-0`), one quick bar (`getA11yBars`) — and every seat it seats plays on it, so
2510
+ * its pause and its menus answer seat 0: START and SELECT from any other seat, by any transport, do nothing. A player with a
2511
+ * screen of her own has a root of her own, and its pause. START asks this below and the pad's START on an open menu asks it
2512
+ * too (`resume`); SELECT needs no question, because `openSeatMenus` opens the seat's own card and this root mounts seat 0's only.
2513
+ * And INSIDE an open menu the same: `ui/menu-nav` and `input/gamepad` ask it before handing a seat the dialog on top or the
2514
+ * card, so another seat's direction, confirm and back — by its keys, its pad, or a transport pressing the controller, whose
2515
+ * press becomes that seat's key — move, press and close nothing, and reach no game under the menu either.
2516
+ */
2517
+ function leadsTheScreen(seat) {
2518
+ return seat === 0;
2519
+ }
2520
+ /**
2521
+ * START for a seat, by whatever door: the key below, or a transport pressing the virtual controller in play (`systemPress`,
2522
+ * ADR-0144 §1 «from any transport»). Answers whether it acted — the key is consumed only then.
2523
+ */
2524
+ function startForSeat(seat) {
2525
+ if (!leadsTheScreen(seat))
2526
+ return false; // another seat of this screen: its START does nothing (ADR-0144 erratum)
2337
2527
  // GUARD 1 — A PANEL IS OPEN. With an overlay visible, `menuNavKey` receives the key, finds no intent in it and leaves
2338
2528
  // without consuming it. Without this guard the quick pause would open UNDER the panel the child is in. Escape closes
2339
2529
  // a panel, not START.
2340
2530
  if (overlays.topVisibleOverlay())
2341
- return;
2531
+ return false;
2342
2532
  // START AGAIN LEAVES — the second way out of the mode that ADR-0044 item 7 already gave START.
2343
2533
  if (inQuickPause.has(seat)) {
2344
2534
  leaveQuickPause(seat, 'jogo');
2345
- e.preventDefault();
2346
- return;
2535
+ return true;
2347
2536
  }
2348
2537
  // GUARD 2 — THE CARD IS OPEN. With it open and a «start» key other than `Enter`, `menuNavKey` finds no intent and
2349
2538
  // lets it through. Closing the card belongs to «Voltar ao jogo» and to Escape.
2350
2539
  const findPauseCard = $(`#vp-pause-${seat}`);
2351
2540
  if (findPauseCard && findPauseCard.hidden === false)
2352
- return;
2541
+ return false;
2353
2542
  enterQuickPause(seat);
2354
- // 📌 AND ONLY HERE, once the key was in fact OURS. `Enter` is «start» by default (`input/default-bindings`), and
2355
- // without this the same press would pause AND activate whatever had focus.
2356
- e.preventDefault();
2543
+ return true;
2544
+ }
2545
+ function toggleQuickPauseByStart(e) {
2546
+ const seat = seatOfPosition(e.code, 'start');
2547
+ if (seat === null)
2548
+ return;
2549
+ // GUARD 0 — THE KEY WAS TYPED INTO A FIELD. A text field owns Enter and every character, so `Enter` or `H` typed there is
2550
+ // the field's and not START, on the way in and out alike (ADR-0111 erratum of 2026-09-26). Left undecided, so it types.
2551
+ if (keyTypedIntoField(e.target))
2552
+ return;
2553
+ // GUARD 3 — THE KEY PRESSES THE ENGINE'S FOCUSED CONTROL. `Enter` is «start», and a focused button is activated by Enter as
2554
+ // well as Space: on ☰ it is ☰'s press, not the pause (ADR-0111 erratum of 2026-09-26, one key one action). Left undecided
2555
+ // here, so the browser clicks it. Only on the way IN: on the quick pause's own bar the leave keeps its meaning.
2556
+ if (!inQuickPause.has(seat) && keyPressesOwnControl(e.code, e.target, ENGINE_CONTROLS_IN_PLAY))
2557
+ return;
2558
+ // 📌 CONSUMED ONLY when it was in fact OURS. `Enter` is «start» by default (`input/default-bindings`), and without this
2559
+ // the same press would pause AND activate whatever had focus.
2560
+ if (startForSeat(seat))
2561
+ e.preventDefault();
2357
2562
  }
2358
2563
  win.addEventListener('keydown', toggleQuickPauseByStart);
2359
2564
  /*
@@ -2384,7 +2589,8 @@ export function createGame(o) {
2384
2589
  }
2385
2590
  function openMenusBySelect(e) {
2386
2591
  const seat = seatOfPosition(e.code, 'select');
2387
- if (seat === null)
2592
+ // `F` typed into a field is a letter, not SELECT (ADR-0111 erratum of 2026-09-26): the field owns every character.
2593
+ if (seat === null || keyTypedIntoField(e.target))
2388
2594
  return;
2389
2595
  if (openSeatMenus(seat))
2390
2596
  e.preventDefault();
@@ -2399,8 +2605,8 @@ export function createGame(o) {
2399
2605
  const seat = seatOfPosition(e.code, 'action4');
2400
2606
  if (seat === null || !inQuickPause.has(seat))
2401
2607
  return;
2402
- if (overlays.topVisibleOverlay())
2403
- return;
2608
+ if (overlays.topVisibleOverlay() || keyTypedIntoField(e.target))
2609
+ return; // a letter typed into a field
2404
2610
  leaveQuickPause(seat, 'cartao');
2405
2611
  pauseControls.show(seat);
2406
2612
  e.preventDefault();
@@ -2418,6 +2624,8 @@ export function createGame(o) {
2418
2624
  */
2419
2625
  const touchHostEl = o.host.touchHost ?? $('#game-region');
2420
2626
  const touchUsable = !!touchHostEl && typeof touchHostEl.appendChild === 'function';
2627
+ // the HUD row and the footer stand above the pad from the frame it shows in, and come down in the frame it leaves (ADR-0239 §6)
2628
+ whenDisposed(followThePad(touchHostEl, win.MutationObserver, reserveRowBand));
2421
2629
  const seat0CardOpen = () => {
2422
2630
  const c = $('#vp-pause-0');
2423
2631
  return !!c && c.hidden === false;
@@ -2437,9 +2645,7 @@ export function createGame(o) {
2437
2645
  eventTarget.dispatchEvent(stampSource(new KeyboardEvent('keydown', { code, key: code, bubbles: true, cancelable: true }), origin));
2438
2646
  };
2439
2647
  const labelledActions = () => {
2440
- const preset = cartridge.preset;
2441
- if (!preset)
2442
- return [];
2648
+ const preset = actionWords();
2443
2649
  const labelOf = labellerFrom(preset);
2444
2650
  return presetActions(preset).flatMap((a) => {
2445
2651
  const label = labelOf(a);
@@ -2457,6 +2663,8 @@ export function createGame(o) {
2457
2663
  // buttons DIRECTLY (ADR-0166, which undid ADR-0157's «the pad stays over the menus»). The only two callers that show
2458
2664
  // the pad — the touch listener below and `reflectPadInMenus` — already ask `isMenuOpen()` first.
2459
2665
  padAllowed: () => players().length <= 1,
2666
+ // each slot drawn by its size, five named positions or fewer a cycle row (ADR-0130 rule 3)
2667
+ drawChoice: (spec, pick) => mountChoice({ find: (sel) => $(sel), create: (tag) => doc.createElement(tag) }, spec, pick),
2460
2668
  });
2461
2669
  /** A menu the child touches directly is open: the pause card or a settings panel (ADR-0166 rule 2). */
2462
2670
  function isMenuOpen() {
@@ -2490,7 +2698,7 @@ export function createGame(o) {
2490
2698
  return;
2491
2699
  }
2492
2700
  const padMap = touchPad.getTouchMap();
2493
- const short = cartridge.preset ? shortLabellerFrom(cartridge.preset) : () => null;
2701
+ const short = shortLabellerFrom(actionWords());
2494
2702
  const pad = mountTouchControls({ find: (sel) => $(sel), create: (tag) => doc.createElement(tag), t: translator.t }, {
2495
2703
  map: padMap,
2496
2704
  gameActions: cartridgeActions(),
@@ -2539,6 +2747,8 @@ export function createGame(o) {
2539
2747
  */
2540
2748
  press: (action, source) => virtualController.press(action, source),
2541
2749
  release: (action, source) => virtualController.release(action, source),
2750
+ // the RAW edge: the press above already recorded it and resolved touch's latch at the controller's `pressedBy`, so this only
2751
+ // repeats an edge the automaton holds (ADR-0109 rule 3)
2542
2752
  playerEdge,
2543
2753
  heldKeys: keys,
2544
2754
  attractOnInput: () => false,
@@ -2628,6 +2838,8 @@ export function createGame(o) {
2628
2838
  * ⚠️ IT EXISTS ONLY WHERE THERE IS A TOUCH HOST: without one there is no pad size to choose, and the submenu's door
2629
2839
  * falls away by itself (`engineActions.motora` is not defined).
2630
2840
  */
2841
+ /** The motor panel's mapping wizard, once mounted: declared out here because the pad's poll below stands aside while it maps. */
2842
+ let padWizard = null;
2631
2843
  if (pauseMountPoint && pauseUsable && touchUsable) {
2632
2844
  const mobilityCtx = {
2633
2845
  find: (sel) => $(sel),
@@ -2701,11 +2913,10 @@ export function createGame(o) {
2701
2913
  const modeLabel = (m) => t(m === 1 ? 'motora.teclado.1' : m === 2 ? 'motora.teclado.2' : 'motora.teclado.34');
2702
2914
  const SIDES = ['leftShoulder', 'leftTrigger', 'rightShoulder', 'rightTrigger'];
2703
2915
  const actionsToMap = () => {
2704
- if (!cartridge.preset)
2705
- return [];
2706
- const word = labellerFrom(cartridge.preset);
2707
- return presetActions(cartridge.preset).flatMap((presetAction) => {
2708
- const actionWord = word(presetAction);
2916
+ const words = actionWords();
2917
+ const nameOf = labellerFrom(words);
2918
+ return presetActions(words).flatMap((presetAction) => {
2919
+ const actionWord = nameOf(presetAction);
2709
2920
  return actionWord ? [{ action: presetAction, label: actionWord }] : [];
2710
2921
  });
2711
2922
  };
@@ -2831,18 +3042,30 @@ export function createGame(o) {
2831
3042
  };
2832
3043
  reflectKeyboardRows();
2833
3044
  /*
2834
- * MAPEAR CONTROLE (ADR-0151 §2; issue #182): the engine's own wizard (`input/pad-wizard`), asking only the positions this
2835
- * game names, in its words, and storing the map `initGamepad` reads — one cache for the page. It reads the pads only
2836
- * while it is open. «Voltar» cancels; the last named position saves and closes. The shell's «restore» is hidden: a pad's
2837
- * map is replaced by mapping again.
3045
+ * MAPEAR CONTROLE (ADR-0151 §2; issue #182): the engine's own wizard (`input/pad-wizard`), asking the positions this
3046
+ * game names, in its words, then START and SELECT in the engine's (ADR-0144 erratum of 2026-09-26 — this labeller is the
3047
+ * preset's, which may not name them), and storing the map `initGamepad` reads — one cache for the page. It reads the pads
3048
+ * only while it is open. «Voltar» cancels; SELECT, the last step, saves and closes. The shell's «restore» is hidden: a
3049
+ * pad's map is replaced by mapping again.
3050
+ * 🔴 ONE WIZARD OWNS THE PAD: the gamepad transport builds its own wizard too (the one that opens by itself on a DirectInput
3051
+ * pad with no map), and each asks only about itself. So the root, which holds both, keeps them apart: while this one maps,
3052
+ * the pad's poll does not run (below), and while that one maps, this row does not open a second.
3053
+ */
3054
+ /**
3055
+ * One closer for «Voltar» and Escape: a running wizard is cancelled (and its close hides the panel); an idle one just hides.
3056
+ * 🔴 WHICHEVER WIZARD IS MAPPING: the transport's own shows in this same overlay, and hiding it alone left that wizard
3057
+ * mapping unseen — the game paused, the pad answering questions nobody showed, and its map stored at the end.
2838
3058
  */
2839
- let padWizard = null;
2840
- /** One closer for «Voltar» and Escape: a running wizard is cancelled (and its close hides the panel); an idle one just hides. */
2841
3059
  const closeControl = () => {
2842
3060
  if (padWizard?.state()) {
2843
3061
  padWizard.close(false);
2844
3062
  return;
2845
3063
  }
3064
+ // its close hides the overlay, resumes the game it paused, and gives the focus back through `wizardClosed` (ADR-0248)
3065
+ if (gamepad.getPadWiz()) {
3066
+ gamepad.closePadWiz(false);
3067
+ return;
3068
+ }
2846
3069
  controlPanel.shell.overlay.hidden = true;
2847
3070
  overlays.restoreFocus?.('padwiz');
2848
3071
  };
@@ -2893,6 +3116,8 @@ export function createGame(o) {
2893
3116
  controlButton.setAttribute('type', 'button');
2894
3117
  controlButton.id = 'opt-controle';
2895
3118
  controlButton.addEventListener('click', () => {
3119
+ if (gamepad.getPadWiz())
3120
+ return; // the transport's own wizard is mapping a pad: it keeps it
2896
3121
  controlPanel.open();
2897
3122
  padWizard?.open();
2898
3123
  });
@@ -2902,7 +3127,8 @@ export function createGame(o) {
2902
3127
  controlStrong.textContent = t('motora.controle');
2903
3128
  controlButton.textContent = t('motora.abrir');
2904
3129
  controlButton.setAttribute('aria-label', t('motora.controle'));
2905
- controlMappingRow.hidden = actionsToMap().length === 0; // nothing named, nothing to map
3130
+ // ⚠️ SHOWN FOR EVERY GAME: one that names no position gets the wizard's fourteen default positions (ADR-0144, erratum of
3131
+ // 2026-09-26), so there is always something to map.
2906
3132
  };
2907
3133
  reflectControlRow();
2908
3134
  /*
@@ -2926,7 +3152,7 @@ export function createGame(o) {
2926
3152
  const hideSlotsWithoutAction = () => {
2927
3153
  const named = cartridgeActions();
2928
3154
  const padMap = touchPad.getTouchMap();
2929
- for (const sel of Array.from(touchPanel.shell.list.querySelectorAll('select[data-slot]'))) {
3155
+ for (const sel of Array.from(touchPanel.shell.list.querySelectorAll('[data-slot]'))) {
2930
3156
  const rowNode = sel.closest('.ctrl-row');
2931
3157
  if (rowNode)
2932
3158
  rowNode.hidden = !named.has(padMap[sel.dataset.slot ?? ''] ?? '');
@@ -2958,10 +3184,8 @@ export function createGame(o) {
2958
3184
  * 🔴 NO STICKY-KEYS ROW IN THIS PANEL (the Dev: «Tire a linha de acessibilidade motora»): the bar's ☝️ is the one
2959
3185
  * surface for that cycle (ADR-0218), and two surfaces of one setting was what the row had become.
2960
3186
  *
2961
- * ⚠️ The row was also the only place that said WHY the latch is locked on a device that sends one command at a time
2962
- * (ADR-0113 clause 3). It stays honest because the cycle does not OFFER what is locked: where the latch is required,
2963
- * «padrão» does not appear, and there is nothing to explain. The «Esperar entre toques» row (ADR-0217) stays: it is
2964
- * another setting.
3187
+ * 📌 No device locks it any more (ADR-0249): on eyes, face, gestures and speech the latch starts as the game's `holdsKeys()`
3188
+ * and the ☝️ offers all three positions there too. The «Esperar entre toques» row (ADR-0217) stays: it is another setting.
2965
3189
  */
2966
3190
  /*
2967
3191
  * «ESPERAR ENTRE TOQUES» (ADR-0217; GAG Advanced/Motor, issue #182). The row beside the sticky keys, and the other half of
@@ -3031,7 +3255,10 @@ export function createGame(o) {
3031
3255
  return; // at the end of the line nothing moved, and nothing is announced
3032
3256
  state.setCameraControlValue(mode);
3033
3257
  reflectCamera();
3034
- srSay(`${t('motora.camera')}: ${t(CAMERA_MODE_WORD[mode])}`);
3258
+ // 🔴 A MODE THAT STARTS IS ANNOUNCED BY ITS CONTROL, which answers once it has really started or says why it could not —
3259
+ // the rule and its reason are at the 📷 in `ui/pause-icons`. Off is true at once, and is said here.
3260
+ if (mode === 'off')
3261
+ srSay(`${t('motora.camera')}: ${t('state.off')}`);
3035
3262
  });
3036
3263
  // the 📷 and this row are one setting: whoever changes it, both show it
3037
3264
  stateOn('cameraControl', () => { reflectCamera(); });
@@ -3058,10 +3285,12 @@ export function createGame(o) {
3058
3285
  };
3059
3286
  voiceButton.addEventListener('click', () => {
3060
3287
  state.setVoiceControlValue(!state.voiceControl);
3061
- // ⚠️ THE ANNOUNCEMENT READS THE STATE AFTER THE WRITE, and not the value it meant to write: what cannot start puts the
3062
- // answer back to off inside the same click, and announcing the intention would tell the child the opposite of what is true.
3063
3288
  reflectVoice();
3064
- srSay(`${t('motora.voz')}: ${t(state.voiceControl ? 'state.on' : 'state.off')}`);
3289
+ // 🔴 ON IS ANNOUNCED BY `ui/voice-control`, which starts AFTER this click, asynchronously, and answers either way: ready,
3290
+ // or the reason it could not start. Reading the state here still read «on», because the failure had not happened yet —
3291
+ // the rule and its reason are at the 👄 in `ui/pause-icons`. Off is true at once, and is said here.
3292
+ if (!state.voiceControl)
3293
+ srSay(`${t('motora.voz')}: ${t('state.off')}`);
3065
3294
  });
3066
3295
  stateOn('voiceControl', () => { reflectVoice(); });
3067
3296
  reflectVoice();
@@ -3162,17 +3391,38 @@ export function createGame(o) {
3162
3391
  * 📌 The right channel is the one the function already has: `onHeavyProgress`, handed to whoever calls. A consumer who
3163
3392
  * wants to show «N MB left» or «the voice did not come down» has a way; the engine invents no surface.
3164
3393
  */
3394
+ /*
3395
+ * THE LIBRARY FONTS THIS GAME DECLARED (ADR-0255): their `@font-face` rules go into the document as soon as the catalogue
3396
+ * loads, pointing at the delivery's `heavy/`, and their files are kept FIRST in the checked cache — a family the library does
3397
+ * not hold, or the delivery did not carry, becomes a line of `problems`. No catalogue is loaded for a game that declared none.
3398
+ */
3399
+ const fontsStarted = libraryFonts.asked.length ? startLibraryFonts({
3400
+ asked: libraryFonts.asked,
3401
+ declare: (css) => { const sheet = doc.createElement('style'); sheet.id = 'incl-library-fonts'; sheet.textContent = css; (doc.head ?? doc.documentElement).appendChild(sheet); },
3402
+ href: (url) => new URL(deliveryPath(url), doc.baseURI).href,
3403
+ report: (line) => { if (!measuredProblems.includes(line))
3404
+ measuredProblems.push(line); },
3405
+ }) : null;
3165
3406
  if (o.downloadHeavy !== false) {
3166
- // ⚠️ THE READING MODEL IS ASKED FOR BY LANGUAGE and not by a yes: the three together are 850 MiB, and the child is reading in
3167
- // one of them. `bcp47()` is already the language the interface booted in (ADR-0031), so nothing new has to be decided here.
3168
- void downloadHeavy({
3169
- // 📌 AND THE COMMAND MODEL IS ASKED FOR WITHOUT ASKING THE GAME (issue #184): a child who says «menu» instead of pressing
3170
- // it is reaching the controller, and no cartridge declares — or denies — a way in (ADR-0111). A delivery built without
3171
- // `--commands` simply has none, this background fetch fails quietly, and the transport says so when she turns it on.
3172
- only: heavyAtBoot({ kokoro: !!o.uses?.neuralVoice, reading: o.uses?.reading ? bcp47() : null, commands: bcp47() }),
3173
- onProgress: o.onHeavyProgress,
3407
+ // ⚠️ THE READING MODELS OF EVERY LANGUAGE THE PAGE CAN SWITCH TO, when the game listens (ADR-0225 erratum, the Dev:
3408
+ // «Negativo, baixar os três. Toda criança vai experimentar as três línguas imediatamente.»). `bcp47()` is the language the
3409
+ // interface booted in (ADR-0031), named first so everything of hers comes down before the 850 MiB of the three does.
3410
+ const keepHeavy = (fonts) => downloadHeavy({
3411
+ also: fonts?.files ?? [],
3412
+ // 📌 AND THE COMMAND MODELS ARE ASKED FOR WITHOUT ASKING THE GAME (issue #184): a child who says «menu» instead of pressing
3413
+ // it is reaching the controller, and no cartridge declares — or denies — a way in (ADR-0111). ONE PER LANGUAGE THE PAGE
3414
+ // CAN SWITCH TO, the child's first (ADR-0225 erratum): a language changed mid-game, offline the next day, must find its
3415
+ // model kept. A language the delivery's `--commands` left out fails quietly here, and the transport says so when she speaks.
3416
+ // 📌 The Libras player only while deaf mode is on (ADR-0234): the delivery's list of its avatar, clips, glosses and three.js,
3417
+ // which a child who never asks for signing does not pay — and which keep it signing on the days without a network.
3418
+ only: heavyAtBoot({
3419
+ kokoro: !!o.uses?.neuralVoice, reading: o.uses?.reading ? [bcp47(), ...availableLocales()] : null,
3420
+ commands: [bcp47(), ...availableLocales()], libras: deafMode.isOn(),
3421
+ }),
3422
+ onProgress: (r) => { fonts?.onReport(r); o.onHeavyProgress?.(r); },
3174
3423
  cacheStorage: heavyCaches, fetch: win.fetch, digest: sha256With(win.crypto?.subtle), base: doc.baseURI,
3175
- })
3424
+ });
3425
+ void (fontsStarted ? fontsStarted.then(keepHeavy) : keepHeavy(null))
3176
3426
  .catch(() => { });
3177
3427
  }
3178
3428
  /*
@@ -3192,22 +3442,16 @@ export function createGame(o) {
3192
3442
  // absent value becomes `{}` — the cartridge that did not answer — and is refused below with ADR-0153's sentence, never
3193
3443
  // with a TypeError on `hooks.preset`.
3194
3444
  function mountAll(declaration, hooks = {}) {
3195
- // ⚠️ IT THROWS, IT DOES NOT DIAGNOSE — the boot's rule, and so the boot's sentence. A malformed declaration is a
3196
- // precondition: `problems` is for gaps one can still play with, and this is not one.
3197
- const malformed = conformanceProblems(declaration);
3198
- if (malformed.length) {
3199
- refuseDeclaration('mount', malformed);
3200
- }
3201
- // ⚠️ AND `mount()` REFUSES BY THE SAME RULES, before writing to `cartridge`. `CartridgeHooks` is
3202
- // `Omit<GameHalf, 'declaration'>`, so it carries `preset` — a second cartridge could take the «start» the first one
3203
- // respected, and the root would be left with the pause unreachable mid-session.
3204
- refuseIfItClaimsStart('mount', hooks.preset);
3205
- refuseIfNoAnswer('mount', hooks.accommodations);
3206
- refuseIfGenreRefused('mount', hooks.genre);
3207
- refuseIfHudMalformed('mount', hooks.hud);
3208
- refuseIfOptionsMalformed('mount', hooks.gameOptions);
3209
- refuseIfHowToPlayMalformed('mount', hooks.howToPlay);
3445
+ // ⚠️ IT THROWS, IT DOES NOT DIAGNOSE — the boot's rule, and so the boot's list and sentence (`cartridgeRefusals`). A
3446
+ // malformed cartridge is a precondition: `problems` is for gaps one can still play with, and this is not one. And it
3447
+ // refuses BEFORE writing to `cartridge`: `CartridgeHooks` carries `preset`, so a second cartridge could take the
3448
+ // «start» the first one respected, and the root would be left with the pause unreachable mid-session.
3449
+ refuseCartridge('mount', declaration, hooks);
3210
3450
  cartridge = { ...hooks, declaration };
3451
+ explainForGame(null); // the explanation was the replaced cartridge's (ADR-0244)
3452
+ // its words, before anything draws them: ADDED to the root's dictionary, so the keys a shell registered at boot stay
3453
+ for (const [code, entries] of Object.entries(hooks.dictionaries ?? {}))
3454
+ translator.registerDict(code, entries);
3211
3455
  mountHud(); // the numbers are the cartridge's: the new one's replace the old one's, and the room is measured again
3212
3456
  followCartridgeMappings(declaration);
3213
3457
  redrawGameOptions(); // the rows are the new cartridge's, drawn or cleared before its door is weighed
@@ -3286,11 +3530,42 @@ unstyled, so a child who plays by keyboard cannot see where focus is — link th
3286
3530
  * vocabulary (ADR-0216): a method only this file calls has no business in a contract seven repositories read.
3287
3531
  */
3288
3532
  let closeReadingThread = () => { };
3533
+ // the command recogniser's bundle, put in the page once and shared by the 👄 and a choice by voice
3534
+ const voskBundle = createBundleLoader((url) => import(/* @vite-ignore */ url));
3535
+ // one command model per language, loaded once for the 👄 and a choice alike, ended with the root (ADR-0220, ADR-0256)
3536
+ const voskModels = keepVoskModels(loadVoskRuntime);
3537
+ whenDisposed(() => { voskModels.closeAll(); });
3538
+ /*
3539
+ * A CHOICE AMONG THE OPTIONS THE GAME SHOWS (ADR-0256): heard by the command recogniser with a grammar of exactly those options,
3540
+ * and the 👄 paused meanwhile — one ear per word. Not tied to `uses.reading`: the command models are asked of every delivery. A
3541
+ * device with no microphone or no model is told by the chooser itself, in `problems` and to the game.
3542
+ */
3543
+ const chooser = createVoiceChooser({
3544
+ language: () => bcp47(),
3545
+ open: openWithCommandRecogniser({
3546
+ base: doc.baseURI, hasFile: hasHeavyFile, loadBundle: voskBundle, fetch: (url) => win.fetch(url),
3547
+ getUserMedia, createContext: () => new HostAudioContext(), load: voskModels.load,
3548
+ }),
3549
+ // the 👄 already listening in the options' language lends its ear: no second model, no second microphone
3550
+ borrow: (language, grammar, onHeard) => voiceControl?.borrow(language, grammar, onHeard) ?? null,
3551
+ after: (fn, ms) => win.setTimeout(fn, ms),
3552
+ cancel: (h) => win.clearTimeout(h),
3553
+ report: (line) => { if (!measuredProblems.includes(line))
3554
+ measuredProblems.push(line); },
3555
+ // `apply(false)` stops without writing the child's stored answer (ADR-0220), so resuming is applying that answer again
3556
+ pause: () => { void voiceControl?.apply(false); },
3557
+ resume: () => { void voiceControl?.apply(state.voiceControl); },
3558
+ });
3289
3559
  const reading = (() => {
3290
3560
  const browserApis = win;
3291
3561
  let microphone = null;
3292
- /** The reading thread, kept between readings (opening it compiles the model again) and let go with the game. */
3293
- let readingThread = null;
3562
+ /**
3563
+ * The reading thread, ONE PER LANGUAGE and kept between readings — opening it compiles the model again, up to 378 MiB — and let
3564
+ * go with the game (`platform/reading-in-worker.keepOneThreadPerLanguage`, built at the first reading with the module).
3565
+ */
3566
+ let readingThreads = null;
3567
+ let readingRuntimes = null;
3568
+ closeReadingThread = () => { readingThreads?.close(); readingRuntimes?.close(); };
3294
3569
  const listener = createReading({
3295
3570
  language: () => bcp47(),
3296
3571
  api: (browserApis.SpeechRecognition ?? browserApis.webkitSpeechRecognition ?? null),
@@ -3299,6 +3574,7 @@ unstyled, so a child who plays by keyboard cannot see where focus is — link th
3299
3574
  stopEvery: (h) => win.clearInterval(h),
3300
3575
  report: (rowNode) => { if (!measuredProblems.includes(rowNode))
3301
3576
  measuredProblems.push(rowNode); },
3577
+ chooser,
3302
3578
  /**
3303
3579
  * THE ENGINE'S OWN RECOGNISER, and it arrives late on purpose (ADR-0216 §5): `platform/reading-runtime` is what names the
3304
3580
  * model files, so a game that never listens — and a child of a game that does, until the first `listen()` — loads none of
@@ -3322,31 +3598,45 @@ unstyled, so a child who plays by keyboard cannot see where focus is — link th
3322
3598
  */
3323
3599
  const Worker = browserApis.Worker;
3324
3600
  if (typeof Worker === 'function') {
3325
- const { createReadingInWorker } = await import('../platform/reading-in-worker.js');
3326
- readingThread?.close();
3327
- readingThread = createReadingInWorker({
3601
+ const { createReadingInWorker, keepOneThreadPerLanguage } = await import('../platform/reading-in-worker.js');
3602
+ // 🔴 THE SAME LANGUAGE REUSES ITS THREAD: this port is asked at EVERY `listen()`, and it used to close and reopen the
3603
+ // thread each time — recompiling up to 378 MiB per reading. Another language closes the old one and opens hers.
3604
+ readingThreads ??= keepOneThreadPerLanguage();
3605
+ return readingThreads.forLanguage(language, (failed) => createReadingInWorker({
3328
3606
  base: doc.baseURI,
3329
3607
  language,
3330
3608
  spawn: () => new Worker(new URL('../platform/reading-worker.js', import.meta.url), { type: 'module' }),
3331
3609
  // 📌 THE SENTENCE IS WRITTEN HERE and the module hands over only the REASON: a thread that fails to open when
3332
3610
  // nobody is waiting for the answer had nowhere to be said (ADR-0169), and whoever knows what the child loses
3333
- // is the diagnostic channel, not a thread's protocol.
3611
+ // is the diagnostic channel, not a thread's protocol. It names BOTH fixes, since `--reading <language>` alone
3612
+ // would now narrow the delivery (ADR-0225 erratum) — and the child is told too, not only the adult.
3334
3613
  report: (reason) => {
3335
- const line = `reading: the transcription thread could not open — ${reason}; a child who reads aloud `
3336
- + 'gets no answer, and nothing else in the page will say so — check that the reading model for this '
3337
- + 'language reached `heavy/` (npx inclusionist-heavy --reading <language>)';
3614
+ failed(); // forgotten: the next reading opens it again, the model may have come down since
3615
+ const line = `reading: the transcription thread could not open for ${language} — ${reason}; a child who reads `
3616
+ + 'aloud in this language gets no answer — build the delivery with `npx inclusionist-heavy --reading` alone (it '
3617
+ + `carries every language) or with \`--reading ${language.split('-')[0].toLowerCase()}\` in its list, and open `
3618
+ + 'the game once online so the install fetches it';
3338
3619
  if (!measuredProblems.includes(line))
3339
3620
  measuredProblems.push(line);
3621
+ srAlert(t('sr.reading.failed'));
3340
3622
  },
3341
- });
3342
- closeReadingThread = () => { readingThread?.close(); readingThread = null; };
3343
- return readingThread;
3623
+ }));
3344
3624
  }
3345
- measuredProblems.push('reading: this browser has no `Worker`, so the transcription runs on the same thread that '
3625
+ const sameThreadLine = 'reading: this browser has no `Worker`, so the transcription runs on the same thread that '
3346
3626
  + 'draws the game and feeds the microphone — measured, that cuts the recording in gaps of seconds and the child '
3347
- + 'loses the words she said meanwhile; serve the game where workers are available');
3348
- const { loadReadingRuntime } = await import('../platform/reading-runtime.js');
3349
- return loadReadingRuntime({ base: doc.baseURI, language, fetch: (url) => win.fetch(url) });
3627
+ + 'loses the words she said meanwhile; serve the game where workers are available';
3628
+ if (!measuredProblems.includes(sameThreadLine))
3629
+ measuredProblems.push(sameThreadLine);
3630
+ // 🔴 ONE RUNTIME PER LANGUAGE HERE TOO (issue #185): loading it at every `listen()` fetched and compiled up to 378 MiB per
3631
+ // reading, on the very thread that draws the game. A switch loads hers; a load that failed is forgotten and tried again.
3632
+ const { keepOneThreadPerLanguage } = await import('../platform/reading-in-worker.js');
3633
+ readingRuntimes ??= keepOneThreadPerLanguage();
3634
+ return readingRuntimes.forLanguage(language, (failed) => {
3635
+ const runtime = import('../platform/reading-runtime.js')
3636
+ .then(({ loadReadingRuntime }) => loadReadingRuntime({ base: doc.baseURI, language, fetch: (url) => win.fetch(url) }));
3637
+ runtime.catch(() => failed()); // the reading that asked still hears the reason: it awaits this same promise
3638
+ return { runtime, close: () => { } };
3639
+ }).runtime;
3350
3640
  }
3351
3641
  : undefined,
3352
3642
  /**
@@ -3368,6 +3658,7 @@ unstyled, so a child who plays by keyboard cannot see where focus is — link th
3368
3658
  return {
3369
3659
  ready: () => (o.uses?.reading ? listener.ready() : Promise.resolve({ can: false, why: 'no-model' })),
3370
3660
  stop: () => listener.stop(),
3661
+ choose: (options, settings) => listener.choose(options, settings),
3371
3662
  listen: (options) => {
3372
3663
  if (!o.uses?.reading) {
3373
3664
  if (!measuredProblems.includes(notDeclared))
@@ -3392,52 +3683,90 @@ unstyled, so a child who plays by keyboard cannot see where focus is — link th
3392
3683
  /*
3393
3684
  * THE SCAN ITSELF (ADR-0218): the list is the positions this cartridge declared AND NAMED, because the chip says the game's
3394
3685
  * own words and a position nobody named would cost the child a pass of silence (ADR-0074). It is rebuilt every time the scan
3395
- * starts, so a `mount()` of another cartridge scans ITS positions and not the ones that booted first (ADR-0142).
3686
+ * starts, so a `mount()` of another cartridge scans ITS positions and not the ones that booted first (ADR-0142). After them come
3687
+ * the engine's own doors, «menu» (SELECT) and «pausar» (START), each only where it has something behind it (ADR-0218 §3).
3688
+ *
3689
+ * 🔴 AND INSIDE A MENU THE LIST IS THAT MENU'S STEPS (ADR-0218 erratum of 2026-09-26). The switch's key is stopped before any
3690
+ * listener, so no menu ever saw it: 📏 a child who opened the quick pause by scanning was left in it, every press doing nothing.
3691
+ * With the quick bar, the card or a panel in front, the scan offers «cancel · next · confirm · back · previous» and a press
3692
+ * moves the menu through `ui/menu-nav`, the path the keys and the pad take. On a panel's control with a value — a slider, a
3693
+ * list, a ⯇ ⯈ row — the pass also offers «increase» and «decrease», the right and left keys' step (`menuScanFor`).
3396
3694
  */
3397
3695
  const gameRegion = $('#game-region');
3398
3696
  const scanChip = gameRegion ? mountScanOverlay(doc, gameRegion) : null;
3399
- let scanner = null;
3697
+ let scanning = null;
3400
3698
  let scanFrame = 0;
3401
- const scanWord = (item) => scanItemText(item, (a) => (cartridge.preset ? labellerFrom(cartridge.preset)(a) : null), t('scan.nothing'));
3699
+ // «Is a menu in front?» is the question the virtual controller asks (`menuWithDpad`): the quick pause, the card or a panel.
3700
+ const freshScan = () => {
3701
+ if (menuWithDpad()) {
3702
+ const cursor = nav.underCursor(0);
3703
+ return { inMenu: true, cursor, pass: createSwitchScan(menuScanFor(cursor)) };
3704
+ }
3705
+ const words = actionWords();
3706
+ const names = labellerFrom(words);
3707
+ // the ENGINE'S DOORS after the game's words (ADR-0218 §3): the card where it is mounted, the quick pause where it has its bar
3708
+ const doors = { menus: pauseUsable, quickPause: barUsable };
3709
+ return { inMenu: false, pass: createSwitchScan(playScanList(presetActions(words).filter((a) => !!names(a)), doors)) };
3710
+ };
3711
+ const scanWord = (item) => scanItemText(item, labellerFrom(actionWords()), t);
3402
3712
  // A word of a different length is a different amount of room to keep free, so the band is measured again — and only then.
3403
3713
  const scanShow = (item) => { if (scanChip?.showing(scanWord(item)))
3404
3714
  reserveBarBand(); };
3405
3715
  const scanTick = () => {
3406
- if (!scanner)
3716
+ if (!scanning)
3407
3717
  return;
3408
- scanShow(scanner(win.performance.now()).showing.item);
3718
+ // 📌 A MENU OPENED OR CLOSED, OR ITS CURSOR REACHED A CONTROL OF ANOTHER KIND: the pass becomes the other list, from
3719
+ // «cancel», which is also what a press does — so the first thing offered in a menu just opened is the item that takes nothing.
3720
+ if (scanning.inMenu !== menuWithDpad() || (scanning.inMenu && scanning.cursor !== nav.underCursor(0)))
3721
+ scanning = freshScan();
3722
+ scanShow(scanning.pass(win.performance.now()).showing.item);
3409
3723
  scanFrame = win.requestAnimationFrame(scanTick);
3410
3724
  };
3411
3725
  const stopScan = () => {
3412
3726
  if (scanFrame)
3413
3727
  win.cancelAnimationFrame(scanFrame);
3414
3728
  scanFrame = 0;
3415
- scanner = null;
3729
+ scanning = null;
3416
3730
  scanChip?.hide();
3417
3731
  reserveBarBand(); // the room the chip was keeping goes back to the game
3418
3732
  };
3419
3733
  const startScan = () => {
3420
- if (scanner)
3734
+ if (scanning)
3421
3735
  return;
3422
- const names = cartridge.preset ? labellerFrom(cartridge.preset) : null;
3423
- const offered = (cartridge.preset ? presetActions(cartridge.preset) : []).filter((a) => !!names?.(a));
3424
- scanner = createSwitchScan(offered);
3736
+ scanning = freshScan();
3425
3737
  scanTick();
3426
3738
  };
3739
+ /** The scan is pressing the controller itself: that press is the position it took, and must not be taken again. */
3740
+ let scanIsPressing = false;
3427
3741
  scanPress = (source) => {
3428
- if (!scanner)
3429
- return;
3742
+ if (!scanning || scanIsPressing)
3743
+ return false;
3744
+ swallowClick = false; // a click this take sends — a menu's «confirm» — is the take's, not a touch's (see the pointer above)
3430
3745
  // 📌 THE CHIP IS NOT REDRAWN HERE, and a surviving mutation is why: the frame loop above draws every frame, so a second
3431
3746
  // drawing path only saved the sixteen milliseconds until the next one — a line that could disagree with the loop and could
3432
3747
  // never be seen doing it.
3433
- const out = scanner(win.performance.now(), { press: true });
3434
- const action = out.commanded;
3748
+ const now = win.performance.now();
3749
+ if (scanning.inMenu) {
3750
+ // THE MENU MOVES BY ITS OWN PATH: the step is the intent a key with that meaning carries, handed to `ui/menu-nav` as seat 0.
3751
+ const step = scanning.pass(now, { press: true }).commanded;
3752
+ if (step)
3753
+ nav.navIntent(0, menuStepKeys(step));
3754
+ return true;
3755
+ }
3756
+ const action = scanning.pass(now, { press: true }).commanded;
3435
3757
  if (!action)
3436
- return;
3437
- // 📌 THROUGH THE VIRTUAL CONTROLLER, like every other transport (ADR-0111): in a menu it becomes that menu's key, in play it
3438
- // holds the child's key and reaches the cartridge. The scan decides WHICH position; it does not decide what a position does.
3439
- virtualController.press(action, source, 0);
3758
+ return true; // «cancel»: the press was taken, and means nothing
3759
+ // 📌 THROUGH THE VIRTUAL CONTROLLER, like every other transport (ADR-0111): in play it holds the child's key and reaches the
3760
+ // cartridge. The scan decides WHICH position; it does not decide what a position does.
3761
+ scanIsPressing = true;
3762
+ try {
3763
+ virtualController.press(action, source, 0);
3764
+ }
3765
+ finally {
3766
+ scanIsPressing = false;
3767
+ }
3440
3768
  win.setTimeout(() => virtualController.release(action, source, 0), SWITCH_SCAN_DEFAULTS.pulseMs);
3769
+ return true;
3441
3770
  };
3442
3771
  stateOn('switchScan', (on) => { if (on)
3443
3772
  startScan();
@@ -3446,13 +3775,43 @@ unstyled, so a child who plays by keyboard cannot see where focus is — link th
3446
3775
  whenDisposed(stopScan); // the scan's frames are this root's, and an ended root keeps none running (ADR-0220)
3447
3776
  if (state.switchScan)
3448
3777
  startScan();
3778
+ /*
3779
+ * 🔴 THE DEVICE IN USE AND ITS LATCH, AT ONE DOOR (ADR-0109 rule 3, ADR-0113, ADR-0249). Every transport that plays presses
3780
+ * this controller — the keyboard conductor below (`teclado`), the on-screen pad (`toque`), the gamepad, the eyes, the face,
3781
+ * the hands, the voice — so its `pressedBy` is where the root hears «the child is using this device now». Each press is that
3782
+ * device's edge: the seat's transport automaton moves to it, and the seat's latch is resolved again for it — the child's
3783
+ * stored choice for that transport, else its default: the MOUNTED game's `holdsKeys()` read now on the four one-command
3784
+ * transports (a platform game keeps walking on «direita», a quiz's «abaixo» presses once), the factory's on the keyboard, the
3785
+ * pad and touch. So a key after a spoken word brings back the keyboard's latch, and the ☝️ writes under the keyboard.
3786
+ * ⚠️ EVERY TRANSPORT, not one hook per transport: a transport this door did not hear would leave the automaton on the device
3787
+ * the child put down, and play would go on under that device's latch — in silence. The seats are the ones the bar's ☝️
3788
+ * writes (`PausePlayer`, the same cast).
3789
+ */
3790
+ const transportEdge = createLatchedEdge(() => players(), {
3791
+ input, store, holdsKeys: () => cartridge.declaration.holdsKeys(),
3792
+ });
3449
3793
  const virtualController = createVirtualController({
3794
+ // told first, for every press that names its transport: the edge and the latch of the device that pressed
3795
+ pressedBy: (source, seat) => { transportEdge(seat, source); },
3450
3796
  scheme: (i) => keyboard.kbFor(i), menuOpen: menuWithDpad,
3451
- // ⚠️ `markKeyFrom` AND NOT RAW `markKey`: a key that arrives WITHOUT a source — which is every real keyboard event —
3452
- // must ERASE whoever held it last instead of inheriting them (ADR-0109). The choice between the two doors lives in
3453
- // `input/state`.
3797
+ // ⚠️ `markKeyFrom` AND NOT RAW `markKey`: a key that arrives WITHOUT a source — a synthetic key nobody stamped — must ERASE
3798
+ // whoever held it last instead of inheriting them (ADR-0109). The choice between the two doors lives in `input/state`.
3454
3799
  holdKey: markKeyFrom,
3455
3800
  releaseKey: releaseKey, menuKey: keyToMenu, deliver: deliverCommand,
3801
+ // With a menu open the game hears no press, so the engine answers the sonar there: R1 reads the menu in front, spoken or,
3802
+ // in deaf mode, captioned and signed — the same two doors the play sonar's words take (ADR-0234). Any seat's R1 reads it,
3803
+ // though only the first steers the menu (ADR-0144), and a reading takes turns with the other seats' (ADR-0234 errata).
3804
+ menuAnswers: (action, seat) => menuSonarPress(action, screen, (text) => { srSay(text); deafMode.sonar(text, seat); }),
3805
+ // START and SELECT from a transport with no key in the world — the eyes, the face, the hands, the voice, the scan — do what
3806
+ // the keys do, for the seat that pressed (ADR-0144 §1, ADR-0155): in play and in the quick pause alike, so START there
3807
+ // always LEAVES, whatever key the child bound first to `start` (ADR-0144 erratum of 2026-09-26).
3808
+ systemPress: (action, seat) => { if (action === 'start')
3809
+ startForSeat(seat);
3810
+ else
3811
+ openSeatMenus(seat); },
3812
+ // 🔴 WITH ONE BUTTON ONLY ON, EVERY PRESS HERE IS THE SWITCH (ADR-0218 §4): the eyes, the face, the hands, the voice, the
3813
+ // on-screen pad and the gamepad press this controller, so asking here is asking once for all of them.
3814
+ takeShown: (source) => !!scanPress?.(source),
3456
3815
  });
3457
3816
  /*
3458
3817
  * 🔴 THE KEYBOARD CONDUCTOR, AND ONLY THAT (ADR-0223). It resolves the action and PRESSES the virtual controller, like the
@@ -3466,6 +3825,11 @@ unstyled, so a child who plays by keyboard cannot see where focus is — link th
3466
3825
  *
3467
3826
  * 📌 And it does not ask whether a menu is open. That question has ONE answer, the controller's; what makes it true
3468
3827
  * for the keyboard is `keyToMenu` above, which does not redispatch a key that is already in the world.
3828
+ *
3829
+ * 🔴 ONE KEY PRESS IS ONE ACTION (ADR-0111 errata of 2026-09-26), from both sides. A key the game HEARD is spent: its browser
3830
+ * default is cancelled, or Space on a focused button was the game's `action2` AND the button's click. A key that went to
3831
+ * something else — a field being typed into, the engine's own control it activates — is not also played: `input/key-default`
3832
+ * says which, and the controller holds it back from play (with a menu open it is the menu's either way).
3469
3833
  */
3470
3834
  for (const kind of ['keydown', 'keyup']) {
3471
3835
  win.addEventListener(kind, (e) => {
@@ -3480,12 +3844,23 @@ unstyled, so a child who plays by keyboard cannot see where focus is — link th
3480
3844
  if (seat < 0)
3481
3845
  return;
3482
3846
  const action = keyboard.actionOf(e.code, seat);
3483
- if (!action)
3847
+ // ⚠️ START AND SELECT ARE NOT PRESSED HERE: their key is answered by the engine's own listeners above, which run for a
3848
+ // cartridge with no `onCommand` too — pressed here as well, the controller would open the pause a second time (ADR-0144).
3849
+ if (!action || action === 'start' || action === 'select')
3484
3850
  return;
3485
- if (kind === 'keydown')
3486
- virtualController.press(action, source, seat);
3487
- else
3851
+ if (kind === 'keyup') {
3488
3852
  virtualController.release(action, source, seat);
3853
+ return;
3854
+ }
3855
+ // 🔴 A KEY THE MENUS CONSUMED IS NOT ALSO PLAYED (ADR-0111 erratum of 2026-09-26). `nav.attach()` put `menuNavKey` on this
3856
+ // window's capture first, so it has already decided — and a key that CLOSED the last menu (resume, «no» at the card's
3857
+ // root, a panel's «Voltar») leaves the controller no menu to see: 📏 Space on resume resumed AND answered the quiz.
3858
+ // 📌 And the key that ends print mode is print's: its listener runs after this one, so it is asked by its state (see there).
3859
+ if (nav.consumed(e) || printArmed)
3860
+ return;
3861
+ const toPlay = keyGoesToGame(e.code, e.target, ENGINE_CONTROLS_IN_PLAY);
3862
+ if (virtualController.press(action, source, seat, toPlay))
3863
+ e.preventDefault();
3489
3864
  }, true);
3490
3865
  }
3491
3866
  /*
@@ -3521,17 +3896,20 @@ unstyled, so a child who plays by keyboard cannot see where focus is — link th
3521
3896
  getGamepads: () => win.navigator?.getGamepads?.() ?? [],
3522
3897
  // THE GAME'S WORD for a position: the engine knows the position exists, only the cartridge knows what it is called —
3523
3898
  // and it already declared that in the `preset` to exist.
3524
- actionLabel: (action) => (cartridge.preset ? labellerFrom(cartridge.preset)(action) : null),
3899
+ actionLabel: (action) => labellerFrom(actionWords())(action),
3525
3900
  srSay, srAlert,
3526
3901
  frontOverlay: overlays.frontOverlay,
3527
3902
  // ⚠️ «PAUSE MENU» HERE IS EVERY MENU WITH A DIRECTIONAL, not only the card: the transport's `steerPause` already
3528
3903
  // handles the shared dialog before the card, which is the panel open on top. The same question the controller asks.
3529
3904
  pauseMenu: menuWithDpad,
3530
3905
  worldRunning: gameHooks.worldRunning,
3531
- // The gamepad's START is the QUICK PAUSE (ADR-0155), like the screen's; and the way out reuses the decision already
3532
- // written for the finger, which knows leaving the quick pause from closing the card.
3906
+ // The pad's START in play is PRESSED for its seat and answered by `systemPress` (ADR-0144 §1); this `pause` is the mapping
3907
+ // wizard's, which stops the game while a pad is mapped. The way out on the card and on seat 0's quick pause reuses the
3908
+ // decision already written for the finger, which knows leaving the quick pause from closing the card — for the screen's
3909
+ // first seat only: another seat's START there does nothing (ADR-0144, erratum of 2026-09-26).
3533
3910
  pause: () => { enterQuickPause(0); },
3534
- resume: togglePauseByTouch,
3911
+ resume: (seat) => { if (leadsTheScreen(seat))
3912
+ togglePauseByTouch(); },
3535
3913
  isAttractActive: gameHooks.attractActive,
3536
3914
  stopAttract: gameHooks.stopAttract,
3537
3915
  // A PHYSICAL button makes the on-screen pad vanish — the same per-modality switch as the keyboard.
@@ -3548,12 +3926,16 @@ unstyled, so a child who plays by keyboard cannot see where focus is — link th
3548
3926
  navBar,
3549
3927
  sharedDialogOpen: nav.sharedDialogOpen,
3550
3928
  navDialog: nav.navDialog,
3929
+ leadsTheScreen, // another seat's pad steers none of this screen's menus, as its keys do not
3551
3930
  getPauseMenu: (i) => $(`#vp-pause-${i}`),
3552
3931
  navPause: nav.navPause,
3553
3932
  setPauseActor,
3933
+ // the RAW edge: every pad press already reached the controller's `pressedBy`, which recorded it and resolved the pad's latch
3554
3934
  playerEdge,
3555
3935
  press: (action, source, player) => virtualController.press(action, source, player),
3556
3936
  release: (action, source, player) => virtualController.release(action, source, player),
3937
+ // the pad also steers menus and the pause without pressing a position, so it asks the scan's question itself (ADR-0218 §4)
3938
+ takeShown: () => !!scanPress?.('gamepad'),
3557
3939
  modalInput: gameHooks.modalInput,
3558
3940
  hasModal: gameHooks.hasModal,
3559
3941
  joinPlayer: gameHooks.joinPlayer,
@@ -3561,6 +3943,9 @@ unstyled, so a child who plays by keyboard cannot see where focus is — link th
3561
3943
  clearWaitingBadge: gameHooks.clearWaitingBadge,
3562
3944
  wizardStep: gameHooks.wizardStep,
3563
3945
  wizardTick: gameHooks.wizardTick,
3946
+ // the root's answer, not a game's: its own wizard closed however it ended, and the focus goes back to whoever held it
3947
+ // when that wizard opened — the same return the panel's wizard makes (ADR-0248)
3948
+ wizardClosed: () => { overlays.restoreFocus?.('padwiz'); },
3564
3949
  });
3565
3950
  /*
3566
3951
  * AND THE ENGINE POLLS, because whoever mounts polls. ⚠️ The game loop is the CARTRIDGE's (`core/loop.startLoop` is
@@ -3569,7 +3954,10 @@ unstyled, so a child who plays by keyboard cannot see where focus is — link th
3569
3954
  */
3570
3955
  let padFrameHandle = 0;
3571
3956
  const anyPadConnected = () => (win.navigator?.getGamepads?.() ?? []).some(Boolean);
3572
- const pollPad = () => { gamepad.pollPads(); padFrameHandle = win.requestAnimationFrame(pollPad); };
3957
+ // 📌 While the motor panel's wizard maps, the pads talk only to it (the transport asks only about its own wizard): read
3958
+ // under it, an unmapped DirectInput pad opened a second wizard on the same pad, and a standard pad steered the menus.
3959
+ const pollPad = () => { if (!padWizard?.state())
3960
+ gamepad.pollPads(); padFrameHandle = win.requestAnimationFrame(pollPad); };
3573
3961
  const stopPollingPad = () => { if (padFrameHandle)
3574
3962
  win.cancelAnimationFrame(padFrameHandle); padFrameHandle = 0; };
3575
3963
  /*
@@ -3616,37 +4004,37 @@ unstyled, so a child who plays by keyboard cannot see where focus is — link th
3616
4004
  * PLAYING BY SPEAKING (ADR-0189, ADR-0193, ADR-0194; issue #184): the stored 👄 drives `ui/voice-control` — the recogniser
3617
4005
  * from the delivery, the microphone that stays open, and presses stamped `fala` on the virtual controller.
3618
4006
  *
3619
- * 📌 THE GRAMMAR FOLLOWS THE OPEN MENU: the names the child can see are the names she can say. They are read from the overlay
3620
- * on top, which is the same one the focus trap and the menu navigation already treat as «the menu that is open».
4007
+ * 📌 THE GRAMMAR FOLLOWS THE OPEN MENU: the names the child can see are the names she can say, and a name heard activates
4008
+ * its item (ADR-0194 §2). Both are asked of the MENU NAVIGATION for seat 0 — the seat the voice presses — so the names are
4009
+ * those of the menu a confirm would reach (the panel on top, else the pause card), and saying one puts that navigation's
4010
+ * cursor on it before the voice confirms through the virtual controller. The grammar is re-read when a card, a list of it or
4011
+ * a panel shows or hides — the region's observer above.
3621
4012
  */
3622
4013
  if (canCaptureMedia) {
3623
- const menuWords = () => {
3624
- const card = overlays.topVisibleOverlay();
3625
- if (!card)
3626
- return [];
3627
- return [...card.querySelectorAll('button, [data-passos]')]
3628
- .filter((el) => !el.hidden && el.getAttribute('aria-disabled') !== 'true')
3629
- .map((el) => accessibleLabel(el))
3630
- .filter((s) => s.length > 1);
3631
- };
3632
4014
  voiceControl = createVoiceControl({
3633
- t: translator.t, base: doc.baseURI, language: () => bcp47(), controller: virtualController, menuWords,
4015
+ t: translator.t, base: doc.baseURI, language: () => bcp47(), controller: virtualController,
4016
+ menuWords: () => nav.itemNames(0), pointAt: (name) => nav.pointAt(name, 0),
4017
+ // with one button only on, a word heard is the switch and nothing else: a name is not pointed at (ADR-0218 §4)
4018
+ oneButtonOnly: () => state.switchScan,
3634
4019
  say: srSay, alert: srAlert,
3635
4020
  report: (line) => { if (!measuredProblems.includes(line))
3636
4021
  measuredProblems.push(line); },
3637
4022
  turnOff: () => { state.setVoiceControlValue(false); },
3638
4023
  after: (fn, ms) => { win.setTimeout(fn, ms); },
3639
4024
  // ⚠️ THE ADDRESS IS ABSOLUTE AND THE BUNDLER MUST NOT FOLLOW IT: the recogniser arrives with the delivery at runtime.
3640
- hasFile: hasHeavyFile, loadBundle: createBundleLoader((url) => import(/* @vite-ignore */ url)),
4025
+ hasFile: hasHeavyFile, loadBundle: voskBundle, loadRuntime: voskModels.load,
4026
+ // the model's vocabulary is read from its archive, so a menu name the model cannot hear is a line of `problems` (ADR-0194 §4)
4027
+ fetch: (url) => win.fetch(url),
3641
4028
  getUserMedia, createContext: () => new HostAudioContext(),
3642
4029
  });
3643
4030
  stateOn('voiceControl', (on) => { void voiceControl?.apply(on); });
3644
- // the words change with the menu that is open, and a menu opens on a key or a touch — so they are re-read on every draw of
3645
- // the bar, which is what already happens whenever a card or a panel appears (ADR-0106 §5)
3646
- stateOn('menuIndexOn', () => { voiceControl?.refreshGrammar(); });
3647
- void voiceControl.apply(state.voiceControl);
3648
- // and so does the microphone; pply(false) stops without writing the stored answer, which belongs to the child (ADR-0220)
3649
- whenDisposed(() => { void voiceControl?.apply(false); });
4031
+ // 📌 A STORED 👄 STARTS ONCE THE STORED LANGUAGE HAS APPLIED: started before, it opened the boot language's model and then the
4032
+ // child's — two workers and ~40 MiB unpacked for nothing on every English or Spanish boot (measured on 2026-09-27)
4033
+ let voiceEnded = false;
4034
+ void translator.ready().then(() => { if (!voiceEnded)
4035
+ void voiceControl?.apply(state.voiceControl); }, () => { });
4036
+ // and so does the microphone; `apply(false)` stops without writing the stored answer, which belongs to the child (ADR-0220)
4037
+ whenDisposed(() => { voiceEnded = true; void voiceControl?.apply(false); });
3650
4038
  }
3651
4039
  /*
3652
4040
  /*
@@ -3667,8 +4055,13 @@ unstyled, so a child who plays by keyboard cannot see where focus is — link th
3667
4055
  closeReadingThread();
3668
4056
  followCartridgeMappings(null);
3669
4057
  removeReachNotice();
4058
+ // the released cartridge's explanation goes with it: the next one has not explained anything yet (ADR-0244)
4059
+ explainForGame(null);
3670
4060
  hudMounted?.remove();
3671
4061
  hudMounted = null;
4062
+ // the map was the released cartridge's; the next one mounts its own (ADR-0239)
4063
+ while (hudRow?.map.firstChild)
4064
+ hudRow.map.removeChild(hudRow.map.firstChild);
3672
4065
  reserveBarBand();
3673
4066
  // ⚠️ `pop()` AND NOT A `clear()`: each `exit()` is that scene's DOM cleanup, and skipping it would leave on the page
3674
4067
  // what the previous cartridge drew. The loop ends because `pop()` returns `null` on an empty stack.
@@ -3693,15 +4086,19 @@ unstyled, so a child who plays by keyboard cannot see where focus is — link th
3693
4086
  tts,
3694
4087
  reading,
3695
4088
  captionSound: writeSoundCaption,
4089
+ explain: explainForGame,
3696
4090
  gameSpeed: () => state.gameSpeed,
3697
4091
  menuIndexOn: () => state.menuIndexOn,
3698
4092
  t: translator.t,
3699
4093
  localeReady: translator.ready,
4094
+ locale: translator.locale,
4095
+ setLocale: translator.setLocale,
3700
4096
  say: srSay,
3701
4097
  alert: srAlert,
3702
4098
  mirrorAnnouncements: announcer.mirrorTo,
3703
- libras: { isOpen: libras.isOpen, say: libras.say, tick: libras.tick, toggle: () => { libras.toggle(translator.t); } },
4099
+ deafMode: { isOn: deafMode.isOn, toggle: deafMode.toggle, captionsOn: deafMode.captionsOn },
3704
4100
  settings: state,
4101
+ mapSlot: hudRow?.map ?? null,
3705
4102
  input,
3706
4103
  keyboardConfig,
3707
4104
  audio: mixer,
@@ -3717,7 +4114,11 @@ unstyled, so a child who plays by keyboard cannot see where focus is — link th
3717
4114
  scenes: rootScenes,
3718
4115
  onLocaleChange: (fn) => { localeListeners.push(fn); },
3719
4116
  cvdFilters,
3720
- get problems() { return [...hostProblems, ...stylesheetMissing(), ...measureCartridgeProblems(), ...translator.dictionaryGaps(), ...measuredProblems, ...storageOutsideScope()]; },
4117
+ get problems() {
4118
+ return [...hostProblems, ...stylesheetMissing(), ...measureCartridgeProblems(), ...translator.dictionaryGaps(),
4119
+ ...missingDeclaredKeys(cartridge, translator.declares), ...measuredProblems, ...storageOutsideScope(),
4120
+ ...unreadableWorldProblems(screen)];
4121
+ },
3721
4122
  onFailure: announceFailure,
3722
4123
  get reach() { return currentReach; },
3723
4124
  };