@the-inclusionist/engine 9.0.0 → 10.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 (756) hide show
  1. package/README.md +89 -78
  2. package/app/css/style.css +584 -374
  3. package/app/public/vendor/fonts/bitter-var-ext.woff2 +0 -0
  4. package/app/public/vendor/fonts/bitter-var.woff2 +0 -0
  5. package/app/public/vendor/fonts/bodonimoda-var-ext.woff2 +0 -0
  6. package/app/public/vendor/fonts/bodonimoda-var.woff2 +0 -0
  7. package/app/public/vendor/fonts/cat-abril_fatface-400-ext.woff2 +0 -0
  8. package/app/public/vendor/fonts/cat-abril_fatface-400.woff2 +0 -0
  9. package/app/public/vendor/fonts/cat-adlam_display-400-ext.woff2 +0 -0
  10. package/app/public/vendor/fonts/cat-adlam_display-400.woff2 +0 -0
  11. package/app/public/vendor/fonts/cat-agu_display-400-ext.woff2 +0 -0
  12. package/app/public/vendor/fonts/cat-agu_display-400.woff2 +0 -0
  13. package/app/public/vendor/fonts/cat-alfa_slab_one-400-ext.woff2 +0 -0
  14. package/app/public/vendor/fonts/cat-alfa_slab_one-400.woff2 +0 -0
  15. package/app/public/vendor/fonts/cat-are_you_serious-400-ext.woff2 +0 -0
  16. package/app/public/vendor/fonts/cat-are_you_serious-400.woff2 +0 -0
  17. package/app/public/vendor/fonts/cat-asimovian-400-ext.woff2 +0 -0
  18. package/app/public/vendor/fonts/cat-asimovian-400.woff2 +0 -0
  19. package/app/public/vendor/fonts/cat-atkinson_hyperlegible_next-400-ext.woff2 +0 -0
  20. package/app/public/vendor/fonts/cat-atkinson_hyperlegible_next-400.woff2 +0 -0
  21. package/app/public/vendor/fonts/cat-bakbak_one-400-ext.woff2 +0 -0
  22. package/app/public/vendor/fonts/cat-bakbak_one-400.woff2 +0 -0
  23. package/app/public/vendor/fonts/cat-bangers-400-ext.woff2 +0 -0
  24. package/app/public/vendor/fonts/cat-bangers-400.woff2 +0 -0
  25. package/app/public/vendor/fonts/cat-barriecito-400-ext.woff2 +0 -0
  26. package/app/public/vendor/fonts/cat-barriecito-400.woff2 +0 -0
  27. package/app/public/vendor/fonts/cat-berkshire_swash-400-ext.woff2 +0 -0
  28. package/app/public/vendor/fonts/cat-berkshire_swash-400.woff2 +0 -0
  29. package/app/public/vendor/fonts/cat-bevan-400-ext.woff2 +0 -0
  30. package/app/public/vendor/fonts/cat-bevan-400.woff2 +0 -0
  31. package/app/public/vendor/fonts/cat-bitcount-400-ext.woff2 +0 -0
  32. package/app/public/vendor/fonts/cat-bitcount-400.woff2 +0 -0
  33. package/app/public/vendor/fonts/cat-bowlby_one_sc-400-ext.woff2 +0 -0
  34. package/app/public/vendor/fonts/cat-bowlby_one_sc-400.woff2 +0 -0
  35. package/app/public/vendor/fonts/cat-bungee_shade-400-ext.woff2 +0 -0
  36. package/app/public/vendor/fonts/cat-bungee_shade-400.woff2 +0 -0
  37. package/app/public/vendor/fonts/cat-charm-400-ext.woff2 +0 -0
  38. package/app/public/vendor/fonts/cat-charm-400.woff2 +0 -0
  39. package/app/public/vendor/fonts/cat-charm-700-ext.woff2 +0 -0
  40. package/app/public/vendor/fonts/cat-charm-700.woff2 +0 -0
  41. package/app/public/vendor/fonts/cat-cherry_bomb_one-400-ext.woff2 +0 -0
  42. package/app/public/vendor/fonts/cat-cherry_bomb_one-400.woff2 +0 -0
  43. package/app/public/vendor/fonts/cat-chokokutai-400-ext.woff2 +0 -0
  44. package/app/public/vendor/fonts/cat-chokokutai-400.woff2 +0 -0
  45. package/app/public/vendor/fonts/cat-chonburi-400-ext.woff2 +0 -0
  46. package/app/public/vendor/fonts/cat-chonburi-400.woff2 +0 -0
  47. package/app/public/vendor/fonts/cat-climate_crisis-400-ext.woff2 +0 -0
  48. package/app/public/vendor/fonts/cat-climate_crisis-400.woff2 +0 -0
  49. package/app/public/vendor/fonts/cat-cookie-400.woff2 +0 -0
  50. package/app/public/vendor/fonts/cat-dela_gothic_one-400-ext.woff2 +0 -0
  51. package/app/public/vendor/fonts/cat-dela_gothic_one-400.woff2 +0 -0
  52. package/app/public/vendor/fonts/cat-fascinate_inline-400-ext.woff2 +0 -0
  53. package/app/public/vendor/fonts/cat-fascinate_inline-400.woff2 +0 -0
  54. package/app/public/vendor/fonts/cat-ga_maamli-400-ext.woff2 +0 -0
  55. package/app/public/vendor/fonts/cat-ga_maamli-400.woff2 +0 -0
  56. package/app/public/vendor/fonts/cat-itim-400-ext.woff2 +0 -0
  57. package/app/public/vendor/fonts/cat-itim-400.woff2 +0 -0
  58. package/app/public/vendor/fonts/cat-jacquard_24-400-ext.woff2 +0 -0
  59. package/app/public/vendor/fonts/cat-jacquard_24-400.woff2 +0 -0
  60. package/app/public/vendor/fonts/cat-kablammo-400-ext.woff2 +0 -0
  61. package/app/public/vendor/fonts/cat-kablammo-400.woff2 +0 -0
  62. package/app/public/vendor/fonts/cat-kalam-400-ext.woff2 +0 -0
  63. package/app/public/vendor/fonts/cat-kalam-400.woff2 +0 -0
  64. package/app/public/vendor/fonts/cat-kalam-700-ext.woff2 +0 -0
  65. package/app/public/vendor/fonts/cat-kalam-700.woff2 +0 -0
  66. package/app/public/vendor/fonts/cat-katibeh-400-ext.woff2 +0 -0
  67. package/app/public/vendor/fonts/cat-katibeh-400.woff2 +0 -0
  68. package/app/public/vendor/fonts/cat-lemonada-400-ext.woff2 +0 -0
  69. package/app/public/vendor/fonts/cat-lemonada-400.woff2 +0 -0
  70. package/app/public/vendor/fonts/cat-lilita_one-400-ext.woff2 +0 -0
  71. package/app/public/vendor/fonts/cat-lilita_one-400.woff2 +0 -0
  72. package/app/public/vendor/fonts/cat-lobster-400-ext.woff2 +0 -0
  73. package/app/public/vendor/fonts/cat-lobster-400.woff2 +0 -0
  74. package/app/public/vendor/fonts/cat-luckiest_guy-400-ext.woff2 +0 -0
  75. package/app/public/vendor/fonts/cat-luckiest_guy-400.woff2 +0 -0
  76. package/app/public/vendor/fonts/cat-marhey-400-ext.woff2 +0 -0
  77. package/app/public/vendor/fonts/cat-marhey-400.woff2 +0 -0
  78. package/app/public/vendor/fonts/cat-modak-400-ext.woff2 +0 -0
  79. package/app/public/vendor/fonts/cat-modak-400.woff2 +0 -0
  80. package/app/public/vendor/fonts/cat-momo_signature-400-ext.woff2 +0 -0
  81. package/app/public/vendor/fonts/cat-momo_signature-400.woff2 +0 -0
  82. package/app/public/vendor/fonts/cat-monofett-400-ext.woff2 +0 -0
  83. package/app/public/vendor/fonts/cat-monofett-400.woff2 +0 -0
  84. package/app/public/vendor/fonts/cat-monomakh-400-ext.woff2 +0 -0
  85. package/app/public/vendor/fonts/cat-monomakh-400.woff2 +0 -0
  86. package/app/public/vendor/fonts/cat-monoton-400-ext.woff2 +0 -0
  87. package/app/public/vendor/fonts/cat-monoton-400.woff2 +0 -0
  88. package/app/public/vendor/fonts/cat-nerko_one-400-ext.woff2 +0 -0
  89. package/app/public/vendor/fonts/cat-nerko_one-400.woff2 +0 -0
  90. package/app/public/vendor/fonts/cat-noto_sans_adlam-400-ext.woff2 +0 -0
  91. package/app/public/vendor/fonts/cat-noto_sans_adlam-400.woff2 +0 -0
  92. package/app/public/vendor/fonts/cat-noto_sans_arabic-400-ext.woff2 +0 -0
  93. package/app/public/vendor/fonts/cat-noto_sans_arabic-400.woff2 +0 -0
  94. package/app/public/vendor/fonts/cat-noto_sans_devanagari-400-ext.woff2 +0 -0
  95. package/app/public/vendor/fonts/cat-noto_sans_devanagari-400.woff2 +0 -0
  96. package/app/public/vendor/fonts/cat-noto_sans_hebrew-400-ext.woff2 +0 -0
  97. package/app/public/vendor/fonts/cat-noto_sans_hebrew-400.woff2 +0 -0
  98. package/app/public/vendor/fonts/cat-noto_sans_jp-400-ext.woff2 +0 -0
  99. package/app/public/vendor/fonts/cat-noto_sans_jp-400.woff2 +0 -0
  100. package/app/public/vendor/fonts/cat-noto_sans_khmer-400-ext.woff2 +0 -0
  101. package/app/public/vendor/fonts/cat-noto_sans_khmer-400.woff2 +0 -0
  102. package/app/public/vendor/fonts/cat-noto_sans_kr-400-ext.woff2 +0 -0
  103. package/app/public/vendor/fonts/cat-noto_sans_kr-400.woff2 +0 -0
  104. package/app/public/vendor/fonts/cat-noto_sans_sc-400-ext.woff2 +0 -0
  105. package/app/public/vendor/fonts/cat-noto_sans_sc-400.woff2 +0 -0
  106. package/app/public/vendor/fonts/cat-noto_sans_thai-400-ext.woff2 +0 -0
  107. package/app/public/vendor/fonts/cat-noto_sans_thai-400.woff2 +0 -0
  108. package/app/public/vendor/fonts/cat-ole-400-ext.woff2 +0 -0
  109. package/app/public/vendor/fonts/cat-ole-400.woff2 +0 -0
  110. package/app/public/vendor/fonts/cat-pacifico-400-ext.woff2 +0 -0
  111. package/app/public/vendor/fonts/cat-pacifico-400.woff2 +0 -0
  112. package/app/public/vendor/fonts/cat-patrick_hand-400-ext.woff2 +0 -0
  113. package/app/public/vendor/fonts/cat-patrick_hand-400.woff2 +0 -0
  114. package/app/public/vendor/fonts/cat-petit_formal_script-400-ext.woff2 +0 -0
  115. package/app/public/vendor/fonts/cat-petit_formal_script-400.woff2 +0 -0
  116. package/app/public/vendor/fonts/cat-pixelify_sans-400-ext.woff2 +0 -0
  117. package/app/public/vendor/fonts/cat-pixelify_sans-400.woff2 +0 -0
  118. package/app/public/vendor/fonts/cat-plaster-400-ext.woff2 +0 -0
  119. package/app/public/vendor/fonts/cat-plaster-400.woff2 +0 -0
  120. package/app/public/vendor/fonts/cat-playwrite_ar_guides-400-0.woff2 +0 -0
  121. package/app/public/vendor/fonts/cat-playwrite_at-400-0.woff2 +0 -0
  122. package/app/public/vendor/fonts/cat-playwrite_at_guides-400-0.woff2 +0 -0
  123. package/app/public/vendor/fonts/cat-playwrite_au_nsw-400-0.woff2 +0 -0
  124. package/app/public/vendor/fonts/cat-playwrite_au_nsw_guides-400-0.woff2 +0 -0
  125. package/app/public/vendor/fonts/cat-playwrite_au_qld-400-0.woff2 +0 -0
  126. package/app/public/vendor/fonts/cat-playwrite_au_qld_guides-400-0.woff2 +0 -0
  127. package/app/public/vendor/fonts/cat-playwrite_au_sa-400-0.woff2 +0 -0
  128. package/app/public/vendor/fonts/cat-playwrite_au_sa_guides-400-0.woff2 +0 -0
  129. package/app/public/vendor/fonts/cat-playwrite_au_tas-400-0.woff2 +0 -0
  130. package/app/public/vendor/fonts/cat-playwrite_au_tas_guides-400-0.woff2 +0 -0
  131. package/app/public/vendor/fonts/cat-playwrite_au_vic-400-0.woff2 +0 -0
  132. package/app/public/vendor/fonts/cat-playwrite_au_vic_guides-400-0.woff2 +0 -0
  133. package/app/public/vendor/fonts/cat-playwrite_be_vlg-400-0.woff2 +0 -0
  134. package/app/public/vendor/fonts/cat-playwrite_be_vlg_guides-400-0.woff2 +0 -0
  135. package/app/public/vendor/fonts/cat-playwrite_be_wal-400-0.woff2 +0 -0
  136. package/app/public/vendor/fonts/cat-playwrite_be_wal_guides-400-0.woff2 +0 -0
  137. package/app/public/vendor/fonts/cat-playwrite_br_guides-400-0.woff2 +0 -0
  138. package/app/public/vendor/fonts/cat-playwrite_ca_guides-400-0.woff2 +0 -0
  139. package/app/public/vendor/fonts/cat-playwrite_cl_guides-400-0.woff2 +0 -0
  140. package/app/public/vendor/fonts/cat-playwrite_co_guides-400-0.woff2 +0 -0
  141. package/app/public/vendor/fonts/cat-playwrite_cu_guides-400-0.woff2 +0 -0
  142. package/app/public/vendor/fonts/cat-playwrite_cz-400-0.woff2 +0 -0
  143. package/app/public/vendor/fonts/cat-playwrite_cz_guides-400-0.woff2 +0 -0
  144. package/app/public/vendor/fonts/cat-playwrite_de_grund-400-0.woff2 +0 -0
  145. package/app/public/vendor/fonts/cat-playwrite_de_grund_guides-400-0.woff2 +0 -0
  146. package/app/public/vendor/fonts/cat-playwrite_de_la-400-0.woff2 +0 -0
  147. package/app/public/vendor/fonts/cat-playwrite_de_la_guides-400-0.woff2 +0 -0
  148. package/app/public/vendor/fonts/cat-playwrite_de_sas-400-0.woff2 +0 -0
  149. package/app/public/vendor/fonts/cat-playwrite_de_sas_guides-400-0.woff2 +0 -0
  150. package/app/public/vendor/fonts/cat-playwrite_de_va-400-0.woff2 +0 -0
  151. package/app/public/vendor/fonts/cat-playwrite_de_va_guides-400-0.woff2 +0 -0
  152. package/app/public/vendor/fonts/cat-playwrite_dk_loopet-400-0.woff2 +0 -0
  153. package/app/public/vendor/fonts/cat-playwrite_dk_loopet_guides-400-0.woff2 +0 -0
  154. package/app/public/vendor/fonts/cat-playwrite_dk_uloopet-400-0.woff2 +0 -0
  155. package/app/public/vendor/fonts/cat-playwrite_dk_uloopet_guides-400-0.woff2 +0 -0
  156. package/app/public/vendor/fonts/cat-playwrite_es_deco_guides-400-0.woff2 +0 -0
  157. package/app/public/vendor/fonts/cat-playwrite_es_guides-400-0.woff2 +0 -0
  158. package/app/public/vendor/fonts/cat-playwrite_fr_moderne-400-0.woff2 +0 -0
  159. package/app/public/vendor/fonts/cat-playwrite_fr_moderne_guides-400-0.woff2 +0 -0
  160. package/app/public/vendor/fonts/cat-playwrite_fr_trad-400-0.woff2 +0 -0
  161. package/app/public/vendor/fonts/cat-playwrite_fr_trad_guides-400-0.woff2 +0 -0
  162. package/app/public/vendor/fonts/cat-playwrite_gb_j_guides-400-0.woff2 +0 -0
  163. package/app/public/vendor/fonts/cat-playwrite_gb_s_guides-400-0.woff2 +0 -0
  164. package/app/public/vendor/fonts/cat-playwrite_hr-400-0.woff2 +0 -0
  165. package/app/public/vendor/fonts/cat-playwrite_hr_guides-400-0.woff2 +0 -0
  166. package/app/public/vendor/fonts/cat-playwrite_hr_lijeva-400-0.woff2 +0 -0
  167. package/app/public/vendor/fonts/cat-playwrite_hr_lijeva_guides-400-0.woff2 +0 -0
  168. package/app/public/vendor/fonts/cat-playwrite_hu-400-0.woff2 +0 -0
  169. package/app/public/vendor/fonts/cat-playwrite_hu_guides-400-0.woff2 +0 -0
  170. package/app/public/vendor/fonts/cat-playwrite_id-400-0.woff2 +0 -0
  171. package/app/public/vendor/fonts/cat-playwrite_id_guides-400-0.woff2 +0 -0
  172. package/app/public/vendor/fonts/cat-playwrite_ie-400-0.woff2 +0 -0
  173. package/app/public/vendor/fonts/cat-playwrite_ie_guides-400-0.woff2 +0 -0
  174. package/app/public/vendor/fonts/cat-playwrite_in-400-0.woff2 +0 -0
  175. package/app/public/vendor/fonts/cat-playwrite_in_guides-400-0.woff2 +0 -0
  176. package/app/public/vendor/fonts/cat-playwrite_is-400-0.woff2 +0 -0
  177. package/app/public/vendor/fonts/cat-playwrite_is_guides-400-0.woff2 +0 -0
  178. package/app/public/vendor/fonts/cat-playwrite_it_moderna-400-0.woff2 +0 -0
  179. package/app/public/vendor/fonts/cat-playwrite_it_moderna_guides-400-0.woff2 +0 -0
  180. package/app/public/vendor/fonts/cat-playwrite_it_trad-400-0.woff2 +0 -0
  181. package/app/public/vendor/fonts/cat-playwrite_it_trad_guides-400-0.woff2 +0 -0
  182. package/app/public/vendor/fonts/cat-playwrite_mx_guides-400-0.woff2 +0 -0
  183. package/app/public/vendor/fonts/cat-playwrite_ng_modern-400-0.woff2 +0 -0
  184. package/app/public/vendor/fonts/cat-playwrite_ng_modern_guides-400-0.woff2 +0 -0
  185. package/app/public/vendor/fonts/cat-playwrite_nl-400-0.woff2 +0 -0
  186. package/app/public/vendor/fonts/cat-playwrite_nl_guides-400-0.woff2 +0 -0
  187. package/app/public/vendor/fonts/cat-playwrite_no-400-0.woff2 +0 -0
  188. package/app/public/vendor/fonts/cat-playwrite_no_guides-400-0.woff2 +0 -0
  189. package/app/public/vendor/fonts/cat-playwrite_nz-400-0.woff2 +0 -0
  190. package/app/public/vendor/fonts/cat-playwrite_nz_basic-400-0.woff2 +0 -0
  191. package/app/public/vendor/fonts/cat-playwrite_nz_basic_guides-400-0.woff2 +0 -0
  192. package/app/public/vendor/fonts/cat-playwrite_nz_guides-400-0.woff2 +0 -0
  193. package/app/public/vendor/fonts/cat-playwrite_pe_guides-400-0.woff2 +0 -0
  194. package/app/public/vendor/fonts/cat-playwrite_pl-400-0.woff2 +0 -0
  195. package/app/public/vendor/fonts/cat-playwrite_pl_guides-400-0.woff2 +0 -0
  196. package/app/public/vendor/fonts/cat-playwrite_pt_guides-400-0.woff2 +0 -0
  197. package/app/public/vendor/fonts/cat-playwrite_ro-400-0.woff2 +0 -0
  198. package/app/public/vendor/fonts/cat-playwrite_ro_guides-400-0.woff2 +0 -0
  199. package/app/public/vendor/fonts/cat-playwrite_sk-400-0.woff2 +0 -0
  200. package/app/public/vendor/fonts/cat-playwrite_sk_guides-400-0.woff2 +0 -0
  201. package/app/public/vendor/fonts/cat-playwrite_tz-400-0.woff2 +0 -0
  202. package/app/public/vendor/fonts/cat-playwrite_tz_guides-400-0.woff2 +0 -0
  203. package/app/public/vendor/fonts/cat-playwrite_us_modern_guides-400-0.woff2 +0 -0
  204. package/app/public/vendor/fonts/cat-playwrite_us_trad_guides-400-0.woff2 +0 -0
  205. package/app/public/vendor/fonts/cat-playwrite_vn-400-0.woff2 +0 -0
  206. package/app/public/vendor/fonts/cat-playwrite_vn_guides-400-0.woff2 +0 -0
  207. package/app/public/vendor/fonts/cat-playwrite_za-400-0.woff2 +0 -0
  208. package/app/public/vendor/fonts/cat-playwrite_za_guides-400-0.woff2 +0 -0
  209. package/app/public/vendor/fonts/cat-potta_one-400-ext.woff2 +0 -0
  210. package/app/public/vendor/fonts/cat-potta_one-400.woff2 +0 -0
  211. package/app/public/vendor/fonts/cat-ranchers-400-ext.woff2 +0 -0
  212. package/app/public/vendor/fonts/cat-ranchers-400.woff2 +0 -0
  213. package/app/public/vendor/fonts/cat-ranga-400-ext.woff2 +0 -0
  214. package/app/public/vendor/fonts/cat-ranga-400.woff2 +0 -0
  215. package/app/public/vendor/fonts/cat-ranga-700-ext.woff2 +0 -0
  216. package/app/public/vendor/fonts/cat-ranga-700.woff2 +0 -0
  217. package/app/public/vendor/fonts/cat-risque-400-ext.woff2 +0 -0
  218. package/app/public/vendor/fonts/cat-risque-400.woff2 +0 -0
  219. package/app/public/vendor/fonts/cat-rowdies-400-ext.woff2 +0 -0
  220. package/app/public/vendor/fonts/cat-rowdies-400.woff2 +0 -0
  221. package/app/public/vendor/fonts/cat-rowdies-700-ext.woff2 +0 -0
  222. package/app/public/vendor/fonts/cat-rowdies-700.woff2 +0 -0
  223. package/app/public/vendor/fonts/cat-ruge_boogie-400-ext.woff2 +0 -0
  224. package/app/public/vendor/fonts/cat-ruge_boogie-400.woff2 +0 -0
  225. package/app/public/vendor/fonts/cat-ruslan_display-400-ext.woff2 +0 -0
  226. package/app/public/vendor/fonts/cat-ruslan_display-400.woff2 +0 -0
  227. package/app/public/vendor/fonts/cat-sacramento-400-ext.woff2 +0 -0
  228. package/app/public/vendor/fonts/cat-sacramento-400.woff2 +0 -0
  229. package/app/public/vendor/fonts/cat-sancreek-400-ext.woff2 +0 -0
  230. package/app/public/vendor/fonts/cat-sancreek-400.woff2 +0 -0
  231. package/app/public/vendor/fonts/cat-sankofa_display-400-ext.woff2 +0 -0
  232. package/app/public/vendor/fonts/cat-sankofa_display-400.woff2 +0 -0
  233. package/app/public/vendor/fonts/cat-sedgwick_ave_display-400-ext.woff2 +0 -0
  234. package/app/public/vendor/fonts/cat-sedgwick_ave_display-400.woff2 +0 -0
  235. package/app/public/vendor/fonts/cat-shantell_sans-400-ext.woff2 +0 -0
  236. package/app/public/vendor/fonts/cat-shantell_sans-400.woff2 +0 -0
  237. package/app/public/vendor/fonts/cat-smokum-400-ext.woff2 +0 -0
  238. package/app/public/vendor/fonts/cat-smokum-400.woff2 +0 -0
  239. package/app/public/vendor/fonts/cat-solitreo-400-ext.woff2 +0 -0
  240. package/app/public/vendor/fonts/cat-solitreo-400.woff2 +0 -0
  241. package/app/public/vendor/fonts/cat-spicy_rice-400-ext.woff2 +0 -0
  242. package/app/public/vendor/fonts/cat-spicy_rice-400.woff2 +0 -0
  243. package/app/public/vendor/fonts/cat-sriracha-400-ext.woff2 +0 -0
  244. package/app/public/vendor/fonts/cat-sriracha-400.woff2 +0 -0
  245. package/app/public/vendor/fonts/cat-srisakdi-400-ext.woff2 +0 -0
  246. package/app/public/vendor/fonts/cat-srisakdi-400.woff2 +0 -0
  247. package/app/public/vendor/fonts/cat-srisakdi-700-ext.woff2 +0 -0
  248. package/app/public/vendor/fonts/cat-srisakdi-700.woff2 +0 -0
  249. package/app/public/vendor/fonts/cat-stix_two_math-400-0.woff2 +0 -0
  250. package/app/public/vendor/fonts/cat-tillana-400-ext.woff2 +0 -0
  251. package/app/public/vendor/fonts/cat-tillana-400.woff2 +0 -0
  252. package/app/public/vendor/fonts/cat-tillana-700-ext.woff2 +0 -0
  253. package/app/public/vendor/fonts/cat-tillana-700.woff2 +0 -0
  254. package/app/public/vendor/fonts/cat-titan_one-400-ext.woff2 +0 -0
  255. package/app/public/vendor/fonts/cat-titan_one-400.woff2 +0 -0
  256. package/app/public/vendor/fonts/cat-yatra_one-400-ext.woff2 +0 -0
  257. package/app/public/vendor/fonts/cat-yatra_one-400.woff2 +0 -0
  258. package/app/public/vendor/fonts/cat-yomogi-400-ext.woff2 +0 -0
  259. package/app/public/vendor/fonts/cat-yomogi-400.woff2 +0 -0
  260. package/app/public/vendor/fonts/dmserifdisplay-400-ext.woff2 +0 -0
  261. package/app/public/vendor/fonts/dmserifdisplay-400.woff2 +0 -0
  262. package/app/public/vendor/fonts/domine-var-ext.woff2 +0 -0
  263. package/app/public/vendor/fonts/domine-var.woff2 +0 -0
  264. package/app/public/vendor/fonts/fraunces-var-ext.woff2 +0 -0
  265. package/app/public/vendor/fonts/fraunces-var.woff2 +0 -0
  266. package/app/public/vendor/fonts/fredoka-var-ext.woff2 +0 -0
  267. package/app/public/vendor/fonts/fredoka-var.woff2 +0 -0
  268. package/app/public/vendor/fonts/jakarta-var-ext.woff2 +0 -0
  269. package/app/public/vendor/fonts/jakarta-var.woff2 +0 -0
  270. package/app/public/vendor/fonts/lora-var-ext.woff2 +0 -0
  271. package/app/public/vendor/fonts/lora-var.woff2 +0 -0
  272. package/app/public/vendor/fonts/merriweather-var-ext.woff2 +0 -0
  273. package/app/public/vendor/fonts/merriweather-var.woff2 +0 -0
  274. package/app/public/vendor/fonts/notosans-var-ext.woff2 +0 -0
  275. package/app/public/vendor/fonts/notosans-var.woff2 +0 -0
  276. package/app/public/vendor/fonts/nunito-var-ext.woff2 +0 -0
  277. package/app/public/vendor/fonts/nunito-var.woff2 +0 -0
  278. package/app/public/vendor/fonts/playfair-var-ext.woff2 +0 -0
  279. package/app/public/vendor/fonts/playfair-var.woff2 +0 -0
  280. package/app/public/vendor/fonts/playwrite-cu.woff2 +0 -0
  281. package/app/public/vendor/fonts/playwrite-es-deco.woff2 +0 -0
  282. package/app/public/vendor/fonts/playwrite-es.woff2 +0 -0
  283. package/app/public/vendor/fonts/playwrite-gb-j.woff2 +0 -0
  284. package/app/public/vendor/fonts/playwrite-gb-s.woff2 +0 -0
  285. package/app/public/vendor/fonts/playwrite-pe.woff2 +0 -0
  286. package/app/public/vendor/fonts/playwrite-pt.woff2 +0 -0
  287. package/app/public/vendor/fonts/quicksand-var-ext.woff2 +0 -0
  288. package/app/public/vendor/fonts/quicksand-var.woff2 +0 -0
  289. package/app/public/vendor/fonts/robotoflex-var-ext.woff2 +0 -0
  290. package/app/public/vendor/fonts/robotoflex-var.woff2 +0 -0
  291. package/app/public/vendor/fonts/sora-var-ext.woff2 +0 -0
  292. package/app/public/vendor/fonts/sora-var.woff2 +0 -0
  293. package/app/public/vendor/fonts/spacegrotesk-var-ext.woff2 +0 -0
  294. package/app/public/vendor/fonts/spacegrotesk-var.woff2 +0 -0
  295. package/app/public/vendor/fonts/spectral-400-ext.woff2 +0 -0
  296. package/app/public/vendor/fonts/spectral-400.woff2 +0 -0
  297. package/app/public/vendor/fonts/spectral-700-ext.woff2 +0 -0
  298. package/app/public/vendor/fonts/spectral-700.woff2 +0 -0
  299. package/app/public/vendor/fonts/teachers-var-ext.woff2 +0 -0
  300. package/app/public/vendor/fonts/teachers-var.woff2 +0 -0
  301. package/app/public/vendor/fonts/ubuntu-400-ext.woff2 +0 -0
  302. package/app/public/vendor/fonts/ubuntu-400.woff2 +0 -0
  303. package/app/public/vendor/fonts/ubuntu-700-ext.woff2 +0 -0
  304. package/app/public/vendor/fonts/ubuntu-700.woff2 +0 -0
  305. package/app/public/vendor/fonts.css +1167 -51
  306. package/dist-pkg/boot/create-game.d.ts +444 -215
  307. package/dist-pkg/boot/create-game.js +3530 -549
  308. package/dist-pkg/core/a11y-sr.d.ts +25 -3
  309. package/dist-pkg/core/a11y-sr.js +40 -19
  310. package/dist-pkg/core/accessible-label.d.ts +17 -0
  311. package/dist-pkg/core/accessible-label.js +37 -0
  312. package/dist-pkg/core/accommodation-subjects.d.ts +26 -0
  313. package/dist-pkg/core/accommodation-subjects.js +32 -0
  314. package/dist-pkg/core/accommodations.d.ts +107 -0
  315. package/dist-pkg/core/accommodations.js +178 -0
  316. package/dist-pkg/core/actions.d.ts +75 -51
  317. package/dist-pkg/core/actions.js +98 -69
  318. package/dist-pkg/core/calm-mode.d.ts +31 -0
  319. package/dist-pkg/core/calm-mode.js +47 -0
  320. package/dist-pkg/core/camera-cycle.d.ts +15 -0
  321. package/dist-pkg/core/camera-cycle.js +7 -0
  322. package/dist-pkg/core/caption-duration.d.ts +15 -0
  323. package/dist-pkg/core/caption-duration.js +19 -0
  324. package/dist-pkg/core/cartridge-problems.d.ts +34 -0
  325. package/dist-pkg/core/cartridge-problems.js +53 -0
  326. package/dist-pkg/core/constants.d.ts +0 -54
  327. package/dist-pkg/core/constants.js +9 -76
  328. package/dist-pkg/core/contract.d.ts +143 -163
  329. package/dist-pkg/core/contract.js +223 -207
  330. package/dist-pkg/core/dom-query.d.ts +4 -4
  331. package/dist-pkg/core/dom-query.js +12 -20
  332. package/dist-pkg/core/entity.d.ts +62 -77
  333. package/dist-pkg/core/entity.js +18 -32
  334. package/dist-pkg/core/escape-html.d.ts +9 -10
  335. package/dist-pkg/core/escape-html.js +16 -20
  336. package/dist-pkg/core/flash-threshold.d.ts +17 -0
  337. package/dist-pkg/core/flash-threshold.js +113 -0
  338. package/dist-pkg/core/game-speed.d.ts +11 -0
  339. package/dist-pkg/core/game-speed.js +16 -0
  340. package/dist-pkg/core/genres.d.ts +20 -0
  341. package/dist-pkg/core/genres.js +44 -0
  342. package/dist-pkg/core/i18n.d.ts +87 -41
  343. package/dist-pkg/core/i18n.js +206 -153
  344. package/dist-pkg/core/loop.d.ts +17 -6
  345. package/dist-pkg/core/loop.js +26 -26
  346. package/dist-pkg/core/pause-icon-catalogue.d.ts +15 -0
  347. package/dist-pkg/core/pause-icon-catalogue.js +54 -0
  348. package/dist-pkg/core/ring.d.ts +17 -0
  349. package/dist-pkg/core/ring.js +29 -0
  350. package/dist-pkg/core/rng.d.ts +7 -12
  351. package/dist-pkg/core/rng.js +23 -53
  352. package/dist-pkg/core/route.d.ts +26 -26
  353. package/dist-pkg/core/route.js +126 -121
  354. package/dist-pkg/core/scenes.d.ts +37 -40
  355. package/dist-pkg/core/scenes.js +41 -43
  356. package/dist-pkg/core/screens.d.ts +4 -4
  357. package/dist-pkg/core/screens.js +11 -16
  358. package/dist-pkg/core/setting-defaults.d.ts +61 -0
  359. package/dist-pkg/core/setting-defaults.js +66 -0
  360. package/dist-pkg/core/speech-rate.d.ts +29 -0
  361. package/dist-pkg/core/speech-rate.js +47 -0
  362. package/dist-pkg/core/state.d.ts +118 -118
  363. package/dist-pkg/core/state.js +322 -297
  364. package/dist-pkg/core/visual-cycles.d.ts +30 -0
  365. package/dist-pkg/core/visual-cycles.js +52 -0
  366. package/dist-pkg/core/visual-state.d.ts +13 -0
  367. package/dist-pkg/core/visual-state.js +6 -0
  368. package/dist-pkg/educational/activities-registry.d.ts +1 -1
  369. package/dist-pkg/educational/activities-registry.js +9 -8
  370. package/dist-pkg/educational/adaptive-engine.d.ts +11 -11
  371. package/dist-pkg/educational/adaptive-engine.js +17 -17
  372. package/dist-pkg/educational/segment-bar.d.ts +12 -12
  373. package/dist-pkg/educational/segment-bar.js +9 -9
  374. package/dist-pkg/i18n/en.js +321 -215
  375. package/dist-pkg/i18n/es.js +320 -215
  376. package/dist-pkg/i18n/pt.js +342 -215
  377. package/dist-pkg/input/default-bindings.d.ts +37 -38
  378. package/dist-pkg/input/default-bindings.js +127 -144
  379. package/dist-pkg/input/devices.d.ts +7 -7
  380. package/dist-pkg/input/devices.js +17 -16
  381. package/dist-pkg/input/edges.d.ts +23 -32
  382. package/dist-pkg/input/edges.js +23 -28
  383. package/dist-pkg/input/empathy-filter.d.ts +22 -0
  384. package/dist-pkg/input/empathy-filter.js +34 -0
  385. package/dist-pkg/input/face-map.d.ts +96 -0
  386. package/dist-pkg/input/face-map.js +255 -0
  387. package/dist-pkg/input/face-signals.d.ts +40 -0
  388. package/dist-pkg/input/face-signals.js +48 -0
  389. package/dist-pkg/input/gamepad.d.ts +164 -145
  390. package/dist-pkg/input/gamepad.js +351 -559
  391. package/dist-pkg/input/gaze-cycle.d.ts +64 -0
  392. package/dist-pkg/input/gaze-cycle.js +133 -0
  393. package/dist-pkg/input/gaze-relative.d.ts +68 -0
  394. package/dist-pkg/input/gaze-relative.js +142 -0
  395. package/dist-pkg/input/hand-map.d.ts +28 -0
  396. package/dist-pkg/input/hand-map.js +142 -0
  397. package/dist-pkg/input/input-cooldown.d.ts +17 -0
  398. package/dist-pkg/input/input-cooldown.js +39 -0
  399. package/dist-pkg/input/keyboard-runtime.d.ts +18 -20
  400. package/dist-pkg/input/keyboard-runtime.js +23 -27
  401. package/dist-pkg/input/keyboard.d.ts +68 -62
  402. package/dist-pkg/input/keyboard.js +83 -105
  403. package/dist-pkg/input/keydown.d.ts +124 -132
  404. package/dist-pkg/input/keydown.js +283 -288
  405. package/dist-pkg/input/latch-edge.d.ts +25 -21
  406. package/dist-pkg/input/latch-edge.js +33 -35
  407. package/dist-pkg/input/latch-scope.d.ts +43 -47
  408. package/dist-pkg/input/latch-scope.js +66 -75
  409. package/dist-pkg/input/latch-store.d.ts +28 -29
  410. package/dist-pkg/input/latch-store.js +44 -47
  411. package/dist-pkg/input/latch-sync.d.ts +28 -29
  412. package/dist-pkg/input/latch-sync.js +25 -25
  413. package/dist-pkg/input/latch.d.ts +14 -17
  414. package/dist-pkg/input/latch.js +29 -37
  415. package/dist-pkg/input/pad-defaults.d.ts +13 -16
  416. package/dist-pkg/input/pad-defaults.js +21 -28
  417. package/dist-pkg/input/pad-reading.d.ts +76 -0
  418. package/dist-pkg/input/pad-reading.js +164 -0
  419. package/dist-pkg/input/pad-wizard.d.ts +96 -0
  420. package/dist-pkg/input/pad-wizard.js +205 -0
  421. package/dist-pkg/input/pointer-space.d.ts +19 -20
  422. package/dist-pkg/input/pointer-space.js +27 -32
  423. package/dist-pkg/input/pointer.d.ts +39 -40
  424. package/dist-pkg/input/pointer.js +42 -45
  425. package/dist-pkg/input/state.d.ts +101 -89
  426. package/dist-pkg/input/state.js +54 -134
  427. package/dist-pkg/input/switch-scan.d.ts +42 -0
  428. package/dist-pkg/input/switch-scan.js +54 -0
  429. package/dist-pkg/input/synthetic-source.d.ts +42 -0
  430. package/dist-pkg/input/synthetic-source.js +57 -0
  431. package/dist-pkg/input/touch-bindings.d.ts +127 -113
  432. package/dist-pkg/input/touch-bindings.js +158 -173
  433. package/dist-pkg/input/touch.d.ts +130 -70
  434. package/dist-pkg/input/touch.js +321 -123
  435. package/dist-pkg/input/transport-in-use.d.ts +107 -0
  436. package/dist-pkg/input/transport-in-use.js +130 -0
  437. package/dist-pkg/input/transports.d.ts +96 -114
  438. package/dist-pkg/input/transports.js +104 -106
  439. package/dist-pkg/input/virtual-controller.d.ts +46 -0
  440. package/dist-pkg/input/virtual-controller.js +41 -0
  441. package/dist-pkg/input/vocabulary-migration.d.ts +50 -49
  442. package/dist-pkg/input/vocabulary-migration.js +80 -79
  443. package/dist-pkg/input/voice-map.d.ts +43 -0
  444. package/dist-pkg/input/voice-map.js +125 -0
  445. package/dist-pkg/platform/audio-ambient.d.ts +13 -1
  446. package/dist-pkg/platform/audio-ambient.js +11 -13
  447. package/dist-pkg/platform/audio-earcons.d.ts +20 -19
  448. package/dist-pkg/platform/audio-earcons.js +51 -42
  449. package/dist-pkg/platform/audio-jingles.js +9 -10
  450. package/dist-pkg/platform/audio-mixer.d.ts +14 -2
  451. package/dist-pkg/platform/audio-mixer.js +36 -39
  452. package/dist-pkg/platform/audio-sonar.d.ts +96 -113
  453. package/dist-pkg/platform/audio-sonar.js +245 -238
  454. package/dist-pkg/platform/audio.d.ts +53 -19
  455. package/dist-pkg/platform/audio.js +161 -169
  456. package/dist-pkg/platform/flash-sampler.d.ts +19 -0
  457. package/dist-pkg/platform/flash-sampler.js +79 -0
  458. package/dist-pkg/platform/guide-intensity.d.ts +26 -27
  459. package/dist-pkg/platform/guide-intensity.js +50 -52
  460. package/dist-pkg/platform/heavy-catalogue.d.ts +31 -0
  461. package/dist-pkg/platform/heavy-catalogue.js +223 -0
  462. package/dist-pkg/platform/heavy-mirror.d.ts +26 -0
  463. package/dist-pkg/platform/heavy-mirror.js +76 -0
  464. package/dist-pkg/platform/heavy.d.ts +104 -0
  465. package/dist-pkg/platform/heavy.js +169 -0
  466. package/dist-pkg/platform/interruptible-speech.d.ts +16 -16
  467. package/dist-pkg/platform/interruptible-speech.js +50 -51
  468. package/dist-pkg/platform/kokoro-port.d.ts +27 -0
  469. package/dist-pkg/platform/kokoro-port.js +50 -0
  470. package/dist-pkg/platform/kokoro-runtime.d.ts +20 -0
  471. package/dist-pkg/platform/kokoro-runtime.js +52 -0
  472. package/dist-pkg/platform/kokoro.d.ts +70 -0
  473. package/dist-pkg/platform/kokoro.js +133 -0
  474. package/dist-pkg/platform/listener-scope.d.ts +12 -0
  475. package/dist-pkg/platform/listener-scope.js +81 -0
  476. package/dist-pkg/platform/locale-host.d.ts +10 -0
  477. package/dist-pkg/platform/locale-host.js +34 -0
  478. package/dist-pkg/platform/microphone.d.ts +51 -0
  479. package/dist-pkg/platform/microphone.js +118 -0
  480. package/dist-pkg/platform/onnx-runtime.d.ts +34 -0
  481. package/dist-pkg/platform/onnx-runtime.js +42 -0
  482. package/dist-pkg/platform/reading-in-worker.d.ts +40 -0
  483. package/dist-pkg/platform/reading-in-worker.js +87 -0
  484. package/dist-pkg/platform/reading-model.d.ts +73 -0
  485. package/dist-pkg/platform/reading-model.js +247 -0
  486. package/dist-pkg/platform/reading-runtime.d.ts +24 -0
  487. package/dist-pkg/platform/reading-runtime.js +159 -0
  488. package/dist-pkg/platform/reading-worker.d.ts +41 -0
  489. package/dist-pkg/platform/reading-worker.js +58 -0
  490. package/dist-pkg/platform/reading.d.ts +69 -0
  491. package/dist-pkg/platform/reading.js +128 -0
  492. package/dist-pkg/platform/speech-recognition.d.ts +51 -0
  493. package/dist-pkg/platform/speech-recognition.js +84 -0
  494. package/dist-pkg/platform/speech.d.ts +14 -1
  495. package/dist-pkg/platform/speech.js +13 -13
  496. package/dist-pkg/platform/storage-keys.d.ts +96 -0
  497. package/dist-pkg/platform/storage-keys.js +106 -0
  498. package/dist-pkg/platform/storage.d.ts +46 -111
  499. package/dist-pkg/platform/storage.js +104 -169
  500. package/dist-pkg/platform/tts.d.ts +51 -38
  501. package/dist-pkg/platform/tts.js +217 -119
  502. package/dist-pkg/platform/vision-loop.d.ts +23 -0
  503. package/dist-pkg/platform/vision-loop.js +56 -0
  504. package/dist-pkg/platform/vision.d.ts +132 -0
  505. package/dist-pkg/platform/vision.js +117 -0
  506. package/dist-pkg/platform/voice-listener.d.ts +36 -0
  507. package/dist-pkg/platform/voice-listener.js +88 -0
  508. package/dist-pkg/platform/voice-plan.d.ts +6 -102
  509. package/dist-pkg/platform/voice-plan.js +9 -151
  510. package/dist-pkg/platform/vosk-runtime.d.ts +71 -0
  511. package/dist-pkg/platform/vosk-runtime.js +93 -0
  512. package/dist-pkg/render/canvas.d.ts +13 -7
  513. package/dist-pkg/render/canvas.js +13 -13
  514. package/dist-pkg/render/crt.d.ts +46 -12
  515. package/dist-pkg/render/crt.js +82 -80
  516. package/dist-pkg/render/cvd-matrices.d.ts +18 -18
  517. package/dist-pkg/render/cvd-matrices.js +33 -38
  518. package/dist-pkg/render/hc-role-data.d.ts +7 -7
  519. package/dist-pkg/render/hc-role-data.js +14 -14
  520. package/dist-pkg/render/high-contrast.d.ts +66 -54
  521. package/dist-pkg/render/high-contrast.js +221 -216
  522. package/dist-pkg/render/low-vision-drawing.d.ts +5 -0
  523. package/dist-pkg/render/low-vision-drawing.js +46 -0
  524. package/dist-pkg/render/lq-filter.d.ts +32 -20
  525. package/dist-pkg/render/lq-filter.js +59 -65
  526. package/dist-pkg/render/port.d.ts +86 -88
  527. package/dist-pkg/render/port.js +17 -23
  528. package/dist-pkg/render/screen-pipeline.d.ts +42 -42
  529. package/dist-pkg/render/screen-pipeline.js +80 -87
  530. package/dist-pkg/render/sprite-fx.d.ts +2 -1
  531. package/dist-pkg/render/sprite-fx.js +16 -14
  532. package/dist-pkg/render/viewports.d.ts +20 -15
  533. package/dist-pkg/render/viewports.js +83 -114
  534. package/dist-pkg/render/viz-axes-labels.d.ts +47 -0
  535. package/dist-pkg/render/viz-axes-labels.js +88 -0
  536. package/dist-pkg/render/viz-axes.d.ts +80 -86
  537. package/dist-pkg/render/viz-axes.js +115 -116
  538. package/dist-pkg/render/viz-modes.d.ts +28 -29
  539. package/dist-pkg/render/viz-modes.js +40 -45
  540. package/dist-pkg/render/viz-refusal.d.ts +32 -0
  541. package/dist-pkg/render/viz-refusal.js +57 -0
  542. package/dist-pkg/render/viz-setters.d.ts +104 -66
  543. package/dist-pkg/render/viz-setters.js +182 -191
  544. package/dist-pkg/ui/aac-sets.d.ts +40 -0
  545. package/dist-pkg/ui/aac-sets.js +49 -0
  546. package/dist-pkg/ui/audio-choices.d.ts +63 -0
  547. package/dist-pkg/ui/audio-choices.js +94 -0
  548. package/dist-pkg/ui/audio-rows-that-apply.d.ts +12 -0
  549. package/dist-pkg/ui/audio-rows-that-apply.js +43 -0
  550. package/dist-pkg/ui/camera-control.d.ts +6 -0
  551. package/dist-pkg/ui/camera-control.js +14 -0
  552. package/dist-pkg/ui/changed-mark.d.ts +9 -8
  553. package/dist-pkg/ui/changed-mark.js +22 -53
  554. package/dist-pkg/ui/control-choices.d.ts +37 -0
  555. package/dist-pkg/ui/control-choices.js +84 -0
  556. package/dist-pkg/ui/debug-panel.d.ts +59 -43
  557. package/dist-pkg/ui/debug-panel.js +68 -69
  558. package/dist-pkg/ui/dom.d.ts +10 -14
  559. package/dist-pkg/ui/dom.js +10 -47
  560. package/dist-pkg/ui/drawing-problems.d.ts +25 -0
  561. package/dist-pkg/ui/drawing-problems.js +93 -0
  562. package/dist-pkg/ui/eye-control.d.ts +36 -0
  563. package/dist-pkg/ui/eye-control.js +178 -0
  564. package/dist-pkg/ui/face-control.d.ts +25 -0
  565. package/dist-pkg/ui/face-control.js +183 -0
  566. package/dist-pkg/ui/focus-trap.d.ts +39 -49
  567. package/dist-pkg/ui/focus-trap.js +56 -65
  568. package/dist-pkg/ui/fonts.d.ts +118 -63
  569. package/dist-pkg/ui/fonts.js +267 -111
  570. package/dist-pkg/ui/game-options.d.ts +42 -0
  571. package/dist-pkg/ui/game-options.js +138 -0
  572. package/dist-pkg/ui/gaze-overlay.d.ts +64 -0
  573. package/dist-pkg/ui/gaze-overlay.js +165 -0
  574. package/dist-pkg/ui/hand-control.d.ts +25 -0
  575. package/dist-pkg/ui/hand-control.js +143 -0
  576. package/dist-pkg/ui/help-panel.d.ts +76 -0
  577. package/dist-pkg/ui/help-panel.js +232 -0
  578. package/dist-pkg/ui/hud-bands.d.ts +45 -0
  579. package/dist-pkg/ui/hud-bands.js +108 -0
  580. package/dist-pkg/ui/hud.d.ts +73 -70
  581. package/dist-pkg/ui/hud.js +94 -98
  582. package/dist-pkg/ui/item-announcement.d.ts +13 -12
  583. package/dist-pkg/ui/item-announcement.js +25 -26
  584. package/dist-pkg/ui/latch-refusal.d.ts +22 -21
  585. package/dist-pkg/ui/latch-refusal.js +37 -37
  586. package/dist-pkg/ui/layout.d.ts +103 -28
  587. package/dist-pkg/ui/layout.js +114 -99
  588. package/dist-pkg/ui/locale-flags.d.ts +16 -0
  589. package/dist-pkg/ui/locale-flags.js +30 -0
  590. package/dist-pkg/ui/loop-crash.d.ts +17 -17
  591. package/dist-pkg/ui/loop-crash.js +33 -62
  592. package/dist-pkg/ui/menu-intent.d.ts +32 -0
  593. package/dist-pkg/ui/menu-intent.js +54 -0
  594. package/dist-pkg/ui/menu-items.d.ts +10 -0
  595. package/dist-pkg/ui/menu-items.js +22 -0
  596. package/dist-pkg/ui/menu-nav.d.ts +60 -90
  597. package/dist-pkg/ui/menu-nav.js +301 -274
  598. package/dist-pkg/ui/mobility-choices.d.ts +26 -0
  599. package/dist-pkg/ui/mobility-choices.js +41 -0
  600. package/dist-pkg/ui/motion-choices.d.ts +31 -0
  601. package/dist-pkg/ui/motion-choices.js +68 -0
  602. package/dist-pkg/ui/motion-scene.d.ts +24 -19
  603. package/dist-pkg/ui/motion-scene.js +29 -61
  604. package/dist-pkg/ui/mount-panel.d.ts +81 -0
  605. package/dist-pkg/ui/mount-panel.js +61 -0
  606. package/dist-pkg/ui/panel-shell.d.ts +65 -31
  607. package/dist-pkg/ui/panel-shell.js +92 -66
  608. package/dist-pkg/ui/panel-widgets.d.ts +116 -0
  609. package/dist-pkg/ui/panel-widgets.js +208 -0
  610. package/dist-pkg/ui/pause-buttons.d.ts +35 -0
  611. package/dist-pkg/ui/pause-buttons.js +66 -0
  612. package/dist-pkg/ui/pause-icons.d.ts +249 -351
  613. package/dist-pkg/ui/pause-icons.js +614 -605
  614. package/dist-pkg/ui/pause-markup.d.ts +86 -0
  615. package/dist-pkg/ui/pause-markup.js +125 -0
  616. package/dist-pkg/ui/reach-notice.d.ts +22 -23
  617. package/dist-pkg/ui/reach-notice.js +55 -58
  618. package/dist-pkg/ui/scan-overlay.d.ts +23 -0
  619. package/dist-pkg/ui/scan-overlay.js +42 -0
  620. package/dist-pkg/ui/settings-aac.d.ts +68 -0
  621. package/dist-pkg/ui/settings-aac.js +189 -0
  622. package/dist-pkg/ui/settings-audio.d.ts +91 -113
  623. package/dist-pkg/ui/settings-audio.js +407 -405
  624. package/dist-pkg/ui/settings-controls.d.ts +53 -92
  625. package/dist-pkg/ui/settings-controls.js +143 -215
  626. package/dist-pkg/ui/settings-empathy.d.ts +39 -30
  627. package/dist-pkg/ui/settings-empathy.js +53 -60
  628. package/dist-pkg/ui/settings-mobility.d.ts +195 -0
  629. package/dist-pkg/ui/settings-mobility.js +374 -0
  630. package/dist-pkg/ui/settings-motion.d.ts +91 -70
  631. package/dist-pkg/ui/settings-motion.js +287 -198
  632. package/dist-pkg/ui/settings-panel.d.ts +48 -45
  633. package/dist-pkg/ui/settings-panel.js +164 -93
  634. package/dist-pkg/ui/settings-typo.d.ts +28 -76
  635. package/dist-pkg/ui/settings-typo.js +145 -125
  636. package/dist-pkg/ui/settings-visual.d.ts +29 -87
  637. package/dist-pkg/ui/settings-visual.js +240 -193
  638. package/dist-pkg/ui/shell.d.ts +113 -134
  639. package/dist-pkg/ui/shell.js +143 -216
  640. package/dist-pkg/ui/simulation-list.d.ts +27 -0
  641. package/dist-pkg/ui/simulation-list.js +76 -0
  642. package/dist-pkg/ui/simulation-over-the-world.d.ts +23 -0
  643. package/dist-pkg/ui/simulation-over-the-world.js +131 -0
  644. package/dist-pkg/ui/simulation-refusal.d.ts +5 -31
  645. package/dist-pkg/ui/simulation-refusal.js +8 -56
  646. package/dist-pkg/ui/switchable-control.d.ts +11 -0
  647. package/dist-pkg/ui/switchable-control.js +19 -0
  648. package/dist-pkg/ui/title.d.ts +4 -4
  649. package/dist-pkg/ui/title.js +11 -13
  650. package/dist-pkg/ui/top-band.d.ts +24 -0
  651. package/dist-pkg/ui/top-band.js +100 -0
  652. package/dist-pkg/ui/typo-choices.d.ts +71 -0
  653. package/dist-pkg/ui/typo-choices.js +84 -0
  654. package/dist-pkg/ui/visual-axes-panel.d.ts +11 -47
  655. package/dist-pkg/ui/visual-axes-panel.js +12 -94
  656. package/dist-pkg/ui/visual-choices.d.ts +77 -0
  657. package/dist-pkg/ui/visual-choices.js +107 -0
  658. package/dist-pkg/ui/vlibras.d.ts +33 -10
  659. package/dist-pkg/ui/vlibras.js +58 -81
  660. package/dist-pkg/ui/voice-control.d.ts +46 -0
  661. package/dist-pkg/ui/voice-control.js +143 -0
  662. package/dist-pkg/ui/voice-settings.d.ts +96 -0
  663. package/dist-pkg/ui/voice-settings.js +302 -0
  664. package/dist-pkg/ui/where-the-child-is.d.ts +31 -0
  665. package/dist-pkg/ui/where-the-child-is.js +70 -0
  666. package/docs/CREDITS.md +198 -54
  667. package/docs/LICENSES.md +160 -145
  668. package/package.json +24 -25
  669. package/scripts/heavy-into-the-delivery.mjs +144 -0
  670. package/scripts/licences/Apache-2.0.txt +176 -0
  671. package/scripts/licences/GPL-3.0.txt +674 -0
  672. package/scripts/licences/MIT.txt +17 -0
  673. package/scripts/licences/third-party.mjs +287 -0
  674. package/scripts/licences/vosk-browser.NOTICE.txt +268 -0
  675. package/app/public/vendor/fonts/comicneue-400.woff2 +0 -0
  676. package/app/public/vendor/fonts/comicneue-700.woff2 +0 -0
  677. package/dist-pkg/core/anel.d.ts +0 -17
  678. package/dist-pkg/core/anel.js +0 -31
  679. package/dist-pkg/core/collision.d.ts +0 -21
  680. package/dist-pkg/core/collision.js +0 -62
  681. package/dist-pkg/core/layers.d.ts +0 -63
  682. package/dist-pkg/core/layers.js +0 -95
  683. package/dist-pkg/core/letter-grid.d.ts +0 -55
  684. package/dist-pkg/core/letter-grid.js +0 -116
  685. package/dist-pkg/core/password.d.ts +0 -34
  686. package/dist-pkg/core/password.js +0 -205
  687. package/dist-pkg/core/rotulo-acessivel.d.ts +0 -17
  688. package/dist-pkg/core/rotulo-acessivel.js +0 -41
  689. package/dist-pkg/core/run-state.d.ts +0 -115
  690. package/dist-pkg/core/run-state.js +0 -40
  691. package/dist-pkg/core/tiles.d.ts +0 -12
  692. package/dist-pkg/core/tiles.js +0 -61
  693. package/dist-pkg/core/world.d.ts +0 -4
  694. package/dist-pkg/core/world.js +0 -58
  695. package/dist-pkg/input/origem-sintetica.d.ts +0 -44
  696. package/dist-pkg/input/origem-sintetica.js +0 -60
  697. package/dist-pkg/input/transporte-em-uso.d.ts +0 -101
  698. package/dist-pkg/input/transporte-em-uso.js +0 -130
  699. package/dist-pkg/platform/audio-nav.d.ts +0 -51
  700. package/dist-pkg/platform/audio-nav.js +0 -99
  701. package/dist-pkg/platform/pesados-catalogo.d.ts +0 -14
  702. package/dist-pkg/platform/pesados-catalogo.js +0 -177
  703. package/dist-pkg/platform/pesados.d.ts +0 -31
  704. package/dist-pkg/platform/pesados.js +0 -75
  705. package/dist-pkg/platform/vozes-prontas.d.ts +0 -28
  706. package/dist-pkg/platform/vozes-prontas.js +0 -61
  707. package/dist-pkg/render/camera.d.ts +0 -58
  708. package/dist-pkg/render/camera.js +0 -105
  709. package/dist-pkg/render/cenario-data.d.ts +0 -127
  710. package/dist-pkg/render/cenario-data.js +0 -145
  711. package/dist-pkg/render/city-tex.d.ts +0 -63
  712. package/dist-pkg/render/city-tex.js +0 -228
  713. package/dist-pkg/render/city-tiles.d.ts +0 -11
  714. package/dist-pkg/render/city-tiles.js +0 -140
  715. package/dist-pkg/render/draw.d.ts +0 -149
  716. package/dist-pkg/render/draw.js +0 -231
  717. package/dist-pkg/render/fx.d.ts +0 -63
  718. package/dist-pkg/render/fx.js +0 -123
  719. package/dist-pkg/render/minimap.d.ts +0 -11
  720. package/dist-pkg/render/minimap.js +0 -94
  721. package/dist-pkg/render/parallax.d.ts +0 -85
  722. package/dist-pkg/render/parallax.js +0 -158
  723. package/dist-pkg/render/player-anim.d.ts +0 -71
  724. package/dist-pkg/render/player-anim.js +0 -152
  725. package/dist-pkg/render/recycling-tex.d.ts +0 -48
  726. package/dist-pkg/render/recycling-tex.js +0 -164
  727. package/dist-pkg/render/scene-city.d.ts +0 -77
  728. package/dist-pkg/render/scene-city.js +0 -181
  729. package/dist-pkg/render/scene-parallax.d.ts +0 -72
  730. package/dist-pkg/render/scene-parallax.js +0 -426
  731. package/dist-pkg/render/scene-sky.d.ts +0 -126
  732. package/dist-pkg/render/scene-sky.js +0 -295
  733. package/dist-pkg/render/set-cenario.d.ts +0 -54
  734. package/dist-pkg/render/set-cenario.js +0 -103
  735. package/dist-pkg/render/textures.d.ts +0 -75
  736. package/dist-pkg/render/textures.js +0 -240
  737. package/dist-pkg/render/title-scene.d.ts +0 -46
  738. package/dist-pkg/render/title-scene.js +0 -75
  739. package/dist-pkg/render/weather.d.ts +0 -96
  740. package/dist-pkg/render/weather.js +0 -171
  741. package/dist-pkg/render/wheelchair-sprites.d.ts +0 -32
  742. package/dist-pkg/render/wheelchair-sprites.js +0 -69
  743. package/dist-pkg/render/world-tex.d.ts +0 -18
  744. package/dist-pkg/render/world-tex.js +0 -87
  745. package/dist-pkg/ui/activities-menu.d.ts +0 -258
  746. package/dist-pkg/ui/activities-menu.js +0 -648
  747. package/dist-pkg/ui/caa-sets.d.ts +0 -39
  748. package/dist-pkg/ui/caa-sets.js +0 -65
  749. package/dist-pkg/ui/map-hub.d.ts +0 -56
  750. package/dist-pkg/ui/map-hub.js +0 -138
  751. package/dist-pkg/ui/settings-caa.d.ts +0 -45
  752. package/dist-pkg/ui/settings-caa.js +0 -137
  753. package/dist-pkg/ui/settings-motor.d.ts +0 -175
  754. package/dist-pkg/ui/settings-motor.js +0 -342
  755. package/dist-pkg/ui/webcam.d.ts +0 -7
  756. package/dist-pkg/ui/webcam.js +0 -93
@@ -1,743 +1,3724 @@
1
1
  // SPDX-License-Identifier: AGPL-3.0-or-later
2
- // boot/create-game — O TESTE DE FRONTEIRA DO ADR-0027 (passo 4), escrito em vez de discutido.
2
+ // boot/create-game — THE BOUNDARY TEST OF ADR-0027 (step 4), written instead of argued.
3
3
  //
4
- // ========================= A PERGUNTA QUE ESTE ARQUIVO EXISTE PARA RESPONDER =========================
5
- // O ADR-0027 pôs um veredito e não o deixou vago:
4
+ // ========================= THE QUESTION THIS FILE EXISTS TO ANSWER =========================
5
+ // ADR-0027 set a verdict and did not leave it vague: if `createGame()` could not be written without a parameter
6
+ // called `coinTarget`, the boundary that record proposed was wrong, and its steps 5 to 7 could not begin.
6
7
  //
7
- // "se `createGame()` não puder ser escrito sem um parâmetro chamado `coinTarget`, a fronteira que este
8
- // registro propõe está errada e os passos 5 a 7 NÃO PODEM COMEÇAR."
8
+ // It can. There is no `coinTarget` here, and not out of discipline: ADR-0030 settled where the target comes from —
9
+ // field 5 of the contract (`objectiveOf`). The HUD that asked for a coin count asks the game how many of how many, and
10
+ // the coin stops being an engine word. `tests/boot-create-game.node.test.js` holds this.
9
11
  //
10
- // Pode. Não há `coinTarget` aqui, e não é por disciplina — é porque o ADR-0030 resolveu de onde o alvo vem:
11
- // campo 5 do contrato (`objectiveOf`). O HUD que pedia um número de moedas passa a perguntar ao jogo quantas
12
- // de quantas, e "moeda" deixa de ser palavra da engine. `tests/boot-create-game.node.test.js` prende isso.
12
+ // ========================= WHAT THIS IS: THE ORDER =========================
13
+ // Not a framework. It is the sequence that switches the engine on, which every consumer would otherwise rewrite by
14
+ // hand — and the second consumer measured the price of rewriting it (`consumer-quiz`, findings 3, 6 and 12):
13
15
  //
14
- // ========================= O QUE ISTO É: A ORDEM, E O QUE FALTA =========================
15
- // Não é um framework. É a sequência de ligar a engine, que hoje cada consumidor reescreve à mão — e o segundo
16
- // consumidor mediu o preço de reescrevê-la (`consumer-quiz`, achados 3, 6 e 12):
16
+ // · FINDING 3 — the mixer MUST be loaded before `createTts`, or `audioCat` is null and `narrate` gives up
17
+ // SILENTLY. An order dependency no type declared, found by the silence. Here it cannot be got wrong: `createAudio`
18
+ // loads the mixer as it is built, and the voice is built after it.
19
+ // · FINDING 6 — the panels need fixed ids in the document and, when they are missing, OPEN EMPTY, with no error.
20
+ // Here the engine builds its panels, and what the page lacks becomes `problems`, a list the consumer can read.
21
+ // · FINDING 12 — every consumer wrote the same one-line adapter for `window`. Written once.
17
22
  //
18
- // · ACHADO 3 — `initAudioMixer()` TEM de rodar antes de `createTts`, senão `audioCat` é null e o `narrate`
19
- // desiste CALADO. Uma dependência de ordem que nenhum tipo declara e que se descobre pelo silêncio. Aqui
20
- // ela é impossível de errar: quem chama não escolhe a ordem.
21
- // · ACHADO 6 — os painéis exigem ids fixos no documento (`#typo`, `#typo-list`…) e, quando faltam, ABREM
22
- // VAZIOS. Sem erro. Aqui a falta vira `problems`, que é uma lista que o consumidor pode ler e mostrar.
23
- // · ACHADO 12 — todo consumidor escreve o mesmo adaptador de uma linha para o `window`. Escrito uma vez.
23
+ // And one finding that only appeared when this function tried to boot against an injected document:
24
24
  //
25
- // E um achado NOVO, que só apareceu quando esta função tentou bootar contra um documento injetado:
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.
26
30
  //
27
- // · ACHADO 15 — `initI18n()` chamava `applyDom(document)`, o GLOBAL, por baixo de quem a chamasse. Num
28
- // navegador dá no mesmo e por isso sobreviveu; num teste de lógica pura é a diferença entre bootar e não
29
- // bootar, e num futuro com dois documentos (uma engine em iframe, um editor ao lado do jogo) seria a
30
- // diferença entre traduzir o documento certo e o outro. Consertado no mesmo passo: `initI18n(root)`, com
31
- // o global como padrão, exatamente como `applyDom` já fazia.
31
+ // ========================= DECLINING IS NOT LYING =========================
32
+ // The quiz declined the sonar and the pad instead of inventing tiles and a fake collision box, and that distinction
33
+ // separates a finding from a false green. Here it becomes a TYPE: a game without a piece says so in a field instead
34
+ // of returning `null` from a getter and hoping. What is declined is kept in the returned object, so a consumer can be
35
+ // audited by what it refused.
36
+ // ⚠️ THIS PARAGRAPH NAMES NONE OF THE FIELDS, which looks odd and is deliberate: `tests/no-decline-is-dead` counts
37
+ // MENTIONS, comments included, on purpose — erring towards «alive» is the right direction for that error, because a
38
+ // false accusation switches a gate off. So using a decline as an EXAMPLE in prose makes it look read, and a field
39
+ // that is genuinely dead stops being accused.
32
40
  //
33
- // ========================= DECLINAR NÃO É MENTIR =========================
34
- // O quiz recusou o sonar e o pad em vez de inventar tiles e uma caixa de colisão falsos, e essa distinção é o
35
- // que separa um achado de um verde falso. Aqui ela vira TIPO: um jogo que não tem uma peça declara-o num
36
- // campo em vez de devolver `null` de um getter e torcer. O que se declina fica registrado no objeto devolvido,
37
- // e um consumidor pode ser auditado pelo que recusou.
38
- // ⚠️ E ESTE PARÁGRAFO NÃO NOMEIA NENHUM DOS CAMPOS, o que parece esquisito e é medido: o
39
- // `tests/declinio-morto` conta MENÇÕES, incluindo as de comentário, e fá-lo de propósito — «falhar para o lado
40
- // de vivo é a direcção certa deste erro», porque uma acusação falsa desliga um gate. A consequência é que usar
41
- // um declínio como EXEMPLO em prosa o faz parecer lido, e um campo genuinamente morto deixa de ser acusado.
42
- // 📌 O primeiro rascunho desta nota fez exactamente isso, e o crivo apanhou-o no mesmo minuto.
43
- //
44
- // ========================= O QUE ISTO AINDA NÃO FAZ, DITO AQUI E NÃO ESCONDIDO =========================
45
- // Não liga render, física, tiles nem o sonar — nada disso é de todo jogo, e o achado 9 mostra que o sonar hoje
46
- // exige seis coisas de plataforma. Não substitui o boot do `main.js`, que tem catorze anos de ordem própria.
47
- // O que ele cobre é o que o quiz provou ser IDÊNTICO em qualquer jogo: idioma, leitor de tela, mixer, voz,
48
- // pilha de diálogos, filtros de daltonismo, teclado remapeável e navegação de menu.
49
- import { initI18n, idiomaPronto } from '../core/i18n.js';
50
- import { entradaDe } from '../input/state.js';
51
- import { criarAvisoDeQueda } from '../ui/loop-crash.js';
52
- import { initFocusTrap, focaveisNoDom } from '../ui/focus-trap.js';
53
- import { mostrarAvisoDeAlcance, REACH_NOTICE_ID } from '../ui/reach-notice.js';
54
- import { alcance, transportesPadrao } from '../input/transports.js';
55
- import { presetActions, ACTIONS } from '../core/actions.js';
56
- import { t } from '../core/i18n.js';
57
- import { srSay, srAlert } from '../core/a11y-sr.js';
58
- import { initPauseIcons, iconsMarkup } from '../ui/pause-icons.js';
59
- // O módulo INTEIRO: o on do barramento de eventos, para a barra montada continuar a dizer a verdade.
60
- import * as state from '../core/state.js';
61
- import { vlibrasOpen, toggleLibras } from '../ui/vlibras.js';
41
+ // ========================= WHAT THIS DOES NOT DO =========================
42
+ // It does not start a renderer, physics or tiles, nor the game loop: none of that belongs to every game, and the loop
43
+ // is the cartridge's (`core/loop.startLoop`, called by the game). It does not mount `ui/shell`, the phase machine of a
44
+ // game with a title screen. What it covers is what is the SAME in every game: language, screen reader, mixer, voice,
45
+ // the dialog stack, the colour filters, the remappable keyboard, menu navigation, the pause card and the accessibility
46
+ // bar, the settings panels, the navigation sonar, and every input transport.
47
+ import i18nObject, { initI18n, loadLocale, createTranslator } from '../core/i18n.js';
48
+ import { localeHostHooks, exposeI18n } from '../platform/locale-host.js';
49
+ import { createInputState } from '../input/state.js';
50
+ import { initTouch, mountTouchControls, touchGaps } from '../input/touch.js';
51
+ import { initTouchBindings } from '../input/touch-bindings.js';
52
+ import { createCrashNotice } from '../ui/loop-crash.js';
53
+ import { sampleFlashes } from '../platform/flash-sampler.js';
54
+ import { initFocusTrap, focusablesInDom } from '../ui/focus-trap.js';
55
+ import { showReachNotice, REACH_NOTICE_ID } from '../ui/reach-notice.js';
56
+ import { reach, defaultTransports } from '../input/transports.js';
57
+ import { accommodationAnswersProblems, subjectWord } from '../core/accommodations.js';
58
+ import { genreProblems, genreWarning } from '../core/genres.js';
59
+ import { cartridgeProblems } from '../core/cartridge-problems.js';
60
+ import { contractSubjects } from '../core/accommodation-subjects.js';
61
+ import { presetActions, startClaimProblem, selectClaimProblem, labellerFrom, shortLabellerFrom, ACTIONS } from '../core/actions.js';
62
+ import { createAnnouncer } from '../core/a11y-sr.js';
63
+ import { createEyeControl, videoFeed } from '../ui/eye-control.js';
64
+ import { createFaceControl } from '../ui/face-control.js';
65
+ import { createHandControl } from '../ui/hand-control.js';
66
+ import { checkedCacheHas, sha256With } from '../platform/heavy.js';
67
+ import { createBundleLoader } from '../platform/vosk-runtime.js';
68
+ import { followCameraMode } from '../ui/camera-control.js';
69
+ import { initPauseIcons, wireBarCaption, showPauseOptions } from '../ui/pause-icons.js';
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
+ // root asks `ui/pause-icons` for is the WIRING — the icons this game can actually act on, and the reflection of their state.
72
+ import { iconsMarkup } from '../ui/pause-markup.js';
73
+ import { accessibleLabel } from '../core/accessible-label.js';
74
+ import { helpRows, mountSlides, showSlide, animateFigure, howToPlayProblems } from '../ui/help-panel.js';
75
+ import { initSettingsControls } from '../ui/settings-controls.js';
76
+ import { keyName } from '../ui/control-choices.js';
77
+ import { reserveTopBand } from '../ui/top-band.js';
78
+ // The settings store's FACTORY: this root builds the one store it, its modules and its game read (ADR-0232 D4).
79
+ import { createSettingsStore } from '../core/state.js';
80
+ import { DEFAULTS, defaultReducedMotion } from '../core/setting-defaults.js';
81
+ import { CAMERA_CONTROLS } from '../core/camera-cycle.js';
82
+ import { createLibras } from '../ui/vlibras.js';
62
83
  import { conformanceProblems } from '../core/contract.js';
63
- import { criarPilha } from '../core/scenes.js';
84
+ import { createSceneStack } from '../core/scenes.js';
64
85
  import { createTts } from '../platform/tts.js';
65
- import { ensureAC, catNode, audioOut, soundOn, volume, audioCat, initAudioMixer, tonePan, audioCtx, setCatGain } from '../platform/audio.js';
86
+ import { createReading } from '../platform/reading.js';
87
+ import { createAudio } from '../platform/audio.js';
66
88
  import { createAudioSonar } from '../platform/audio-sonar.js';
67
- // A raiz é a camada que PODE conhecer os dois eixos: `render/` está abaixo dela, e é dela a tarefa de
68
- // responder ao `platform/audio-sonar`, que não pode importar daqui sem inverter uma aresta (#104).
69
- import { ehCego, ehBaixaVisao, PADRAO } from '../render/viz-axes.js';
89
+ // The root is the layer that MAY know both axes: `render/` is below it, and it is the root's job to answer
90
+ // `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';
92
+ // 📌 The mode → `url(#...)` table, which `render/cvd-matrices` installs and the `consumer-quiz` consumes.
93
+ import { VIZ_FILTER } from '../render/viz-modes.js';
94
+ import { createPadWizard, createPadMaps } from '../input/pad-wizard.js';
95
+ import { typographyCycle, CYCLE_START, FONT_BY_KEY } from '../ui/fonts.js';
96
+ // 📏 The drawing reporters (`barIntruders`, `belowFloor`, `minimumTarget` and their types) live in `ui/drawing-problems`
97
+ // (ADR-0221, issue #203), and the root does not know them. An import that can be deleted is coupling that no longer
98
+ // exists, and that is how this debt is paid: by subject.
99
+ import { stageScale, applyScale } from '../ui/layout.js';
100
+ import { screenBaseSize } from '../core/screens.js';
70
101
  import { OVERLAY_SCOPE_SELECTOR } from '../ui/settings-panel.js';
102
+ import { drawnBelowTheFloor, barIntruderProblems } from '../ui/drawing-problems.js';
103
+ import { createListenerScope } from '../platform/listener-scope.js';
71
104
  import { LOGICAL_W } from '../core/constants.js';
105
+ import { captionDuration, CAPTION_RATES } from '../core/caption-duration.js';
72
106
  import { initSettingsPanel } from '../ui/settings-panel.js';
107
+ 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.
110
+ import { stampSource, sourceOfEvent } from '../input/synthetic-source.js';
111
+ import { createVirtualController } from '../input/virtual-controller.js';
112
+ import { createSwitchScan, SWITCH_SCAN_DEFAULTS } from '../input/switch-scan.js';
113
+ import { mountScanOverlay, scanItemText } from '../ui/scan-overlay.js';
114
+ import { createVoiceControl } from '../ui/voice-control.js';
115
+ import { mountSteps, updateSteps, nextStep, controlRow, labelRow } from '../ui/panel-widgets.js';
116
+ import { PERSONAS_DO_PAD, closestPersona } from '../input/touch.js';
117
+ import { initSettingsTypo } from '../ui/settings-typo.js';
118
+ import { initSettingsMotion } from '../ui/settings-motion.js';
119
+ import { readStoredScene, storeScene } from '../ui/motion-scene.js';
120
+ import { initSettingsVisual } from '../ui/settings-visual.js';
121
+ import { initSettingsEmpathy } from '../ui/settings-empathy.js';
122
+ import { HC_ROLE_DEF } from '../render/hc-role-data.js';
123
+ import { createLqFilter } from '../render/lq-filter.js';
124
+ import { createCrt } from '../render/crt.js';
125
+ import { initSettingsAudio, mountAudioInside, mountSoundInside } from '../ui/settings-audio.js';
126
+ import { AUDIO_CATS } from '../platform/audio-mixer.js';
127
+ import { toggleBtn, toggleLabel } from '../ui/dom.js';
128
+ import { createEmpathyFilter } from '../input/empathy-filter.js';
129
+ import { createInputCooldown, COOLDOWN_MS } from '../input/input-cooldown.js';
130
+ import { markChanged } from '../ui/changed-mark.js';
131
+ import { mountHudBands, hudNumbersProblems } from '../ui/hud-bands.js';
132
+ import { gameOptionsProblems, drawGameOptions } from '../ui/game-options.js';
133
+ import { createStorage, keysOutsideScopes } from '../platform/storage.js';
134
+ import { KEYS } from '../platform/storage-keys.js';
73
135
  import { initMenuNav } from '../ui/menu-nav.js';
136
+ // 🔴 THE GAMEPAD IS MOUNTED HERE (ADR-0224), like every other transport — no longer by the cartridge.
137
+ import { initGamepad, padGameAnswers, seatEveryPlayer } from '../input/gamepad.js';
138
+ import { whereTheChildIs } from '../ui/where-the-child-is.js';
139
+ import { createSimulationOverTheWorld } from '../ui/simulation-over-the-world.js';
140
+ import { createSimulationList } from '../ui/simulation-list.js';
141
+ import { showOnlyRowsThatApply } from '../ui/audio-rows-that-apply.js';
74
142
  import { initKeyboardRuntime } from '../input/keyboard-runtime.js';
75
- import { kb, initKB, registrarMapeamentoDoTeclado } from '../input/keyboard.js';
76
- import { registrarMapeamentoDoPad } from '../input/pad-defaults.js';
77
- import { baixarPesados } from '../platform/pesados.js';
143
+ import { createKeyboardConfig } from '../input/keyboard.js';
144
+ import { createPadTable } from '../input/pad-defaults.js';
145
+ import { downloadHeavy, heavyAtBoot } from '../platform/heavy.js';
78
146
  import { installCvdFilters } from '../render/cvd-matrices.js';
147
+ /**
148
+ * The backend the host lent, else its window's `localStorage` — or `null` where the window has none or reading it throws
149
+ * (file://, private modes): a host with no storage, whose reads give their fallbacks.
150
+ */
151
+ function hostStorage(host) {
152
+ if (host.storage)
153
+ return host.storage;
154
+ try {
155
+ return host.win.localStorage ?? null;
156
+ }
157
+ catch {
158
+ return null;
159
+ }
160
+ }
161
+ /*
162
+ * ONE SENTENCE FOR BOTH REFUSALS.
163
+ *
164
+ * ⚠️ The boot and `mount()` refuse a malformed declaration for the SAME reason, so with the same sentence: two copies
165
+ * of a sentence are two places for it to drift, and a second translation to do when pillar 3 reaches it (the i18n
166
+ * gate caps this module's raw text).
167
+ */
168
+ function refuseDeclaration(who, problemas) {
169
+ throw new Error(`${who}: declaração malformada — ${problemas.join('; ')}`);
170
+ }
79
171
  /*
80
- * UMA FRASE SÓ PARA AS DUAS RECUSAS, e o gate do pilar 3 é que a pediu.
172
+ * «start» BELONGS TO THE PAUSE, AND A CARTRIDGE DOES NOT TAKE IT (ADR-0144 §4).
81
173
  *
82
- * ⚠️ O arranque e o `mount()` recusam uma declaração malformada pela MESMA razão e com a mesma frase, e
83
- * escrevê-la duas vezes fez o teto de texto cru deste módulo subir de 15 para 16 — o crivo de i18n reprovou,
84
- * e reprovou com razão. Duas cópias de uma frase são dois sítios para ela divergir, e é também a segunda
85
- * tradução a fazer quando o pilar 3 chegar aqui.
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.
178
+ *
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.
86
181
  */
87
- function recusarDeclaracao(quem, problemas) {
88
- throw new Error(`${quem}: declaração malformada — ${problemas.join('; ')}`);
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);
89
187
  }
90
- /** Os ids que os painéis emprestados exigem do documento. Achado 6: sem eles o painel abre VAZIO, sem erro. */
91
- const MARCACAO_EXIGIDA = ['#game-region', '#sr-status', '#sr-alert'];
92
188
  /**
93
- * Onde a engine procura a barra de acessibilidade quando o jogo não declara `host.a11yBarHost`.
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);
209
+ }
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);
221
+ }
222
+ /** The ids the engine announces and draws into. Without them a child who listens hears nothing (finding 6). */
223
+ const REQUIRED_MARKUP = ['#game-region', '#sr-status', '#sr-alert'];
224
+ /**
225
+ * Where the engine looks for the accessibility bar when the game does not declare `host.a11yBarHost`.
226
+ *
227
+ * ⚠️ NOT IN `REQUIRED_MARKUP` on purpose, and the difference is one of message, not of rigour. That list produces «the
228
+ * page lacks #x», which is what one says of an id the game forgot. What is missing here is not an id — it is the whole
229
+ * bar, and its own sentence can say WHAT is lost, which is what makes it useful to whoever reads it first.
230
+ */
231
+ const A11Y_BAR_SELECTOR = '#title-icons';
232
+ /**
233
+ * Switches the engine on for a declared game.
94
234
  *
95
- * ⚠️ NÃO ENTROU NA `MARCACAO_EXIGIDA` de propósito, e a diferença é de mensagem e não de rigor. Aquela lista
96
- * produz «marcação ausente: #x», que é o que se diz de um id que o jogo esqueceu. Aqui o que falta não é um
97
- * id — é a barra inteira, e cinco jogos do catálogo não a têm porque ninguém lhes disse que a deviam ter. A
98
- * frase própria pode explicar O QUE se perde, e é isso que a torna útil a quem a lê pela primeira vez.
235
+ * ⚠️ THROWS if the declaration is malformed, and does NOT throw if markup is missing. The difference is not taste: a
236
+ * wrong declaration is a PROGRAM defect, and a game that runs half-declared is worse than one that does not open; a
237
+ * missing id is a gap of the HOST, and the quiz proved that switching on only the part that serves is legitimate —
238
+ * that is how it declined the pad and the sonar without lying. So one becomes an exception and the other `problems`.
99
239
  */
100
- const SELETOR_BARRA_A11Y = '#title-icons';
101
240
  export function createGame(o) {
102
241
  /*
103
- * O CARTUCHO CORRENTE, e por enquanto ele É as opções que chegaram.
242
+ * THE CURRENT CARTRIDGE, which starts as the options that arrived.
104
243
  *
105
- * ⚠️ Este passo não muda comportamento nenhum: `cartucho` começa como o próprio `o`, então toda leitura
106
- * abaixo devolve exatamente o que devolvia. O que ele compra é o LUGAR onde `mount()` vai escrever
107
- * (ADR-0142) — sem ele, as trinta e seis leituras da metade do jogo estão presas ao argumento, e uma raiz
108
- * de composição a servir vários cartuchos ficaria com o primeiro deles para sempre.
244
+ * 📌 Every read of the game's half goes through `cartridge`, which is the PLACE `mount()` writes (ADR-0142): a read
245
+ * tied to the argument would leave a root serving several cartridges with the first of them forever.
109
246
  */
110
- let cartucho = o;
111
- const problemasDoContrato = conformanceProblems(cartucho.declaration);
112
- if (problemasDoContrato.length) {
113
- recusarDeclaracao('createGame', problemasDoContrato);
247
+ let cartridge = o;
248
+ const contractProblems = conformanceProblems(cartridge.declaration);
249
+ if (contractProblems.length) {
250
+ refuseDeclaration('createGame', contractProblems);
114
251
  }
115
- const { doc, win } = o.host;
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);
258
+ const { doc } = o.host;
259
+ /*
260
+ * 🔴 THE WINDOW THIS ROOT LISTENS ON IS A SCOPED ONE, and it is not plumbing: a root installs about thirty listeners on the
261
+ * window and, until this line existed, NOTHING COULD TAKE THEM OFF. A root whose host was removed from the document kept
262
+ * listening, and because every query it makes is document-wide (`getPauseMenu` below is `doc.querySelector('#vp-pause-0')`) it
263
+ * drove the NEXT root's pause card: measured in the browser, one ArrowDown moved the cursor one item with one root, two with a
264
+ * second, three with a third. `dispose()` is the end of life this had never had. See `platform/listener-scope`.
265
+ */
266
+ const listeners = createListenerScope(o.host.win);
267
+ const win = listeners.win;
268
+ /*
269
+ * 🔴 AND THE REST OF WHAT THIS ROOT HOLDS ENDS WITH IT (ADR-0220): the window's scope never saw the STATE BUS, which keeps
270
+ * its subscribers for the whole page. So every `state.on` of this root goes through `stateOn`, and whatever a subscription
271
+ * started (the scan's frames, the recogniser, the camera) hands its stop to `whenDisposed`. `dispose()` runs them all;
272
+ * `unmount()` runs none, because it releases the cartridge and the root goes on hearing (ADR-0142).
273
+ */
274
+ const endOfLife = [];
275
+ const whenDisposed = (release) => { endOfLife.push(release); };
276
+ const stateOn = (evt, fn) => { const off = state.on(evt, fn); whenDisposed(off); return off; };
277
+ /*
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).
280
+ */
281
+ const translator = createTranslator();
282
+ for (const [code, entries] of Object.entries(o.dictionaries ?? {}))
283
+ translator.registerDict(code, entries);
284
+ // 📌 The root's own words go through ITS translator, which reads this game's dictionary (ADR-0232 D3).
285
+ const { t, bcp47 } = translator;
286
+ 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`.
291
+ */
292
+ const store = createStorage(hostStorage(o.host));
293
+ // THE CHILD'S STORED SETTINGS, FIRST (ADR-0178): nothing below reads or writes one before this.
294
+ // ⚠️ The port of ADR-0178 carries the key names beside the store, so `core` names no storage place itself.
295
+ const state = createSettingsStore({ ...store, KEYS });
296
+ // THIS ROOT'S INPUT STATE (ADR-0232 D4): held keys and their sources, the transport in use per player, the pads' frames.
297
+ // Destructured so the call sites below read as they did; a game reads the same object as `Engine.input`.
298
+ const input = createInputState();
299
+ 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);
116
309
  /*
117
- * ⚠️ LEITOR E NÃO INSTANTÂNEO — terceira vez que este ficheiro comete e conserta o mesmo padrão, depois do
118
- * `seguraTeclas` e do `players`. `declines` é da metade do JOGO (ADR-0139, errata de 2026-09-11: é o
119
- * cartucho que declara o que NÃO tem), então um `const` tirado no arranque devolve, depois de um `mount()`,
120
- * os declínios do cartucho anterior — e um declínio lido errado esconde uma linha de `problems` ou
121
- * inventa outra.
310
+ * ⚠️ A READER AND NOT A SNAPSHOT. `declines` belongs to the GAME's half (ADR-0139: the cartridge declares what it does
311
+ * NOT have), so a `const` taken at boot would return, after a `mount()`, the previous cartridge's declines — and a
312
+ * decline read wrong hides a line of `problems` or invents one.
122
313
  *
123
- * 📌 O vazio é uma constante e não um literal por chamada: `declines()` é lido em sítios que comparam.
314
+ * 📌 The empty value is a constant and not a literal per call: `declines()` is read in places that compare.
124
315
  */
125
- const SEM_DECLINIOS = {};
126
- const declines = () => cartucho.declines ?? SEM_DECLINIOS;
127
- const problemasDoHospedeiro = [];
316
+ const NO_DECLINES = {};
317
+ const declines = () => cartridge.declines ?? NO_DECLINES;
318
+ const hostProblems = [];
128
319
  const $ = (sel) => doc.querySelector(sel);
129
320
  const $$ = (sel) => [...doc.querySelectorAll(sel)];
130
- for (const sel of MARCACAO_EXIGIDA) {
321
+ /*
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.
326
+ */
327
+ const announcer = createAnnouncer({
328
+ doc,
329
+ raf: win.requestAnimationFrame, // as the host has it: the announcer answers a window with none (bound by the listener scope)
330
+ });
331
+ const { say: srSay, alert: srAlert } = announcer;
332
+ const libras = createLibras({ doc, win, store, now: () => Date.now() });
333
+ for (const sel of REQUIRED_MARKUP) {
131
334
  if (!$(sel))
132
- problemasDoHospedeiro.push(`marcação ausente: ${sel}`);
335
+ 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`);
133
336
  }
134
- // ⚠️ O MUNDO DECLARADO TEM DE EXISTIR NO DOCUMENTO, e esta é a falha que o ADR-0087 deixaria aberta se
135
- // parasse na conformidade. `conformanceProblems` confere a FORMA — que há um seletor e que ele não está
136
- // vazio — e não tem como conferir se ele CASA alguma coisa, porque `core/contract` é puro e não vê DOM.
337
+ // ⚠️ THE DECLARED WORLD MUST EXIST IN THE DOCUMENT, the gap ADR-0087 would leave open if it stopped at conformance.
338
+ // `conformanceProblems` checks the SHAPE — that there is a selector and it is not empty — and cannot check whether it
339
+ // MATCHES anything, because `core/contract` is pure and sees no DOM.
137
340
  //
138
- // Um seletor com erro de digitação (`#gaem-region`) passa na conformidade e produz exatamente o defeito
139
- // que o registro existe para eliminar: a simulação de empatia aplicada a NADA, e um adulto informado de
140
- // que sentiu algo que não sentiu. É um problema do HOSPEDEIRO e não do programa, então entra em
141
- // `problems` como as marcações — o jogo abre, e quem o integrou lê que o mundo dele não está lá.
142
- /*
143
- * AS TRÊS LINHAS DE `problems` QUE DEPENDEM DO CARTUCHO, juntas e recalculáveis.
144
- *
145
- * 📏 Medido: das oito que esta raiz produz, CINCO são sobre a PÁGINA — marcação ausente, sem host de
146
- * filtros, sem barra de acessibilidade, uma barra que não aceita conteúdo, sem sítio para a pausa — e
147
- * essas não mexem quando se troca de jogo, porque não é o jogo que as causa. Só estas três mexem.
148
- *
149
- * ⚠️ E é por isso que `problems` não podia continuar a ser UM array construído no arranque: metade dele
150
- * descreve o hospedeiro e vale para sempre, a outra metade descreve um cartucho e caduca no `mount()`.
151
- * Recalcular tudo apagaria diagnósticos do hospedeiro que ninguém consertou; não recalcular nada deixaria
152
- * o diagnóstico a falar do jogo errado.
153
- */
154
- function problemasDoCartucho() {
155
- const p = [];
156
- // O mundo declarado tem de existir na página — e quem o declara é o jogo, não o hospedeiro.
157
- const mundo = cartucho.declaration.world();
158
- if (mundo.kind === 'element' && !$(mundo.selector)) {
159
- p.push(`mundo declarado não encontrado: ${mundo.selector}`);
160
- }
161
- // ⚠️ MISTA, e fica deste lado por causa da segunda metade: a porta é do hospedeiro
162
- // (`carregarVozNeural`), mas o declínio é do CARTUCHO — logo a linha pode aparecer ou calar-se ao
163
- // trocar de jogo, com o mesmo hospedeiro.
164
- if (!o.carregarVozNeural && !declines().semVozNeural) {
165
- p.push('sem voz neural: declare `carregarVozNeural` (uma linha — ver ADR-0094) ou `declines.semVozNeural`. '
166
- + 'Sem ela a criança que não lê fica com a voz do sistema, que em Chromebook de escola pode não existir '
167
- + 'em português');
168
- }
169
- const assentos = (cartucho.players ?? []).length;
170
- if (assentos > 1 && !declines().semAtorDePausa && !cartucho.setPauseActor) {
171
- p.push(`declarou ${assentos} jogadores e não registra o ator da pausa: o painel de controle edita sempre o `
172
- + 'assento 0, então ninguém além do primeiro consegue remapear. Declare `declines.semAtorDePausa` se '
173
- + 'for de propósito');
174
- }
175
- return p;
341
+ // A selector with a typo (`#gaem-region`) passes conformance and produces exactly the defect that record exists to
342
+ // remove: the empathy simulation applied to NOTHING, and an adult told they felt something they did not. It is a
343
+ // HOST problem and not a program one, so it goes into `problems` like the markup — the game opens, and whoever
344
+ // integrated it reads that their world is not there.
345
+ /*
346
+ * `problems` HAS TWO HALVES. The HOST's lines (`hostProblems`) are measured once at boot and hold for as long as the
347
+ * page does: they are about the page, not the game, and do not change when a cartridge is swapped. The CARTRIDGE's
348
+ * lines are measured again at every read, so they follow `mount()`. Recomputing everything would drop host
349
+ * diagnoses nobody fixed; recomputing nothing would leave the diagnosis talking about the wrong game.
350
+ */
351
+ /**
352
+ * The VIRTUAL CONTROLLER's gaps for this cartridge. Starts empty and is replaced once the pad is mounted, below:
353
+ * `measureCartridgeProblems` runs only when `problems` is read, after boot, but reading the pad from here before it
354
+ * exists would fall into the temporal dead zone.
355
+ */
356
+ let padGapProblems = () => [];
357
+ /*
358
+ * 🔴 THE RULES LIVE IN `core/cartridge-problems` (ADR-0221 step 7c). What stays here is MEASURING the page and answering
359
+ * that module; what is a decision — the rules, the order of the lines, when to keep quiet — lives where a sentence can
360
+ * be read whole. A composition root is big on purpose and carries WIRING; branches are logic, and ADR-0221's erratum
361
+ * measures a root's debt in branches.
362
+ */
363
+ function measureCartridgeProblems() {
364
+ const declaredWorld = cartridge.declaration.world();
365
+ const worldCssSelector = declaredWorld.kind === 'element' ? declaredWorld.selector : null;
366
+ return cartridgeProblems({
367
+ padGaps: padGapProblems(),
368
+ resizedRegion: regionResizedByCartridge(),
369
+ drawnBelowFloor: drawnBelowTheFloor(drawingContext),
370
+ genreWarning: genreWarning(cartridge.genre),
371
+ }, {
372
+ worldSelector: worldCssSelector,
373
+ worldIsInPage: !!worldCssSelector && !!$(worldCssSelector),
374
+ wantsNeuralVoice: !!o.uses?.neuralVoice,
375
+ declinesNeuralVoice: !!declines().noNeuralVoice,
376
+ seats: (cartridge.players ?? []).length,
377
+ setsPauseActor: !!cartridge.setPauseActor,
378
+ declinesPauseActor: !!declines().noPauseActor,
379
+ });
176
380
  }
177
- // 1. IDIOMA ANTES DE TUDO. A interface não pode ser construída antes de a língua ser conhecida — foi o que
178
- // o item 14 consertou movendo `initI18n()` para o topo do boot. O documento entra: ver o achado 15.
381
+ // 1. LANGUAGE BEFORE EVERYTHING. The interface cannot be built before the language is known. The document goes in:
382
+ // see finding 15.
179
383
  initI18n(doc);
180
- // 2. MIXER ANTES DA VOZ. O achado 3, virado sequência: quem chama não tem como inverter estas duas linhas.
181
- initAudioMixer();
384
+ translator.applyDom(doc); // the host's markup, with THIS game's dictionary too — the module's pass reads only the page's
385
+ // 2. MIXER BEFORE VOICE. Finding 3 turned into sequence: `createAudio` loads the mixer, and the voice reads it.
386
+ // 🔴 THE BROWSER'S SOUND AND SPEECH ARE LENT HERE, from the host's window (ADR-0232 D4): the audio context — made at the
387
+ // first sound, never at boot, so it is born inside the child's gesture and the browser lets it run — and the speech
388
+ // synthesis the voice speaks through. The sonar's per-player contexts come from the same maker.
389
+ const audioHost = win;
390
+ const newAudioContext = () => {
391
+ const AC = audioHost.AudioContext ?? audioHost.webkitAudioContext;
392
+ return AC ? new AC() : null;
393
+ };
394
+ const speechHost = win;
395
+ const speech = {
396
+ synth: () => speechHost.speechSynthesis ?? null,
397
+ utterance: (text) => new speechHost.SpeechSynthesisUtterance(text),
398
+ };
399
+ /*
400
+ * 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
+ * ⚠️ IMPORTED AT THE FIRST NEURAL UTTERANCE AND NOT BEFORE: `kokoro-runtime` is what names espeak-ng and the ONNX runtime, so a
402
+ * game that never speaks neurally never loads a byte of them — which is the whole reason this is an `import()` and not an
403
+ * import. The page's address, `fetch` and WebAssembly are the host's.
404
+ */
405
+ const wasmHost = win.WebAssembly;
406
+ const loadKokoro = () => import('../platform/kokoro-runtime.js').then((m) => m.loadKokoroRuntime({
407
+ base: doc.baseURI,
408
+ fetch: (url) => win.fetch(url),
409
+ compileWasm: (bytes) => wasmHost.compile(bytes),
410
+ instantiateWasm: (module, imports) => wasmHost.instantiate(module, imports),
411
+ }));
412
+ const mixer = createAudio({ newContext: newAudioContext, store });
413
+ const { ensureAC, catNode, audioOut, setSoundOn, setVolume, tonePan, setCatGain, setHearingLossGraph } = mixer;
182
414
  const tts = createTts({
183
- srSay, srAlert, ensureAC, catNode, audioOut,
184
- getSoundOn: () => soundOn, getVolume: () => volume, getAudioCat: () => audioCat,
185
- carregarVozNeural: o.carregarVozNeural,
415
+ store, translator, srSay, srAlert, ensureAC, catNode, audioOut,
416
+ getSoundOn: () => mixer.soundOn, getVolume: () => mixer.volume, getAudioCat: () => mixer.audioCat,
417
+ neuralVoice: !!o.uses?.neuralVoice, // ADR-0216 §3: the game says it wants one; the engine loads it
418
+ loadKokoro,
419
+ getSpeechPpm: () => state.speechPpm, // ADR-0183 §1: the child's speech rate
420
+ createAudio: () => doc.createElement('audio'),
421
+ speech,
422
+ now: () => win.performance.now(),
186
423
  });
187
- // 📌 A linha da voz neural mudou-se para `problemasDoCartucho()`: o declínio que a cala é do jogo.
424
+ // 📌 The neural-voice line lives in `measureCartridgeProblems()`: the decline that silences it is the game's.
188
425
  /**
189
- * O FILTRO DE VISÃO, aplicado ao MUNDO QUE O JOGO DECLAROU (ADR-0087).
426
+ * THE VISION FILTER, applied to THE WORLD THE GAME DECLARED (ADR-0087).
190
427
  *
191
- * ⚠️ E A REGRA DOS MENUS É UMA GENERALIZAÇÃO, não uma segunda regra. O jogo próprio da engine limpava o
192
- * filtro em `#dom-layer` porque ele está DENTRO de `#game-region` e um filtro CSS herda — sem isso, uma
193
- * simulação de cegueira apagaria o menu de pausa e trancaria a criança dentro dela (#82). O 15-puzzle não
194
- * tem nada dentro, e a mesma linha não faz nada. Um só código serve às duas formas porque ele pergunta ao
195
- * DOM em vez de assumir a forma: limpa o filtro nos overlays que ESTEJAM dentro do mundo.
428
+ * ⚠️ THE RULE OF MENUS IS A GENERALISATION, not a second rule. A CSS filter is inherited, so a menu inside the world
429
+ * would be filtered with it — a blindness simulation would black out the pause menu and lock the child inside it
430
+ * (#82). A world with nothing inside it (the 15-puzzle) is untouched by the same line. One code serves both shapes
431
+ * because it asks the DOM instead of assuming the shape: it clears the filter on the overlays that ARE inside the
432
+ * world.
196
433
  *
197
- * ⚠️ `{kind:'none'}` NÃO APLICA NADA. Uma atividade sem espaço não tem mundo para simular, e pintar um
198
- * filtro sobre ela seria a mentira que o ADR-0087 existe para impedir, só que ao contrário.
434
+ * ⚠️ `{kind:'none'}` APPLIES NOTHING. An activity with no space has no world to simulate, and painting a filter over
435
+ * it would be the lie ADR-0087 exists to prevent, only backwards.
199
436
  */
200
- function aplicarFiltroDeVisao(css, alcance) {
201
- const mundo = cartucho.declaration.world();
202
- if (mundo.kind !== 'element')
437
+ function setVisionFilter(css, reach) {
438
+ const declaredWorld = cartridge.declaration.world();
439
+ if (declaredWorld.kind !== 'element')
203
440
  return;
204
- const el = $(mundo.selector);
441
+ const el = $(declaredWorld.selector);
205
442
  if (!el)
206
- return; // já reportado em `problems`; não se inventa superfície
443
+ return; // already reported in `problems`; no surface is invented
207
444
  el.style.filter = css;
208
- if (alcance === 'mundo') {
209
- // Os menus vivem POR CIMA da simulação e são o instrumento de sair dela: se herdaram o filtro por
210
- // estarem dentro do mundo, desfaz-se neles.
445
+ if (reach === 'mundo') {
446
+ // Menus live ABOVE the simulation and are the way out of it: if they inherited the filter by being inside the
447
+ // world, it is undone on them.
211
448
  for (const ov of $$(OVERLAY_SCOPE_SELECTOR)) {
212
449
  if (el.contains(ov))
213
450
  ov.style.filter = '';
214
451
  }
215
452
  }
216
453
  }
217
- // 3. A pilha de diálogos. O ctx é o mesmo em qualquer jogo — é boilerplate, e boilerplate repetido é onde
218
- // consumidores divergem sem querer.
454
+ /*
455
+ * THE WORLD'S FILTER IS A COMPOSITION: the 🚥's colour correction and the L→Q contrast enhancement (ADR-0151). Written
456
+ * apart, the second writer erased the first — turning the enhancement on turned off the correction a colour-blind
457
+ * child had set. Each writer keeps its part and asks for the composition.
458
+ */
459
+ // The visual state the WORLD shows: the correction (🚥) and the simulation (empathy mode) are two fields of it, and
460
+ // `filterKey` already knows the simulation runs only with the correction at its default (ADR-0076).
461
+ let worldState = DEFAULT_VISUAL;
462
+ /*
463
+ * A DISABILITY SIMULATION RUNS IN THE GAME, NEVER IN A MENU (issue #182). The Dev: «Simulação de deficiência não pode
464
+ * funcionar no menu! Só no jogo! Senão fica impossível desabilitar em certos casos.» Two rules make it so:
465
+ * · SUSPENDED while a menu is open — the pause card, a panel, the quick pause — and back when the child returns to play;
466
+ * · NEVER ON AN ANCESTOR OF A MENU: a CSS filter reaches every descendant, and clearing it on the child does not undo it.
467
+ * 📏 In the quiz the world is the whole region, menus inside it: a simulated blindness blacked out the empathy panel
468
+ * where it is turned off. So the filter goes on the world's parts that hold no menu, down to the world itself when
469
+ * it holds none (a canvas world).
470
+ */
471
+ /** Assigned once the menus exist (below): until then no menu can be open. */
472
+ let simulationSuspended = () => false;
473
+ /*
474
+ * 🔴 WHERE THE SIMULATION LANDS lives in `ui/simulation-over-the-world` (ADR-0221 step 7c). Going down into a box that
475
+ * holds a door, sending the layer to a canvas's parent because a canvas has no children, keeping what HELPS under what
476
+ * SIMULATES: those are rules with reasons, not wiring.
477
+ */
478
+ const simulationOverWorld = createSimulationOverTheWorld({
479
+ world: () => cartridge.declaration.world(),
480
+ find: (sel) => $(sel),
481
+ createCanvas: () => doc.createElement('canvas'),
482
+ });
483
+ function recomposeWorldFilter() {
484
+ // what HELPS (the colour correction, the contrast enhancement) stays on the world as before; the SIMULATION is laid apart
485
+ const enhancementKey = filterKey({ ...worldState, simulacao: null });
486
+ const enhancement = [enhancementKey ? (VIZ_FILTER[enhancementKey] ?? '') : '', lq.filter()].filter(Boolean).join(' ');
487
+ setVisionFilter(enhancement, 'mundo');
488
+ const simulation = simulationSuspended() ? null : worldState.simulacao;
489
+ simulationOverWorld.onlyInPlay(simulation ? (VIZ_FILTER[simulation] ?? '') : '', enhancement);
490
+ simulationOverWorld.drawLayer(simulation);
491
+ applyCrt(); // the decorative CRT yields to every visual mode, and comes back when none is on (ADR-0047)
492
+ }
493
+ /*
494
+ * 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.
497
+ */
498
+ const crt = createCrt({
499
+ region: () => $('#game-region'),
500
+ win,
501
+ // `cartucho` and not `players()`: this runs at boot, above the `players` declaration (temporal dead zone)
502
+ numPlayers: () => Math.max(1, (cartridge.players ?? []).length),
503
+ a11yVisualOn: () => filterKey(worldState) !== null || lq.t() > 0,
504
+ store,
505
+ });
506
+ const { apply: applyCrt, scanVars: crtScanVars } = crt;
507
+ const lq = createLqFilter({ doc, onChange: recomposeWorldFilter, store });
508
+ if (lq.t() > 0)
509
+ recomposeWorldFilter(); // the stored enhancement holds from boot
510
+ else
511
+ applyCrt(); // and the stored CRT too (the recompose above applies it when it runs)
512
+ // 3. The dialog stack. The ctx is the same in every game — it is boilerplate, and repeated boilerplate is where
513
+ // consumers diverge without meaning to.
219
514
  const overlays = initSettingsPanel({
220
- $, $$, doc,
515
+ t: translator.t, $, $$, doc,
221
516
  computedZ: (el) => +win.getComputedStyle(el).zIndex || 0,
222
517
  });
223
- // 4. Daltonismo: a engine ENTREGA o markup em vez de exigir que o consumidor o adivinhe (achado 7).
518
+ // 4. Colour vision: the engine HANDS OVER the markup instead of making the consumer guess it (finding 7).
224
519
  const cvdFilters = installCvdFilters(o.host.cvdHost ?? null);
225
520
  if (!cvdFilters)
226
- problemasDoHospedeiro.push('sem host de filtros (<svg>): a correção de daltonismo não foi montada');
227
- // 4c. A BARRA DE ACESSIBILIDADE DA PRIMEIRA TELA. Ver a nota em `EngineHost.a11yBarHost`: cinco dos seis
228
- // jogos do catálogo não têm nenhuma, e nada o dizia. Isto não a monta — diz que ela falta, que é o
229
- // passo que tira o silêncio. A frase nomeia a saída, como as outras deste bloco fazem.
230
- const a11yBar = o.host.a11yBarHost ?? $(SELETOR_BARRA_A11Y);
521
+ hostProblems.push('there is no filter host (<svg>): colour-vision correction was not mounted, so a colour-blind child cannot turn it on — set `host.cvdHost`');
522
+ // 4c. THE FIRST SCREEN'S ACCESSIBILITY BAR. See the note on `EngineHost.a11yBarHost`. This block only reports that it
523
+ // is missing, with a sentence that names the way out; the bar is mounted further below, once the icons exist.
524
+ const a11yBar = o.host.a11yBarHost ?? $(A11Y_BAR_SELECTOR);
231
525
  if (!a11yBar) {
232
- problemasDoHospedeiro.push(`sem barra de acessibilidade na primeira tela: declare \`host.a11yBarHost\` ou ponha um ${SELETOR_BARRA_A11Y} no documento. Sem ela a criança não alcança modo cego, TTS, alto contraste nem Libras antes de começar`);
526
+ hostProblems.push(`there is no accessibility bar on the first screen: a child cannot reach blind mode, narration or Libras before starting — set \`host.a11yBarHost\` or put a ${A11Y_BAR_SELECTOR} in the page`);
527
+ }
528
+ /*
529
+ * ⚠️ THE ENGINE MOUNTS IT (ADR-0106 §4, step 2): step 1 took from the game the duty of answering the seven incidental
530
+ * fields, and step 3 gave the menu a default list, so `PauseIconsCtx` demands nothing this root cannot answer.
531
+ *
532
+ * ⚠️ IT IS NOT `buildQuickBar`, and the difference has a reason: that one sets `tabIndex = -1` on the buttons because
533
+ * during play ten tab stops separate the child from the game (ADR-0044 item 7). On the first screen nobody is playing,
534
+ * and taking the icons out of the tab order there would hide them from whoever navigates by keyboard — exactly the
535
+ * person they exist for.
536
+ */
537
+ /**
538
+ * THE BLIND-MODE READER — and it must read WHERE THE DEFAULT WRITER WRITES.
539
+ *
540
+ * 🔴 The writer's default is the engine's (`ui/pause-icons`: `ctx.setBlindMode ?? setBlindModeValue`), which stores
541
+ * in `core/state`. A reader that answered a CONSTANT `false` made blind mode impossible to turn off in a game that does
542
+ * not inject `isBlindMode`: the first press turned it on; the reflection read `false` and the icon said off; the
543
+ * second press wrote `true` AGAIN, `core/state`'s equality guard returned early, and nothing happened. A game that
544
+ * starts describing everything aloud and never stops, with no error anywhere.
545
+ *
546
+ * 📌 ONE CONSTANT AND NOT THE EXPRESSION REPEATED IN TWO PLACES, because repetition IS the defect: two answers to the
547
+ * same question drift. The store's `blindMode` is a LIVE getter, so this reads the value of now and not of boot.
548
+ */
549
+ const readBlindMode = cartridge.isBlindMode ?? (() => state.blindMode);
550
+ /**
551
+ * WHAT THE ENGINE CAN ACT ON BY ITSELF IN THE PAUSE MENU — filled below, read when the pause opens.
552
+ *
553
+ * 🔴 Without it, a game that calls only `createGame` got a pause card of one item: with an empty table
554
+ * `itemsThatAct` keeps only the `ENGINE_ITEMS`, and `rootThatActs` also drops `options` — «uma porta para uma sala
555
+ * vazia». A pause menu with one item is not a pause menu.
556
+ *
557
+ * ⚠️ MUTABLE AND READ LATE, on purpose, which is what the field's laziness is for: `initPauseIcons` runs here and the
558
+ * panels mount further below. `refreshPauseItems` evaluates it when the pause OPENS, not at mounting; reading the
559
+ * table now would freeze an empty object.
560
+ *
561
+ * 📌 AND THE CARTRIDGE OVERRIDES, not the other way round: a game that brings its own entry wins over the engine's.
562
+ * What ADR-0122 makes non-declinable is that the pause EXISTS, not that the engine owns every item in it.
563
+ */
564
+ const engineActions = {};
565
+ /** Opens the game options panel, once mounted (ADR-0182). Offered as the door's action only while the cartridge declares rows. */
566
+ let openGameOptions = null;
567
+ let redrawGameOptions = () => { };
568
+ /*
569
+ * ===================== THE WAY OUT, BORN WITH THE WAY IN (ADR-0144, erratum) =====================
570
+ *
571
+ * 🔴 `resume` is not in `ENGINE_ITEMS`, so without this entry `itemsThatAct` cut «continuar» from the card of EVERY game
572
+ * that calls only `createGame`; and Escape at the card's root calls `setPhase('playing')` (`ui/menu-nav`), which with
573
+ * no hook was a no-op. Both go through `changePhase` now.
574
+ *
575
+ * ⚠️ OPENING A DOOR WITH NO WAY OUT IS WORSE THAN NOT OPENING IT. It is ADR-0106 §5 in so many words, and the child
576
+ * stuck on the card would be precisely the one who navigates without seeing, who has no mouse to fall back on.
577
+ *
578
+ * 📌 It also unlocks a third thing: `enterBarMode` in `ui/pause-icons` calls `resume` before handing the directional
579
+ * to the bar, so ADR-0044's item 7 is reachable from any game.
580
+ *
581
+ * 📌 THE CARTRIDGE STILL OVERRIDES (the order of the spread in `getPauseActs` does not change): a game with its own
582
+ * «continuar» — because resuming there means unfreezing physics, resuming audio and more — wins over this one. What the
583
+ * engine guarantees is that there is ALWAYS one.
584
+ */
585
+ // The screen footer (see `screenFooter`): declared HERE, before the first `changePhase`, which already clears it.
586
+ let footer = null;
587
+ let barExplanation = null;
588
+ // The quick-pause state and the button legend, declared before the first `changePhase` too: its `pauseControls.hide`
589
+ // refreshes the legend (ADR-0164 rule 3), and reading them earlier would be a temporal-dead-zone error at boot.
590
+ const inQuickPause = new Set();
591
+ // the menus and the quick pause exist from here on: a simulation is suspended while one is open (issue #182)
592
+ simulationSuspended = () => isMenuOpen() || inQuickPause.size > 0;
593
+ let pauseCaption = null;
594
+ function changePhase(p) {
595
+ // ⚠️ THE ENGINE CLOSES ITS CARD; THE GAME STILL DECIDES THE WORLD. It is the exact symmetry of ADR-0144 §2 from the
596
+ // other side: there the engine reveals and ASKS for the pause, here it hides and ASKS for the resume.
597
+ if (p !== 'paused') {
598
+ pauseControls.hide(0);
599
+ writeInFooter(null);
600
+ } // an item's reason does not stay over the game
601
+ cartridge.setPhase?.(p);
233
602
  }
603
+ engineActions.resume = () => changePhase('playing');
234
604
  /*
235
- * ⚠️ E AGORA A ENGINE MONTA-A (ADR-0106 §4, etapa 2). Até 2026-09-08 esta raiz só REPORTAVA a ausência, e o
236
- * registo dizia porquê: «reportar não é oferecer — cinco jogos continuam sem barra até alguém agir na
237
- * linha». A etapa 1 tirou dos sete campos acidentais a obrigação de virem do jogo, e a 3 deu lista padrão
238
- * ao menu; com isso o `PauseIconsCtx` deixou de exigir seja o que for que esta raiz não saiba responder.
605
+ * QUIT — «voltar à tela de press start» (the Dev's decision, 2026-09-12; erratum of ADR-0144 §5).
606
+ *
607
+ * 🔴 ADR-0144 §5 had left this OPEN on purpose, because the wrong answer loses a child's game: reload? `history.back()`?
608
+ * an activities menu that may not exist? The answer is none of the three — it is a PHASE the project already has a
609
+ * name and a contract for.
610
+ *
611
+ * ⚠️ AND THE EDGE IS STATED: `ui/shell`, which draws that screen, is deliberately not mounted by this root. The engine
612
+ * hides its card and ASKS for the phase; a game without the hook stays where it is. It is the same asymmetry as
613
+ * `setPhase('paused')` in ADR-0144 §2, and that is why the gate asserts the CALL.
239
614
  *
240
- * ⚠️ NÃO É `buildQuickBar`, e a diferença tem dono: aquele põe `tabIndex = -1` nos botões porque durante a
241
- * partida dez paradas de tabulação separam a criança do jogo (ADR-0044 item 7). Na primeira tela não se
242
- * está a jogar, e tirar os ícones da ordem de tabulação ali seria escondê-los de quem navega por teclado —
243
- * exactamente a pessoa para quem eles existem.
615
+ * 📌 NO CONFIRMATION, and that is a choice, not an omission. The ring puts `quit` one step from `resume` (ADR-0044
616
+ * item 1), which makes it easy to reach by mistake — but ADR-0037 decides: this project saves nothing, so what is lost
617
+ * is the current round and not progress. A confirmation dialog would cost one more stop in the scan of EVERY exit to
618
+ * protect what does not exist.
244
619
  */
620
+ engineActions.quit = () => changePhase('title');
621
+ /*
622
+ * PRINT — «ver a tela sem menus», and any button comes back.
623
+ *
624
+ * 📌 `ui/shell.printMode` does this, and `ui/shell` is not mounted by this root. But it needs no phase machine: it needs
625
+ * the cards, the window and the announcement — three things the engine has. Rewritten here with the SAME behaviour,
626
+ * including the delay.
627
+ *
628
+ * ⚠️ THE 80 ms ARE NOT SUPERSTITION: without them, the very event that TRIGGERED print is what undoes it — the child
629
+ * presses once and sees the clean screen blink.
630
+ *
631
+ * 📌 IN CAPTURE, not bubbling, because the point is to intercept BEFORE anyone else: in print mode the key belongs
632
+ * 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
+ * which bubbles: `goBack` reveals the card in the capture, and when the bubbling one arrives the card-already-open
634
+ * guard sends it away.
635
+ */
636
+ engineActions.print = () => {
637
+ const findPauseCard = () => $('#vp-pause-0');
638
+ const eventTarget = findPauseCard();
639
+ if (!eventTarget)
640
+ return;
641
+ eventTarget.hidden = true;
642
+ const goBack = (e) => {
643
+ if (e && typeof e.preventDefault === 'function') {
644
+ try {
645
+ e.preventDefault();
646
+ }
647
+ catch { /* noop */ }
648
+ }
649
+ win.removeEventListener('keydown', goBack, true);
650
+ win.removeEventListener('pointerdown', goBack, true);
651
+ const c = findPauseCard();
652
+ if (c)
653
+ c.hidden = false;
654
+ };
655
+ win.setTimeout(() => {
656
+ win.addEventListener('keydown', goBack, true);
657
+ win.addEventListener('pointerdown', goBack, true);
658
+ }, 80);
659
+ srSay(t('sr.print.on'));
660
+ };
245
661
  /**
246
- * O LEITOR DO MODO CEGO — e ele tem de ler ONDE O ESCRITOR PADRÃO ESCREVE.
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.
247
664
  *
248
- * 🔴 ESTAS DUAS METADES GANHARAM PADRÃO EM DIAS DIFERENTES E NÃO SE FALAVAM, o que produziu um defeito que
249
- * nenhum teste podia ver. O escritor recebeu o padrão da engine na etapa 1b do ADR-0106
250
- * (`ui/pause-icons` → `ctx.setModoCego ?? setModoCegoValue`), que grava no `core/state`. O leitor ficou com
251
- * o `() => false` que já cá estava — uma CONSTANTE. Num jogo que não injecta `isBlindMode`:
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.
673
+ */
674
+ let audio = null;
675
+ /*
676
+ * ⚠️ HOISTED ABOVE `initPauseIcons`, and the reason is ORDER, not tidiness: the bar decides WHICH ICONS it mounts at
677
+ * boot (`iconsThatAct`, resolved once), and the 11th — the typography cycle — exists only if the typography writer
678
+ * exists. That writer is born inside `if (pauseMountPoint && pauseUsable)`, further below; read there, the bar would
679
+ * already have decided.
252
680
  *
253
- * 1. a criança carrega no ícone → `setModoCego(!false)` → o modo LIGA de verdade;
254
- * 2. o reflexo lê `false` → o ícone diz «desligado» e o anúncio diz o mesmo;
255
- * 3. ela carrega outra vez → `setModoCegoValue(!false)` = `true` OUTRA VEZ → a guarda de igualdade do
256
- * `core/state` devolve cedo → nada acontece.
681
+ * 📌 Hoisting instead of repeating the question: `o.host.pauseHost ?? $('#game-region')` written in two places would be
682
+ * the same answer with two sources.
683
+ */
684
+ const pauseMountPoint = o.host.pauseHost ?? $('#game-region');
685
+ const pauseUsable = !!pauseMountPoint && typeof pauseMountPoint.appendChild === 'function';
686
+ /*
687
+ * ⚠️ `typo` IS HOISTED TOO, for the same reason: the bar's typography cycle, decided earlier, must reach it. It is still
688
+ * assigned below; what changed is the SCOPE, not the moment.
689
+ */
690
+ let typo = null;
691
+ /** The typography cycle's current position. See the note on `cycleTypography`, below. */
692
+ let typographyStep = CYCLE_START;
693
+ /*
694
+ * 🔴 THE PLAYERS ARE HOISTED HERE (issue #147), for an order-of-boot reason: `initPauseIcons` consumes `getPlayers()`
695
+ * EAGERLY (when building the audio sub-ctx), so a `players` declared lower down fell into the temporal dead zone and
696
+ * brought the boot down. And with the bar reading `cartridge.players ?? []` instead, a game that declares no players
697
+ * had ZERO seats for the bar and ONE for the keyboard, and the bar's cycles (🚥 correction, ☝️, ASD) stuck on the
698
+ * first position: the state had nowhere to be kept and each press re-read the default.
699
+ */
700
+ // ⚠️ THE BOOT SCHEME REACHES NOTHING, and says so with `null` instead of with an empty object (issue #118). It lives an
701
+ // instant — `assignControls()` just below replaces it with the real scheme — but while it lives it is a `KeyScheme` like
702
+ // any other, and the only honest form of a scheme that reaches nothing is fourteen declared absences. A `{}` made the
703
+ // type lie about being complete.
704
+ const withoutReach = Object.fromEntries(ACTIONS.map((a) => [a, null]));
705
+ // ⚠️ THE FALLBACK IS A CONSTANT and not a new literal per call: `getPlayers` is read by the keyboard runtime at every
706
+ // control read, and returning a new array each time would make any identity comparison lie — a defect that shows
707
+ // only in whoever compares, and late.
708
+ const withoutPlayers = [{ ctrl: withoutReach }];
709
+ // ⚠️ IT READS `cartridge.players`, NOT A SNAPSHOT: with several cartridges on one composition root (ADR-0142), a `const`
710
+ // taken at boot would leave the keyboard with the players of the cartridge that booted first.
711
+ const players = () => cartridge.players ?? withoutPlayers;
712
+ /*
713
+ * THE COLOUR-BLIND-SAFE PALETTE IN THE MENUS AND THE HUD (ADR-0151) — Okabe-Ito, through `:root[data-paleta]`.
257
714
  *
258
- * ⚠️ O modo cego ligava uma vez e NÃO HAVIA COMO DESLIGAR — um jogo que começa a descrever tudo em voz alta
259
- * e não se cala, sem erro em lado nenhum. Para quem não depende dele, é o jogo a ficar inutilizável.
715
+ * 📌 `core/state.cbSafe` stores, persists and notifies; this is its writer on the page. The colours, and the measure
716
+ * that chose them, are in the stylesheet (`style.css`, beside `data-cursiva`).
260
717
  *
261
- * 📌 UMA CONSTANTE E NÃO A EXPRESSÃO REPETIDA NOS DOIS SÍTIOS, porque a repetição É o defeito: duas
262
- * respostas à mesma pergunta divergem, e foi assim que esta divergiu. `import * as state` dá ligação VIVA,
263
- * então isto lê o valor de agora e não o do arranque.
718
+ * 🎯 THE RULE IS THE DEV'S, in their words: «ativada automaticamente quando se liga correção para protano,
719
+ * deutero e tritanopia e desativada automaticamente quando muda para visão padrão (tricromática). Aqui se
720
+ * permite ativá-la sem usar o filtro.» So the state is ONE (`cbSafe`), and the correction only pushes it.
721
+ */
722
+ const applySafePalette = (on) => {
723
+ if (on)
724
+ doc.documentElement.dataset.paleta = 'okabe-ito';
725
+ else
726
+ delete doc.documentElement.dataset.paleta;
727
+ };
728
+ applySafePalette(state.cbSafe);
729
+ stateOn('cbSafe', (v) => applySafePalette(Boolean(v)));
730
+ /**
731
+ * Wraps ANY correction writer — the cartridge's or the engine's: the palette follows the correction whoever applies it.
732
+ * ⚠️ Wrapping only the engine's would leave a game that corrects in its own render (`game-pinball`) without the palette
733
+ * the child asked for by pressing the same icon.
734
+ */
735
+ function withSafePalette(write) {
736
+ return (i, correction) => {
737
+ write(i, correction);
738
+ state.setCbSafeValue(correction !== 'tricro');
739
+ };
740
+ }
741
+ // 📌 ONE DOOR FOR BOTH, and the name says so since 2026-09-21: getUserMedia is what a page has to ask the camera AND the
742
+ // microphone for. It used to be called «temCamera» and the 👄 read it anyway — a name that describes half of what it answers is
743
+ // how a device with a headset and no webcam would have lost the voice for a reason nobody could see in the code.
744
+ const canCaptureMedia = typeof win.navigator?.mediaDevices?.getUserMedia === 'function';
745
+ /*
746
+ * ONE SET OF SCENE REDUCED-MOTION FLAGS, built here and handed to BOTH writers — the quick bar's calm icon and the motion
747
+ * panel (ADR-0232: state is built by the root). Left to themselves each read its own copy from storage, so the panel
748
+ * showed the scene animated after the calm mode reduced it, and its next switch stored that stale copy over the calm mode.
749
+ * The object is mutated in place; its identity is what the two share.
264
750
  */
265
- const lerModoCego = cartucho.isBlindMode ?? (() => state.modoCego);
751
+ const sceneMotion = readStoredScene(store, defaultReducedMotion(win.matchMedia));
752
+ const saveSceneMotion = () => { storeScene(store, sceneMotion); };
266
753
  const pauseIcons = initPauseIcons({
267
- doc,
754
+ translator, store,
755
+ settings: state, // the page's settings store itself: its live bindings are the reads the bar asks for (ADR-0232)
756
+ doc, matchMedia: win.matchMedia, // the reduced-motion default when nothing is stored (ADR-0232)
757
+ rm: sceneMotion, saveRM: saveSceneMotion,
268
758
  /*
269
- * A RESPOSTA DO JOGO, lida da declaração (ADR-0115). Sem ela o ícone `altmove` não é montado.
270
- *
271
- * ⚠️ LIDA UMA VEZ, NO ARRANQUE, e a razão não é economia — é a criança. O campo é uma FUNÇÃO porque o
272
- * ADR-0084 diz que um jogo muda de exigência entre fases, mas a COMPOSIÇÃO DA BARRA não pode mudar
273
- * debaixo da mão de quem está a usá-la: um ícone que aparece e some entre fases é pior do que um que
274
- * nunca esteve lá, e para quem navega por teclado desloca a ordem de tabulação a meio.
275
- * 📌 Logo a leitura correcta do contrato é «este jogo segura teclas em ALGUMA fase» — um jogo que segura
276
- * a pé e nada dentro de um veículo declara `true`, e o registo não disse isto porque a pergunta só
277
- * aparece quando se monta a barra.
759
+ * THE GAME'S ANSWER, read from the declaration (ADR-0115). Without it the `altmove` icon is not mounted.
760
+ *
761
+ * ⚠️ WHICH ICONS THE BAR HAS IS DECIDED ONCE, AT BOOT, and the reason is not economy — it is the child. The field is
762
+ * a FUNCTION because ADR-0084 says a game changes its demands between phases, but the bar's COMPOSITION must not
763
+ * change under the hand of whoever is using it: an icon that comes and goes between phases is worse than one that
764
+ * was never there, and for someone navigating by keyboard it shifts the tab order midway.
765
+ * 📌 So the right reading of the contract is: this game holds keys in SOME phase — a game that holds keys on foot
766
+ * and nothing inside a vehicle declares `true`.
278
767
  */
279
- // ⚠️ A REFERÊNCIA, e não o resultado. Chamar aqui congelava a resposta no arranque, e o
280
- // `reflectPauseIcons` — que existe porque a tabela de acções muda (ADR-0106 §5) — refrescava a partir
281
- // dela. Com vários cartuchos numa raiz de composição (ADR-0142) o ícone descrevia o primeiro deles.
282
- seguraTeclas: () => cartucho.declaration.seguraTeclas(),
283
- getPlayers: () => cartucho.players ?? [],
284
- getNumPlayers: () => (cartucho.players ?? [null]).length,
768
+ // ⚠️ THE REFERENCE, not the result: the ☝️'s cycle reads it again at each press, and with several cartridges on one
769
+ // composition root (ADR-0142) it must describe the mounted one.
770
+ holdsKeys: () => cartridge.declaration.holdsKeys(),
771
+ // how many positions this cartridge declared — what «one button only» would have to offer (ADR-0218, issue #201)
772
+ declaredPositions: () => (cartridge.preset ? presetActions(cartridge.preset).length : 0),
773
+ // the hourglass is offered where time runs by itself (ADR-0180), read per cartridge
774
+ clock: () => cartridge.declaration.tick === 'clock',
775
+ // the 📷 is offered where there is a camera to ask for (ADR-0215); the three camera controls below follow its position
776
+ camera: canCaptureMedia,
777
+ // and the 👄 where there is a MICROPHONE (issue #184) — the same door as the camera's, so the same answer
778
+ microphone: canCaptureMedia,
779
+ // the ☰, the bar's first icon (interface log 2026-09-16): the SELECT door, where there is a card to open. Hoisted, read at the press.
780
+ ...(pauseUsable ? { openMenus: (i) => { openSeatMenus(i); } } : {}),
781
+ // no voice speaks the current language: the narration icon locks like the panel's rows (ADR-0185)
782
+ noVoice: () => tts.voices().length === 0,
783
+ /*
784
+ * ✅ THE SAME LIST AS THE KEYBOARD (issue #147).
785
+ *
786
+ * 📏 With an empty list, `iconAct('cvd', i)` re-read `(P()[i] || {}).visual` as `undefined` at every press: the cycle
787
+ * STUCK on the first position while the icon announced different corrections — and the same for the ☝️ and ASD.
788
+ * Since ADR-0151 the correction also switches the safe palette, which is PERSISTED, so it stuck for good.
789
+ *
790
+ * ⚠️ `initPauseIcons` consumes this EAGERLY, which is why `players` is hoisted above this call. A game that declares
791
+ * no players has ONE child playing, not none.
792
+ */
793
+ getPlayers: () => players(),
794
+ getNumPlayers: () => players().length,
285
795
  srSay, srAlert,
286
- // ⚠️ NÃO `instanceof HTMLElement`: esse é um GLOBAL DO NAVEGADOR, e lê-lo onde ele não existe LANÇA —
287
- // não devolve falso. Escrito assim na etapa 2, fazia o `reflectPauseIcons` rebentar em qualquer ambiente
288
- // sem DOM. É o mesmo erro de forma do ACHADO 15 no cabeçalho deste ficheiro: alcançar o global por baixo
289
- // de quem injectou o documento. A pergunta certa é a mesma que o `barraUsavel` faz — sabe ser uma barra?
290
- getA11yBars: () => (barraUsavel && a11yBar ? [a11yBar] : []),
291
- getModoCego: lerModoCego,
292
- getAudioCat: () => audioCat,
796
+ // Leaving the bar is leaving the quick pause, by any door (ADR-0155). Hoisted function, read when called.
797
+ onLeaveBar: (i, silent) => endQuickPause(i, silent),
798
+ // What the pointed icon DOES goes to the footer (hoisted function, read when called).
799
+ explainIcon: (_i, k) => explainIconInFooter(k),
800
+ explainItem: (text) => writeInFooter(text),
801
+ // ⚠️ NOT `instanceof HTMLElement`: that is a BROWSER GLOBAL, and reading it where it does not exist THROWS — it does
802
+ // not return false. It is the same shape of error as FINDING 15 in this file's header: reaching the global underneath
803
+ // whoever injected the document. The right question is the one `barUsable` asks — can it be a bar?
804
+ getA11yBars: () => (barUsable && a11yBar ? [a11yBar] : []),
805
+ getBlindMode: readBlindMode,
806
+ getAudioCat: () => mixer.audioCat,
293
807
  setCatGain,
294
808
  /*
295
- * ⚠️ QUEM RESPONDE PELO APARELHO EM USO É A RAIZ, e é aqui que o autómato do ADR-0109 ganha o primeiro
296
- * leitor. `input/state.entradaDe(i)` devolve `PADRAO` para quem nunca produziu uma aresta, logo isto
297
- * nunca é `undefined` e o ícone nunca escreve numa chave torta.
809
+ * ⚠️ THE ROOT ANSWERS FOR THE DEVICE IN USE, and here ADR-0109's automaton gets its reader. `input/state.inputOf(i)`
810
+ * returns `DEFAULT_INPUT_STATE` for whoever never produced an edge, so this is never `undefined` and the icon never
811
+ * writes to a crooked key.
298
812
  *
299
- * 📌 E é a RAIZ que o passa, não o ícone que o importa: `ui/` a ler estado de módulo de `input/` seria
300
- * uma aresta nova entre camadas para poupar um argumento. A composição é o trabalho deste ficheiro.
813
+ * 📌 And the ROOT passes it rather than the icon importing it: `ui/` reading module state from `input/` would be a new
814
+ * edge between layers to save one argument. Composition is this file's job.
301
815
  */
302
- transporteEmUso: (i) => entradaDe(i).emUso,
303
- reflectTtsPanel: () => { },
304
- reflectTtsPanelEnabled: false,
305
- isLibrasOn: vlibrasOpen,
306
- toggleLibras,
816
+ 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),
307
822
  /*
308
- * ⚠️ O QUE O JOGO ENTREGA, E QUE ATÉ HOJE NÃO TINHA POR ONDE. Os três campos são opcionais dos dois
309
- * lados: ausentes, tudo se comporta como antes — tabela de acções vazia e os dois ícones visuais
310
- * não montados. Ver as notas em `CreateGameOptions` para o que a ausência custava.
823
+ * ⚠️ WHAT THE GAME HANDS OVER: the three fields are optional on both sides. Absent, the pause card holds only what the
824
+ * engine acts on, and the two visual icons follow their own rules. See the notes in `CreateGameOptions`.
311
825
  */
312
- ...(cartucho.getPauseActs ? { getPauseActs: cartucho.getPauseActs } : {}),
313
- ...(cartucho.setTemaDoJogador ? { setTemaDoJogador: cartucho.setTemaDoJogador } : {}),
314
- ...(cartucho.setCorrecaoDoJogador ? { setCorrecaoDoJogador: cartucho.setCorrecaoDoJogador } : {}),
826
+ // ⚠️ ALWAYS PASSED: a cartridge's absence means only what the engine acts on, which is what ADR-0106 §1 asks for.
827
+ getPauseActs: () => ({
828
+ ...engineActions,
829
+ ...(openGameOptions && cartridge.gameOptions?.length ? { opcoesdojogo: openGameOptions } : {}),
830
+ ...(cartridge.getPauseActs ? cartridge.getPauseActs() : {}),
831
+ }),
832
+ /*
833
+ * THE TYPOGRAPHY CYCLE OF THE 11th ICON (ADR-0149 §1, ADR-0150 §2).
834
+ *
835
+ * 🎯 ONE PRESS CHANGES THE CASE **AND** THE FACE, and that is the decision: `letterCase` (ADR-0028) and the face are
836
+ * two settings, and Andika in capitals is ONE pedagogical choice of whoever teaches reading. A child should not
837
+ * have to know the model to make it.
838
+ *
839
+ * 📌 THE POSITION LIVES HERE, in a `let` of the root, and not in `core/state`: it is derived — the case and the face
840
+ * are each persisted on their own —, and storing an index beside what it derives from is a third place for the
841
+ * three to drift. On reopening, the cycle restarts at the default position with the face that was left.
842
+ *
843
+ * ⚠️ THE COUNTRY'S HAND COMES FROM THE BCP-47 TAG of the current language, and the fallback is the COLONISER's
844
+ * (ADR-0150): Spanish → Spain, Portuguese → Portugal, English → England. A language outside the repertoire returns an
845
+ * empty list and the cycle has four positions — better one fewer than the hand of a country that is not that child's.
846
+ */
847
+ cycleTypography: pauseUsable ? () => {
848
+ // ⚠️ `typo` is read HERE and not in the condition: it is born further below, and the condition runs now. Whether
849
+ // the icon exists is decided by `pauseUsable`, the SAME question that decides whether the writer is born.
850
+ if (!typo)
851
+ return null;
852
+ const cycle = typographyCycle(bcp47());
853
+ typographyStep = (typographyStep + 1) % cycle.length;
854
+ const position = cycle[typographyStep];
855
+ state.setLetterCaseValue(position.letterCase);
856
+ typo.setFont(position.font, false);
857
+ /*
858
+ * ⚠️ THE SCALE IS ALWAYS WRITTEN, not only when it is above 1. Writing only on the way up would leave the country's
859
+ * hand in force after the child went back to Atkinson — the whole text 25% larger with nothing explaining it, and
860
+ * her pressing the button again to try to undo it.
861
+ * 📌 On the document root and not on `#game-region`: the base `font-size` belongs to `html,body`, and that is what
862
+ * this ratio multiplies.
863
+ */
864
+ doc.documentElement.style.setProperty('--fonte-escala', String(position.scale));
865
+ reserveBarBand(); // the name line under the bar grows with the text (issue #160)
866
+ return FONT_BY_KEY[position.font]?.fam ?? null;
867
+ } : undefined,
868
+ ...(cartridge.setPlayerTheme ? { setPlayerTheme: cartridge.setPlayerTheme } : {}),
869
+ /*
870
+ * 🚥 COLOUR-VISION CORRECTION HAS AN ENGINE DEFAULT (ADR-0148 §1), so the icon is never missing for want of a writer.
871
+ *
872
+ * 🎯 A FILTER NEEDS NOT KNOW THE GAME — the argument that makes this legitimate. It goes over whatever the game drew,
873
+ * the same way the `consumer-quiz` does by hand. What the engine CANNOT do is repaint textures, which is why the 🌗
874
+ * stays the game's (see the ADR-0148 erratum).
875
+ *
876
+ * ⚠️ AND ONLY IF THE FILTERS EXIST. Without `host.cvdHost`, `installCvdFilters` returns zero, `url(#cvd-fix-protan)`
877
+ * points at nothing and the icon would announce a correction that does not happen — a control that lies about its
878
+ * state, worse than one icon fewer (ADR-0106 §5). The `problems` line for that gap already exists.
879
+ *
880
+ * 📌 THE CARTRIDGE STILL WINS: a game that corrects colour in its own render (`game-pinball`, in a framebuffer) hands in
881
+ * its own and the engine steps aside.
882
+ */
883
+ ...(cartridge.setPlayerCorrection
884
+ ? { setPlayerCorrection: withSafePalette(cartridge.setPlayerCorrection) }
885
+ : cvdFilters
886
+ /*
887
+ * ⚠️ `filterKey` AND NOT `VIZ_FILTER[correction]`: the two vocabularies are DIFFERENT. The axis says `protan`;
888
+ * `VIZ_FILTER` knows `fix-protan`. Translated by hand, the lookup gave `undefined`, the filter came out empty and
889
+ * the icon ANNOUNCED a correction that did not happen — exactly the control lying about its state.
890
+ * 📌 And `filterKey` does more than glue a prefix: it puts the SIMULATION ahead of the correction when there is
891
+ * one, a rule this module should not reinvent.
892
+ */
893
+ ? { setPlayerCorrection: withSafePalette((i, correction) => {
894
+ /*
895
+ * 🔴 STORE BEFORE APPLYING: applying alone left the cycle STUCK on the first position. The next step is computed
896
+ * by `nextCorrection(p.visual)` in `ui/pause-icons`; without writing it back, every press re-read the default and
897
+ * returned `protan`.
898
+ * ⚠️ No error at all: the icon announced the right correction, the filter changed the first time, and the child
899
+ * pressed twice more to see the same screen. Caught by a surviving MUTATION — always apply, never clear — that stayed
900
+ * green because the case pressed only once.
901
+ */
902
+ // ⚠️ `players()`, the SAME list `ui/pause-icons` reads (`getPlayers`, above) to compute the next step: written
903
+ // anywhere else, the state would land where nobody reads it again.
904
+ const player = players()[i];
905
+ // A correction switched ON stops the demonstration (ADR-0076): a simulation over an adaptation teaches something false.
906
+ const before = player?.visual ?? worldState;
907
+ const nextVisual = { ...before, correcao: correction, simulacao: correction === 'tricro' ? before.simulacao : null };
908
+ if (player)
909
+ player.visual = nextVisual;
910
+ worldState = nextVisual;
911
+ recomposeWorldFilter();
912
+ }) }
913
+ : {}),
315
914
  });
316
915
  /*
317
- * ⚠️ O HOSPEDEIRO TEM DE SABER SER UMA BARRA, e perguntar isso não é zelo: `a11yBarHost` é `Element` no
318
- * tipo, e um consumidor pode passar um duplo, um nó de outro documento, ou um elemento de um `<svg>`. Sem
319
- * esta guarda, um objecto sem `addEventListener` derruba o BOOT INTEIRO — e derrubá-lo por causa da barra
320
- * de acessibilidade seria tirar o jogo a toda a gente para não o dar a ninguém.
916
+ * ⚠️ THE HOST MUST BE ABLE TO BE A BAR, and asking is not fussiness: `a11yBarHost` is `Element` in the type, and a
917
+ * consumer may pass a double, a node from another document, or an element of an `<svg>`. Without this guard, an object
918
+ * without `addEventListener` brings the WHOLE BOOT down — and bringing it down for the accessibility bar would take
919
+ * the game from everyone so as to give it to no one.
321
920
  *
322
- * Não sabendo, é `problems` como qualquer outra lacuna do hospedeiro: o consumidor lê e conserta.
921
+ * If it cannot, it is `problems` like any other gap of the host: the consumer reads and fixes.
323
922
  */
324
- const barraUsavel = !!a11yBar
923
+ const barUsable = !!a11yBar
325
924
  && typeof a11yBar.addEventListener === 'function'
326
925
  && 'innerHTML' in a11yBar;
327
- if (a11yBar && !barraUsavel) {
328
- problemasDoHospedeiro.push('o elemento da barra de acessibilidade não aceita conteúdo nem clique: os ícones não foram montados');
926
+ if (a11yBar && !barUsable) {
927
+ hostProblems.push('the accessibility bar element takes neither content nor clicks: its icons were not mounted, so a child cannot reach them — give `host.a11yBarHost` a real element');
329
928
  }
330
- if (a11yBar && barraUsavel) {
331
- a11yBar.innerHTML = iconsMarkup(pauseIcons.iconesMontados);
929
+ if (a11yBar && barUsable) {
930
+ // 🔴 WITH THE NAME CAPTION under the row: the bar the engine mounted had none, and the Dev saw it mute when navigating
931
+ // and hovering. `aria-hidden` because the name is already SPOKEN (`srSay` on the cursor, the `aria-label` on focus).
932
+ a11yBar.innerHTML = iconsMarkup(translator, pauseIcons.mountedIcons) + '<p class="pause-icons-cap" aria-hidden="true"></p>';
933
+ wireBarCaption(a11yBar, explainIconInFooter);
332
934
  a11yBar.addEventListener('click', (e) => {
333
- const botao = e.target?.closest('.pi-btn');
334
- if (!botao)
935
+ const rowButton = e.target?.closest('.pi-btn');
936
+ if (!rowButton)
335
937
  return;
336
- pauseIcons.iconAct(botao.dataset.pi ?? '', 0);
938
+ pauseIcons.iconAct(rowButton.dataset.pi ?? '', 0);
337
939
  pauseIcons.reflectIconsIn(a11yBar, 0);
338
- // O anúncio lê o `aria-label` DEPOIS do reflexo, porque é ele que carrega o estado NOVO — anunciar
339
- // antes diria o estado que a criança acabou de deixar.
340
- srSay(botao.getAttribute('aria-label') ?? '');
940
+ const caption = a11yBar.querySelector('.pause-icons-cap');
941
+ if (caption)
942
+ caption.textContent = accessibleLabel(rowButton); // the NEW state, after the reflection; «N de M» is spoken, never written (ADR-0167)
943
+ // The announcement reads the `aria-label` AFTER the reflection, because that carries the NEW state — announcing
944
+ // before would say the state the child just left.
945
+ srSay(rowButton.getAttribute('aria-label') ?? '');
341
946
  });
342
947
  pauseIcons.reflectIconsIn(a11yBar, 0);
343
948
  /*
344
- * ⚠️ E OUTRA VEZ QUANDO O IDIOMA DO ARRANQUE CHEGAR — sem isto a barra fica no idioma de RECUO.
345
- *
346
- * O `initI18n` aplica pt de forma síncrona (para a página nunca ficar em branco) e, se o idioma
347
- * preferido for outro, PEDE a troca — que é assíncrona, porque en/es são chunks sob demanda. Esta
348
- * marcação nasce nesse intervalo. 📏 Medido num navegador em 2026-09-08, com `lang="en"`: a barra
349
- * servia cinco rótulos em inglês e três ainda em português, na mesma linha de ícones.
350
- *
351
- * 📌 O `idiomaPronto()` existe exactamente para isto, e o cabeçalho dele já descreve o defeito noutro
352
- * lugar: «o `applyDom` conserta o markup ESTÁTICO, mas o que o JavaScript monta tinha capturado o
353
- * texto de pt e ninguém reconstruía». A barra é a instância nova, criada quando a ENGINE passou a
354
- * montá-la (ADR-0106 etapa 2).
355
- *
356
- * ⚠️ É O `idiomaPronto()` E NÃO O EVENTO `i18n:change`, de propósito: o evento é a troca de idioma EM
357
- * EXECUÇÃO, e o próprio `core/i18n` declara essa pergunta como sendo do Dev («QUANDO a interface se
358
- * reconstrói ao trocar de idioma em execução»). Isto responde só a pergunta do ARRANQUE, que aquele
359
- * mesmo comentário diz não ter duas respostas.
949
+ * ⚠️ AND AGAIN WHEN THE BOOT LANGUAGE ARRIVES — without it the bar stays in the FALLBACK language.
950
+ *
951
+ * `initI18n` applies pt synchronously (so the page is never blank) and, if the preferred language is another, ASKS
952
+ * for the switch — which is asynchronous, because en/es are on-demand chunks. This markup is born in that interval.
953
+ *
954
+ * 📌 IT IS THE `i18n:change` LISTENER near the pad that repaints it (study item C6, ADR-0031): the boot's preferred
955
+ * language arrives through `setLocale`, which dispatches that same event, so one path serves the boot and a change
956
+ * made mid-game.
360
957
  */
361
- void idiomaPronto().then(() => { pauseIcons.reflectIconsIn(a11yBar, 0); });
362
958
  /*
363
- * ⚠️ E ELA TEM DE CONTINUAR A DIZER A VERDADE quando o estado muda NOUTRO SÍTIO. O modo cego liga-se
364
- * também pelo painel de áudio e pela simulação de empatia; sem esta assinatura, o ícone da barra ficaria
365
- * a dizer «desligado» com `aria-pressed=false` depois de a criança o ter ligado — o controlo a mentir o
366
- * estado, que é a família de defeito que o `reflectTTS` e o `#opt-modocego` já custaram a este projeto.
367
- *
368
- * 📌 SÓ O MODO CEGO, e a limitação é medida e não preguiça: dos ícones que esta raiz monta, ele é o ÚNICO
369
- * cujo estado tem evento (`EventoDoJogo` tem `modoCego`; TTS, Libras, TEA e alternância não emitem nada).
370
- * Os outros continuam a refletir-se ao clique, que é o caminho por onde hoje eles mudam.
959
+ * ⚠️ AND IT MUST GO ON TELLING THE TRUTH when a state changes ELSEWHERE. Blind mode is also switched by the hearing
960
+ * panel and by the empathy simulation; without this subscription, the bar's icon would go on saying off with
961
+ * `aria-pressed=false` after the child had turned it on — the control lying about its state.
962
+ *
963
+ * 📌 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.
371
965
  */
372
- state.on('modoCego', () => { pauseIcons.reflectIconsIn(a11yBar, 0); });
966
+ stateOn('blindMode', () => { pauseIcons.reflectIconsIn(a11yBar, 0); });
967
+ // the 👀 changes elsewhere too: the eye control puts it back to off when the camera or the files are missing (ADR-0213)
968
+ stateOn('cameraControl', () => { pauseIcons.reflectIconsIn(a11yBar, 0); }); // a mode that cannot start puts the 📷 back to off
969
+ // 🔴 AND THE 👄 FOR THE SAME REASON, measured in a browser on 2026-09-21: with the microphone refused, the child heard «it did
970
+ // not open», the stored answer went back to off — and the button went on saying «ligado». A control that lies about its state
971
+ // is worse than a missing one (ADR-0106 §5), and the click path does not cover it, because this change comes from elsewhere.
972
+ stateOn('voiceControl', () => { pauseIcons.reflectIconsIn(a11yBar, 0); });
373
973
  }
374
- // 4d. QUEM ABRIU A PAUSA, quando há mais de um assento — o achado 3 da auditoria do `game-soccer`.
974
+ // 4d. WHO OPENED THE PAUSE, when there is more than one seat — finding 3 of the `game-soccer` audit.
375
975
  //
376
- // ⚠️ O PAINEL DE CONTROLE É PARAMETRIZADO PELO ASSENTO: `render(selPlayer)` desenha as posições DAQUELE
377
- // esquema, e não há selector de assento — o `#ctrl-players` é uma FRASE, não abas. Quem decide o assento é
378
- // o consumidor, passando o ator da pausa: «edita o controle de quem abriu o menu».
976
+ // ⚠️ THE KEYBOARD PANEL IS PARAMETERISED BY THE SEAT: `render(selPlayer)` draws the positions of THAT scheme, and the
977
+ // consumer decides the seat by passing the pause actor: the panel edits the controls of whoever opened the menu.
379
978
  //
380
- // ✅ E ISTO DEIXOU DE SER MUDO. O `setPauseActor` desta raiz era `() => {}` LITERAL, sem campo por onde um
381
- // jogo o entregar: a linha abaixo dizia «conserte» sem haver por onde, e a única saída era declarar
382
- // `semAtorDePausa`, que é aceitar a perda em vez de a corrigir. Agora ela só acusa quem NÃO respondeu —
383
- // que é o que uma linha de `problems` deve fazer, pelo §2 do ADR-0106.
384
- // 📌 A linha do ator da pausa mudou-se para `problemasDoCartucho()`: quem declara assentos é o jogo.
385
- /*
386
- * 4e. O CARTÃO DE PAUSA DA PRIMEIRA TELA — e isto fecha um LAÇO QUE ESTAVA ABERTO.
387
- *
388
- * 📏 MEDIDO EM 2026-09-08, nos seis jogos do catálogo local: `#vp-pause-0` é procurado por esta raiz (o
389
- * `getPauseMenu` do `initMenuNav`, mais abaixo) e **NENHUM jogo o cria**. `git grep vp-pause` devolve zero
390
- * em `game-platformer`, `game-soccer`, `pixi-15-puzzle`, `2048`, `whackwhack` e `game-chess`. Ou seja: a
391
- * engine inventou uma convenção, procurou-a, não a achou, e concluiu em silêncio que nenhum jogo tem menu
392
- * de pausa — que é a MESMA forma de defeito do ADR-0106 §2, desta vez cometida pela engine contra si mesma.
393
- *
394
- * Agora ela cria o que procura — e para TODO jogo, desde o ADR-0120 e outra vez desde o ADR-0122: o
395
- * declínio saiu, porque a razão de ele existir foi construída fora por este mesmo ADR-0106, e porque a
396
- * regra é do Dev — a pausa e os ícones de acessibilidade estão em todo jogo, logo são da engine.
397
- */
398
- const hospedeiroDaPausa = o.host.pauseHost ?? $('#game-region');
399
- const pausaUsavel = !!hospedeiroDaPausa && typeof hospedeiroDaPausa.appendChild === 'function';
400
- if (!pausaUsavel) {
401
- problemasDoHospedeiro.push('sem sítio para o menu de pausa: declare `host.pauseHost` ou tenha um #game-region que aceite filhos. '
402
- + 'Sem ele a criança não alcança os ajustes durante a partida — e NÃO há como declinar: desde o '
403
- + 'ADR-0122 a pausa é da engine em todo jogo, e o que este jogo declara é só ONDE ela cabe');
404
- }
405
- if (hospedeiroDaPausa && pausaUsavel) {
406
- const cartao = pauseIcons.buildScreenPause(0);
407
- // ⚠️ O ID É O QUE A PRÓPRIA ENGINE PROCURA, logo abaixo, no `getPauseMenu`. Montar sem o pôr deixaria o
408
- // laço tão aberto como estava — o cartão existiria e a navegação de menu continuaria a não o achar.
409
- cartao.id = 'vp-pause-0';
410
- hospedeiroDaPausa.appendChild(cartao);
411
- }
412
- // 4b. NAVEGAÇÃO SONORA. Só o contrato entra: nada de tile, caixa de colisão ou array de moedas.
979
+ // 📌 The pause-actor line of `problems` lives in `measureCartridgeProblems()`: whoever declares seats is the game, and
980
+ // the line accuses only a game that did not answer (ADR-0106 §2).
981
+ /*
982
+ * 4e. THE FIRST SCREEN'S PAUSE CARD. `#vp-pause-0` is what this root looks for (`getPauseMenu` of `initMenuNav`, below),
983
+ * and games did not create it: the engine had invented a convention, looked for it, not found it, and concluded in
984
+ * silence that no game had a pause menu — the same shape of defect as ADR-0106 §2, committed by the engine against
985
+ * itself. So the engine creates what it looks for, for EVERY game (ADR-0120, ADR-0122): the pause and the
986
+ * accessibility icons are in every game, so they are the engine's.
987
+ */
988
+ // (`pauseMountPoint` and `pauseUsable` are hoisted above `initPauseIcons` — see the note there. They depend only on
989
+ // `o.host` and `$`, and the bar needs the answer BEFORE deciding which icons it mounts.)
990
+ if (!pauseUsable) {
991
+ hostProblems.push('the pause menu has nowhere to mount: a child cannot reach the settings during play — set `host.pauseHost` or give '
992
+ + '#game-region room for children (the pause is the engine\'s in every game, ADR-0122; the game only says where it fits)');
993
+ }
994
+ if (pauseMountPoint && pauseUsable) {
995
+ const findPauseCard = pauseIcons.buildScreenPause(0);
996
+ // ⚠️ THE ID IS WHAT THE ENGINE ITSELF LOOKS FOR, just below, in `getPauseMenu`. Mounting without it would leave the
997
+ // loop as open as it was — the card would exist and menu navigation would still not find it.
998
+ findPauseCard.id = 'vp-pause-0';
999
+ pauseMountPoint.appendChild(findPauseCard);
1000
+ }
1001
+ /*
1002
+ * 4f. THE SETTINGS PANELS (ADR-0106 §1).
1003
+ *
1004
+ * 🔴 `ui/panel-shell.mountShell` BUILDS a panel's shell, and each `ui/settings-*` fills the INSIDE of ids somebody
1005
+ * must create. Left to the page, the failure took the worst form available — the quiz recorded it as finding 6: the
1006
+ * panel opens EMPTY, with no error. So the engine mounts each shell (`ui/mount-panel`) and then its writer.
1007
+ *
1008
+ * ⚠️ AND THE PANELS LIVE WHERE THE CARD LIVES, which is not tidiness: `ui/settings-panel.topVisibleOverlay` scans
1009
+ * `'#game-region .overlay'` (`OVERLAY_SCOPE_SELECTOR`), and that is how `ui/menu-nav` reaches the top dialog to move
1010
+ * with the arrows. A panel hung outside that scope OPENS, closes with Escape — and **the arrows do not move inside
1011
+ * it**, with no error at all. Hence the `problems` line below instead of silence.
1012
+ */
1013
+ if (pauseMountPoint && pauseUsable) {
1014
+ const regionEl = $('#game-region');
1015
+ const insideScope = !!regionEl && typeof regionEl.contains === 'function'
1016
+ && regionEl.contains(pauseMountPoint);
1017
+ if (!insideScope) {
1018
+ hostProblems.push('the pause host is outside #game-region: settings panels open, but arrows do not move inside them, so a child '
1019
+ + 'who plays by keyboard cannot reach the settings — put `host.pauseHost` inside #game-region');
1020
+ }
1021
+ const panelCtx = {
1022
+ find: (sel) => $(sel),
1023
+ create: (tag) => doc.createElement(tag),
1024
+ host: pauseMountPoint,
1025
+ overlays,
1026
+ localeOn, // a language change redraws an open panel, and `dispose()` releases it (ADR-0232 D4)
1027
+ };
1028
+ /*
1029
+ * TYPOGRAPHY — NO PANEL since ADR-0151: «quem escolhe a tipografia é o jogo, o jogador escolhe suas fontes via o
1030
+ * menu de acessibilidade rápida». Its door left the inclusion settings and its shell is not mounted — a dialog in the
1031
+ * document that nobody reaches is the defect ADR-0144 measured.
1032
+ *
1033
+ * 📌 BUT THE WRITER STAYS, which is why the `init` is still here: the 11th button's cycle writes the face through this
1034
+ * API (`typo.setFont`), and the `init` applies at boot the font the child left stored. `initSettingsTypo` guards each
1035
+ * access to the document, so it runs without the shell.
1036
+ */
1037
+ typo = initSettingsTypo({
1038
+ t: translator.t, $, srSay, store, root: doc.documentElement,
1039
+ // The rows' prose goes to the footer at EVERY render, or it appears twice on the first click.
1040
+ 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
+ : {}),
1047
+ });
1048
+ /*
1049
+ * AAC — COMMUNICATION: THE PANEL IS NOT MOUNTED (ADR-0151).
1050
+ *
1051
+ * 🔴 The «Comunicação» door left the inclusion settings: the letter case moves with the bar's 11th button, which is the
1052
+ * COMMUNICATION cycle (with ARASAAC and PCS disabled). Mounting a panel with no door would leave in the document a
1053
+ * dialog nobody reaches — the defect ADR-0144 measured. The `ui/settings-aac` module stays in the engine for whoever
1054
+ * wants to mount it.
1055
+ */
1056
+ /*
1057
+ * HELP — which button does what, IN THIS game, on THIS child's keyboard (ADR-0147 §4).
1058
+ *
1059
+ * 🔴 The `ajuda` item has been on the pause list since ADR-0044 and the engine could not act on it, so `itemsThatAct`
1060
+ * hid it in every game. The engine draws it now, so no game has to write its own.
1061
+ *
1062
+ * ⚠️ IT IS NOT MOUNTED WITHOUT `howToPlay` OR `preset`, and the absence is the right answer: without the game's words,
1063
+ * the table could only show `action2` — an identifier in front of a child, the defect ADR-0074 forbids in so many
1064
+ * words. Better no help than one that cannot be read.
1065
+ *
1066
+ * 📌 THE KEY COMES FROM `kbFor(0)`, not the factory map: whoever remapped sees HER key. It is the same reason ADR-0144
1067
+ * listens to the action and not the key.
1068
+ */
1069
+ // The cartridge's «how to play» slides come first (ADR-0195; issue #188); the help stands with them, with the buttons, or both.
1070
+ if (cartridge.preset || cartridge.howToPlay?.length) {
1071
+ let stopFigure = () => { };
1072
+ const helpPanel = mountPanel(panelCtx, {
1073
+ id: 'help',
1074
+ labels: () => ({
1075
+ title: t('menu.help'),
1076
+ listLabel: t('help.grupo.rotulo'),
1077
+ resetLabel: t('menu.restoreDefaults'),
1078
+ closeLabel: t('pause.pmback'),
1079
+ }),
1080
+ // ⚠️ `render` AND NOT A ONE-OFF MOUNTING: the preset may change with another cartridge's `mount()` (ADR-0142) and
1081
+ // the child may have remapped between two openings. A table built at boot would show yesterday's key — the exact
1082
+ // shape of the control lying about its state.
1083
+ // The slide show opens on its first slide (interface log, 2026-09-13; `ui/help-panel`).
1084
+ render: () => {
1085
+ const list = $('#help-list');
1086
+ if (!list)
1087
+ return;
1088
+ while (list.firstChild)
1089
+ list.removeChild(list.firstChild);
1090
+ const slideContents = [...(cartridge.howToPlay ?? []), ...helpRows(cartridge.preset, (a) => keyboard.kbFor(0)[a], (code) => keyName(t, code))];
1091
+ const ctxDoSlide = { create: (tag) => doc.createElement(tag), t, title: t('menu.help') };
1092
+ const slides = mountSlides(ctxDoSlide);
1093
+ list.appendChild(slides);
1094
+ const slideTimer = {
1095
+ requestFrame: (cb) => win.requestAnimationFrame(cb),
1096
+ cancelFrame: (id) => win.cancelAnimationFrame(id),
1097
+ reduced: defaultReducedMotion(win.matchMedia),
1098
+ };
1099
+ const showSlideAt = (i) => {
1100
+ stopFigure();
1101
+ const shown = showSlide(slides, slideContents, i, ctxDoSlide);
1102
+ const slide = slideContents[shown.index];
1103
+ if (slide && 'text' in slide)
1104
+ stopFigure = animateFigure(slides, slide, slideTimer);
1105
+ return shown;
1106
+ };
1107
+ let currentSlide = showSlideAt(0).index;
1108
+ slides.addEventListener('passo', (ev) => {
1109
+ const fresh = nextStep(currentSlide, slideContents.length, ev.detail);
1110
+ if (fresh === currentSlide)
1111
+ return;
1112
+ const shown = showSlideAt(fresh);
1113
+ currentSlide = shown.index;
1114
+ srSay(shown.spoken);
1115
+ });
1116
+ },
1117
+ });
1118
+ // A slide show restores nothing: the reset row the panel shell builds does not show here.
1119
+ const helpActions = helpPanel.shell.reset.parentElement;
1120
+ if (helpActions)
1121
+ helpActions.hidden = true;
1122
+ engineActions.ajuda = helpPanel.open;
1123
+ }
1124
+ else {
1125
+ hostProblems.push('the help screen was not mounted: it shows how to play and each position, its key and the game\'s word, and without '
1126
+ + '`howToPlay` or `preset` it could only show a child `action2` — declare `howToPlay` (ADR-0195) or `preset` (ADR-0085)');
1127
+ }
1128
+ /*
1129
+ * OPÇÕES DO JOGO — the cartridge's rows, drawn by the engine (ADR-0182; issue #178).
1130
+ * 📌 Drawn at every opening and at every `mount()`: the rows are the CURRENT cartridge's, and each value is read from it.
1131
+ * The shell's «restore defaults» is hidden: a cartridge declares no defaults, and a button that does nothing is the
1132
+ * dead control ADR-0106 §5 forbids.
1133
+ */
1134
+ const gamePanel = mountPanel(panelCtx, {
1135
+ id: 'game-options',
1136
+ labels: () => ({
1137
+ title: t('pause.opcoesdojogo'),
1138
+ listLabel: t('pause.opcoesdojogo'),
1139
+ resetLabel: t('menu.restoreDefaults'),
1140
+ closeLabel: t('pause.pmback'),
1141
+ }),
1142
+ render: () => redrawGameOptions(),
1143
+ });
1144
+ gamePanel.shell.reset.hidden = true;
1145
+ redrawGameOptions = () => {
1146
+ drawGameOptions({ ...panelCtx, say: srSay, t: translator.t }, gamePanel.shell.list, cartridge.gameOptions ?? []);
1147
+ if (!gamePanel.shell.overlay.hidden)
1148
+ overlays.fillExplain(gamePanel.shell.card);
1149
+ };
1150
+ openGameOptions = gamePanel.open;
1151
+ /*
1152
+ * ANIMATION — motion sensitivity.
1153
+ *
1154
+ * 📌 `rm`, `saveRM`, `rmKeys` and `rmChar` are OPTIONAL (ADR-0106 step 1), and the absence is the news: none of them
1155
+ * held a choice of the game — `rmKeys` was the `MotionSceneKey` union written by hand and `rm`/`saveRM` read an engine
1156
+ * storage key with an engine default. `ui/motion-scene` answers for the four, so this panel needs nothing only the
1157
+ * cartridge knows. The root still passes `rm`/`saveRM`: not the game's, but the ONE object the quick bar also writes.
1158
+ *
1159
+ * ⚠️ AND ITS LIST IS `#motion-list`, NOT `#animation-list` — inherited from the monolith, where the panel was called
1160
+ * «motion» and the overlay «animation». See `PanelShellSpec.listId`: renaming would touch the contract with markup of
1161
+ * consumers this repository cannot measure.
1162
+ */
1163
+ let motion = null;
1164
+ const animPanel = mountPanel(panelCtx, {
1165
+ id: 'animation',
1166
+ listId: 'motion-list',
1167
+ labels: () => ({
1168
+ title: t('menu.animation'),
1169
+ listLabel: t('animation.grupo.rotulo'),
1170
+ resetLabel: t('menu.restoreDefaults'),
1171
+ closeLabel: t('pause.pmback'),
1172
+ }),
1173
+ render: () => motion?.render(),
1174
+ });
1175
+ /*
1176
+ * THE MASTER BUTTON — stop all animations at once.
1177
+ *
1178
+ * ⚠️ CREATED HERE AND BEFORE THE `init`: `initSettingsMotion` wires its click ONCE, at boot. And it is not
1179
+ * decoration — it is the way out for someone who felt sick with the screen moving and needs to stop EVERYTHING in one
1180
+ * gesture, instead of going through seven rows one by one.
1181
+ * 📌 The label comes from the panel itself (`motionMasterLabel`), which swaps it by state; setting it here would give
1182
+ * two hands writing the same text, and the one left behind would lie about the state.
1183
+ */
1184
+ const animMaster = doc.createElement('button');
1185
+ animMaster.id = 'motion-master';
1186
+ animMaster.className = 'mode-btn switch';
1187
+ animMaster.setAttribute('type', 'button');
1188
+ animPanel.shell.card.insertBefore(animMaster, animPanel.shell.list);
1189
+ motion = initSettingsMotion({
1190
+ t: translator.t, $, srSay, store, matchMedia: win.matchMedia, crt,
1191
+ // the SAME flags the quick bar's calm icon writes (see `sceneMotion`, above)
1192
+ rm: sceneMotion, saveRM: saveSceneMotion,
1193
+ getNumPlayers: () => (cartridge.players ?? [null]).length,
1194
+ getPlayers: () => cartridge.players ?? [],
1195
+ frontOverlay: overlays.frontOverlay,
1196
+ restoreFocus: overlays.restoreFocus,
1197
+ fillExplain: overlays.fillExplain,
1198
+ toggleBtn,
1199
+ // 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,
1203
+ });
1204
+ engineActions.anim = animPanel.open;
1205
+ /*
1206
+ * VISUAL ACCESSIBILITY (ADR-0151) — and the item is no longer locked (ADR-0161).
1207
+ *
1208
+ * 📌 THE MODULE IS `ui/settings-visual`, with the two rows the engine CAN act on: the contrast enhancement (the L→Q
1209
+ * filter, composed on the world with the colour correction) and the safe palette (`core/state.cbSafe`, which paints
1210
+ * menus and HUD). ⚠️ Role colours («lava, escada, água, portão») stay OUT: they belong to a game with those roles, and
1211
+ * the engine does not describe a game it does not know. Owner colours and outlines are offered below, where the game
1212
+ * answers them (ADR-0188). High contrast and colour correction left this panel for the quick bar (ADR-0151).
1213
+ * ⚠️ The writers of the rows not offered are inert ON PURPOSE: `reset` calls them only when the value read differs
1214
+ * from the default, and the value returned here IS the default.
1215
+ */
1216
+ const visualPanel = mountPanel(panelCtx, {
1217
+ id: 'visual',
1218
+ labels: () => ({
1219
+ title: t('menu.visual'),
1220
+ listLabel: t('visual.grupo.rotulo'),
1221
+ resetLabel: t('menu.restoreDefaults'),
1222
+ closeLabel: t('pause.pmback'),
1223
+ }),
1224
+ render: () => {
1225
+ rateHint.textContent = t('visual.legenda.ritmo.dica'); // before `visual.render()` runs `fillExplain`
1226
+ fgOutline.escreverDica();
1227
+ bgOutline.escreverDica();
1228
+ visual.render();
1229
+ offerOwnerAndOutlines();
1230
+ labelRow(captionsRow, captionsSpec()); // in the language of the opening
1231
+ reflectCaptions();
1232
+ reflectCaptionRate();
1233
+ },
1234
+ });
1235
+ /*
1236
+ * CAPTIONS (ADR-0151 §2; issue #182): the Dev listed them in the visual panel. `state.captionsOn` was stored and read by
1237
+ * the sound captions (`captionSound`) with no row to change it. Placed after the panel's list, which `visual.render()`
1238
+ * rewrites by markup; built once, so its listener is not lost.
1239
+ */
1240
+ const captionsSpec = () => ({ id: 'opt-captions', label: t('visual.captions'), hint: t('visual.captions.dica') });
1241
+ const { row: captionsRow, control: captionsButton } = controlRow(panelCtx, captionsSpec());
1242
+ visualPanel.shell.card.insertBefore(captionsRow, visualPanel.shell.list.nextSibling);
1243
+ const reflectCaptions = () => {
1244
+ toggleBtn(captionsButton, state.captionsOn);
1245
+ captionsButton.textContent = toggleLabel(t, state.captionsOn);
1246
+ markChanged(t, captionsRow, state.captionsOn !== DEFAULTS.captionsOn);
1247
+ };
1248
+ /* THE CAPTION RATE (ADR-0183 §4; issue #179): 125, 145 or 175 words a minute, by steps, right after the captions switch. */
1249
+ const rateSpec = () => ({
1250
+ label: t('visual.legenda.ritmo'),
1251
+ values: CAPTION_RATES.map((n) => t('visual.legenda.ppm', { n })),
1252
+ current: Math.max(0, CAPTION_RATES.indexOf(state.captionPpm)),
1253
+ });
1254
+ const speechRateRow = doc.createElement('div');
1255
+ speechRateRow.className = 'ctrl-row ctrl-row--passos';
1256
+ const rateEnvelope = doc.createElement('span');
1257
+ const rateHint = doc.createElement('span');
1258
+ rateHint.className = 'opt-hint';
1259
+ // ⚠️ WRITTEN NOW, and again before the panel's render: `visual.render()` runs `fillExplain` on the whole card, and a row it
1260
+ // meets with an empty hint is marked done and keeps its hint INSIDE — the Dev saw the explanation in the row.
1261
+ rateHint.textContent = t('visual.legenda.ritmo.dica');
1262
+ rateEnvelope.appendChild(rateHint);
1263
+ speechRateRow.appendChild(rateEnvelope);
1264
+ const rateSteps = mountSteps(panelCtx, rateSpec());
1265
+ rateSteps.id = 'opt-legenda-ppm';
1266
+ speechRateRow.appendChild(rateSteps);
1267
+ visualPanel.shell.card.insertBefore(speechRateRow, captionsRow.nextSibling);
1268
+ rateSteps.addEventListener('passo', (ev) => {
1269
+ const currentSlide = rateSpec().current;
1270
+ const fresh = nextStep(currentSlide, CAPTION_RATES.length, ev.detail);
1271
+ if (fresh === currentSlide)
1272
+ return;
1273
+ state.setCaptionPpmValue(CAPTION_RATES[fresh]);
1274
+ updateSteps(rateSteps, rateSpec());
1275
+ markChanged(t, speechRateRow, state.captionPpm !== DEFAULTS.captionPpm);
1276
+ srSay(`${t('visual.legenda.ritmo')}: ${t('visual.legenda.ppm', { n: state.captionPpm })}`);
1277
+ });
1278
+ const reflectCaptionRate = () => {
1279
+ updateSteps(rateSteps, rateSpec());
1280
+ rateHint.textContent = t('visual.legenda.ritmo.dica');
1281
+ markChanged(t, speechRateRow, state.captionPpm !== DEFAULTS.captionPpm);
1282
+ };
1283
+ captionsButton.addEventListener('click', () => {
1284
+ state.setCaptionsOnValue(!state.captionsOn);
1285
+ reflectCaptions();
1286
+ srSay(t(state.captionsOn ? 'sr.visual.captionsOn' : 'sr.visual.captionsOff'));
1287
+ });
1288
+ /*
1289
+ * OWNER COLOURS AND CONTRAST OUTLINES (ADR-0188; issue #183): rows only where the cartridge answers the subject. Built once
1290
+ * beside the panel's list (`visual.render()` rewrites that by markup), with their own ids — `ui/settings-visual` wires
1291
+ * `#opt-ownercolors` at every render — and shown or hidden at each opening: `mount()` may have swapped the answer.
1292
+ * Owner colours carries the game's word; the outlines are two positions of one subject, named by the engine.
1293
+ */
1294
+ const ownerSpec = () => {
1295
+ const word = subjectWord(cartridge.accommodations, 'ownerColors');
1296
+ return { id: 'opt-dono', label: word?.label ?? '', hint: word?.hint };
1297
+ };
1298
+ const { row: ownerRow, control: ownerButton } = controlRow(panelCtx, ownerSpec());
1299
+ const reflectOwner = () => {
1300
+ toggleBtn(ownerButton, state.ownerColors);
1301
+ ownerButton.textContent = toggleLabel(t, state.ownerColors);
1302
+ markChanged(t, ownerRow, state.ownerColors !== DEFAULTS.ownerColors);
1303
+ };
1304
+ ownerButton.addEventListener('click', () => {
1305
+ state.setOwnerColorsValue(!state.ownerColors);
1306
+ reflectOwner();
1307
+ srSay(`${ownerSpec().label}: ${toggleLabel(t, state.ownerColors)}`);
1308
+ });
1309
+ const OUTLINE_LEVELS = ['visual.contorno.0', 'visual.contorno.1', 'visual.contorno.2'];
1310
+ const outline = (plane) => {
1311
+ const readOutline = () => (plane === 'fg' ? state.hcOutlineFg : state.hcOutlineBg);
1312
+ const write = plane === 'fg' ? state.setOutlineFgValue : state.setOutlineBgValue;
1313
+ const spec = () => ({ label: t(`visual.contorno.${plane}`), values: OUTLINE_LEVELS.map((k) => t(k)), current: readOutline() });
1314
+ const rowC = doc.createElement('div');
1315
+ rowC.className = 'ctrl-row ctrl-row--passos';
1316
+ const envelope = doc.createElement('span');
1317
+ const explanation = doc.createElement('span');
1318
+ explanation.className = 'opt-hint';
1319
+ envelope.appendChild(explanation);
1320
+ rowC.appendChild(envelope);
1321
+ const stepper = mountSteps(panelCtx, spec());
1322
+ stepper.id = `opt-contorno-${plane}`;
1323
+ rowC.appendChild(stepper);
1324
+ stepper.addEventListener('passo', (ev) => {
1325
+ const fresh = nextStep(readOutline(), OUTLINE_LEVELS.length, ev.detail);
1326
+ if (fresh === readOutline())
1327
+ return;
1328
+ write(fresh);
1329
+ updateSteps(stepper, spec());
1330
+ srSay(`${t(`visual.contorno.${plane}`)}: ${t(OUTLINE_LEVELS[fresh])}`);
1331
+ });
1332
+ // the hint is written BEFORE the panel's render, which runs `fillExplain`: written after, it stays inside the row
1333
+ const writeHint = () => {
1334
+ explanation.textContent = subjectWord(cartridge.accommodations, 'contrastOutlines')?.hint ?? t(`visual.contorno.${plane}.dica`);
1335
+ };
1336
+ writeHint();
1337
+ const reflect = () => { updateSteps(stepper, spec()); };
1338
+ return { row: rowC, refletir: reflect, escreverDica: writeHint };
1339
+ };
1340
+ const fgOutline = outline('fg');
1341
+ const bgOutline = outline('bg');
1342
+ const offerOwnerAndOutlines = () => {
1343
+ ownerRow.hidden = subjectWord(cartridge.accommodations, 'ownerColors') === null;
1344
+ if (!ownerRow.hidden) {
1345
+ labelRow(ownerRow, ownerSpec());
1346
+ reflectOwner();
1347
+ }
1348
+ const withoutOutlines = subjectWord(cartridge.accommodations, 'contrastOutlines') === null;
1349
+ for (const c of [fgOutline, bgOutline]) {
1350
+ c.row.hidden = withoutOutlines;
1351
+ if (!withoutOutlines)
1352
+ c.refletir();
1353
+ }
1354
+ };
1355
+ // after the list, owner colours first, then the two outlines (the captions row, built above, follows them)
1356
+ for (const l of [bgOutline.row, fgOutline.row, ownerRow])
1357
+ visualPanel.shell.card.insertBefore(l, visualPanel.shell.list.nextSibling);
1358
+ offerOwnerAndOutlines();
1359
+ const noEffect = () => { };
1360
+ const visual = initSettingsVisual({
1361
+ t: translator.t, $, srSay,
1362
+ getNumPlayers: () => players().length,
1363
+ getPlayers: () => cartridge.players ?? [],
1364
+ getVisualSettings: () => ({
1365
+ lq: lq.t(), cbSafe: state.cbSafe,
1366
+ ownerColors: state.ownerColors, outlineFg: state.hcOutlineFg, outlineBg: state.hcOutlineBg,
1367
+ roleColors: { ...HC_ROLE_DEF },
1368
+ }),
1369
+ getSelectedPlayer: () => 0,
1370
+ setSelectedPlayer: noEffect,
1371
+ setPlayerViz: noEffect,
1372
+ renderVisualAxes: noEffect,
1373
+ setLq: lq.set,
1374
+ setCbSafe: state.setCbSafeValue,
1375
+ // the panel's «restore» puts these back too (ADR-0188): their rows now exist where the game answered them
1376
+ setOwnerColors: state.setOwnerColorsValue, setOutlineFg: state.setOutlineFgValue, setOutlineBg: state.setOutlineBgValue,
1377
+ setRoleColor: noEffect, resetRoleColors: noEffect,
1378
+ fillExplain: overlays.fillExplain,
1379
+ offer: { owner: false, roles: false },
1380
+ });
1381
+ engineActions.visual = visualPanel.open;
1382
+ /*
1383
+ * EMPATHY MODE (ADR-0151) — and the last locked item of the submenu unlocks (ADR-0161).
1384
+ *
1385
+ * 📌 THE MODULE IS `ui/settings-empathy`, with what the engine CAN do:
1386
+ * · the SIMULATIONS that are a filter on the world — the three colour-blindness ones (the matrices of
1387
+ * `installCvdFilters`), blur, haze and blindness; and the three DRAWN ones — tunnel vision, a central scotoma,
1388
+ * scattered scotomas — whose filter is only a blur: `ui/simulation-over-the-world` lays their drawing over the
1389
+ * world (issue #182);
1390
+ * · HEARING LOSS (`platform/audio.setHearingLossGraph`, already the engine's);
1391
+ * · the two MOTOR simulations (ADR-0181): «um botão por vez» and «sem força para segurar», applied to game keys by
1392
+ * the filter at the end of the boot, before any cartridge hears them.
1393
+ * ⚠️ No wheelchair (cut by ADR-0151).
1394
+ * ⚠️ And the simulation respects ADR-0076: with a colour correction on it does not run, and SAYS why.
1395
+ */
1396
+ const WORLD_SIMULATIONS = ['normal', 'sim-protan', 'sim-deuter', 'sim-tritan', 'lv-blur', 'lv-haze', 'lv-tunnel', 'lv-macular', 'lv-diabetic', 'blind'];
1397
+ const empathyPanel = mountPanel(panelCtx, {
1398
+ id: 'empathy',
1399
+ labels: () => ({
1400
+ title: t('menu.empathy'),
1401
+ listLabel: t('empathy.grupo.rotulo'),
1402
+ resetLabel: t('menu.restoreDefaults'),
1403
+ closeLabel: t('pause.pmback'),
1404
+ }),
1405
+ // the hearing-loss row was born in the fallback language: relabelled at every opening, like the hearing panel's inside
1406
+ render: () => {
1407
+ labelRow(hearingRow, hearingSpec());
1408
+ labelRow(oneAtOnceRow, oneAtOnceSpec());
1409
+ labelRow(noStrengthRow, noStrengthSpec());
1410
+ empathy.render();
1411
+ },
1412
+ });
1413
+ // The hearing-loss row is born BEFORE the `init`, which wires its click once (the same order rule as the motion master).
1414
+ const hearingSpec = () => ({ id: 'opt-hearing', label: t('empathy.hearing'), hint: t('empathy.hearing.dica') });
1415
+ const hearingRow = controlRow(panelCtx, hearingSpec()).row;
1416
+ empathyPanel.shell.card.insertBefore(hearingRow, empathyPanel.shell.list);
1417
+ // THE TWO MOTOR SIMULATIONS (ADR-0181), before the `init`, which wires `#opt-onebtn` once (the same order rule).
1418
+ const oneAtOnceSpec = () => ({ id: 'opt-onebtn', label: t('empathy.onebtn'), hint: t('empathy.onebtn.dica') });
1419
+ const noStrengthSpec = () => ({ id: 'opt-semforca', label: t('empathy.semforca'), hint: t('empathy.semforca.dica') });
1420
+ const oneAtOnceRow = controlRow(panelCtx, oneAtOnceSpec()).row;
1421
+ const noStrengthRow = controlRow(panelCtx, noStrengthSpec()).row;
1422
+ empathyPanel.shell.card.insertBefore(oneAtOnceRow, empathyPanel.shell.list);
1423
+ empathyPanel.shell.card.insertBefore(noStrengthRow, empathyPanel.shell.list);
1424
+ const reflectMobilitySimulations = () => {
1425
+ for (const [id, on] of [['#opt-onebtn', state.oneButton], ['#opt-semforca', state.noGripStrength]]) {
1426
+ const b = $(id);
1427
+ if (!b)
1428
+ continue;
1429
+ toggleBtn(b, on);
1430
+ b.textContent = toggleLabel(t, on);
1431
+ }
1432
+ markChanged(t, noStrengthRow, state.noGripStrength !== DEFAULTS.noGripStrength);
1433
+ };
1434
+ const simulate = (i, key) => {
1435
+ const simulation = (key === 'normal' ? null : key);
1436
+ const player = players()[i];
1437
+ const base = player?.visual ?? worldState;
1438
+ const reason = simulation ? simulationUnavailable(base) : null;
1439
+ if (reason) {
1440
+ srSay(t(`sim.indisponivel.${reason}`));
1441
+ return false;
1442
+ } // a VISIBLE, explained refusal (ADR-0076)
1443
+ const nextVisual = { ...base, simulacao: simulation };
1444
+ if (player) {
1445
+ player.visual = nextVisual;
1446
+ player.viz = key;
1447
+ }
1448
+ worldState = nextVisual;
1449
+ recomposeWorldFilter();
1450
+ return true;
1451
+ };
1452
+ // ONE LIST, NOT SEVEN BUTTONS (ADR-0159 rule 7) — `ui/simulation-list` says why, and it is not wiring.
1453
+ const simulationPicker = createSimulationList({
1454
+ t: translator.t,
1455
+ find: $,
1456
+ make: (tag) => doc.createElement(tag),
1457
+ keys: WORLD_SIMULATIONS,
1458
+ running: () => worldState.simulacao ?? null,
1459
+ // a refused simulation (ADR-0076) is announced by `simular` and the re-render puts the list back on what runs
1460
+ picked: (key) => { simulate(0, key); empathy.render(); },
1461
+ });
1462
+ const empathy = initSettingsEmpathy({
1463
+ t: translator.t, $, srSay, store,
1464
+ hearing: mixer,
1465
+ renderVizGroup: (listSelector) => { simulationPicker.render(listSelector); },
1466
+ reflectMobilityEmpathy: reflectMobilitySimulations,
1467
+ reflectVizButtons: noEffect,
1468
+ frontOverlay: overlays.frontOverlay,
1469
+ fillExplain: overlays.fillExplain,
1470
+ restoreFocus: overlays.restoreFocus,
1471
+ setHearingLoss: (on) => {
1472
+ setHearingLossGraph(on);
1473
+ store.set(KEYS.hearingloss, on);
1474
+ srSay(t(on ? 'sr.empathy.hearingOn' : 'sr.empathy.hearingOff'));
1475
+ },
1476
+ setOneButton: (on) => {
1477
+ state.setOneButtonValue(on);
1478
+ srSay(t(on ? 'sr.empathy.onebtnOn' : 'sr.empathy.onebtnOff'));
1479
+ reflectMobilitySimulations();
1480
+ },
1481
+ setWheelchair: noEffect,
1482
+ getOneButton: () => state.oneButton,
1483
+ getWheelchair: () => DEFAULTS.wheelchair,
1484
+ getPlayers: () => [{ viz: worldState.simulacao ?? 'normal' }],
1485
+ setPlayerViz: (i, rowMode) => { simulate(i, rowMode); },
1486
+ });
1487
+ // «sem força para segurar» is wired here: the empathy module predates it. Refused over toggle keys (ADR-0076): a latch
1488
+ // would hold what the simulation lets go, and the demonstration would show the accommodation instead of the difficulty.
1489
+ $('#opt-semforca')?.addEventListener('click', () => {
1490
+ const wire = !state.noGripStrength;
1491
+ if (wire && players().some((p) => p.toggleMove)) {
1492
+ srAlert(t('sim.indisponivel.alternancia'));
1493
+ return;
1494
+ }
1495
+ state.setNoGripStrengthValue(wire);
1496
+ srSay(t(wire ? 'sr.empathy.semforcaOn' : 'sr.empathy.semforcaOff'));
1497
+ empathy.render();
1498
+ });
1499
+ // and the panel's «restore defaults» turns it off too, after the module's own reset
1500
+ $('#empathy-reset')?.addEventListener('click', () => { state.setNoGripStrengthValue(false); empathy.render(); });
1501
+ engineActions.empatia = empathyPanel.open;
1502
+ /*
1503
+ * HEARING ACCESSIBILITY — the biggest of the panels, and the one with the most to lose by not existing.
1504
+ *
1505
+ * 📏 The panel reached nodes it never created; `mountAudioInside`, beside it, builds them. ⚠️ And the inside goes in
1506
+ * BEFORE the `init`, by the order rule the panels above already paid for: `initSettingsAudio` wires its clicks once,
1507
+ * at boot.
1508
+ *
1509
+ * 📌 None of the `ctx` fields is the game's: the mixer, the volume, blind mode, the cane and the voice are all the
1510
+ * engine's, and the cartridge's players serve as they are. It was the most expensive panel to mount and the least
1511
+ * dependent on who mounts it.
1512
+ */
1513
+ const audioPanel = mountPanel(panelCtx, {
1514
+ id: 'audio',
1515
+ // 📌 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.
1517
+ listId: 'navsound-list',
1518
+ labels: () => ({
1519
+ title: t('menu.audio'),
1520
+ listLabel: t('audio.navsound.grupo'),
1521
+ resetLabel: t('menu.restoreDefaults'),
1522
+ closeLabel: t('pause.pmback'),
1523
+ }),
1524
+ /*
1525
+ * ⚠️ THE INSIDE GOES IN AT RENDER, not only at mounting. The frame is retranslated (`MountPanelSpec.labels`); the
1526
+ * inside, run once, would keep the text of the boot interval, where the language is still the fallback — the
1527
+ * title in English and the rows in Portuguese on the same screen, as measured in a real browser with `lang="en"`.
1528
+ *
1529
+ * 📌 `mountAudioInside` RELABELS what exists instead of rebuilding it — rebuilding would leave controls in the
1530
+ * document with no listener. And no unit test catches this: they all run in one language.
1531
+ */
1532
+ render: () => {
1533
+ mountAudioInside(translator.t, panelCtx, audioPanel.shell.card, audioPanel.shell.list);
1534
+ hideRowsWithoutSubject();
1535
+ audio?.renderAudio();
1536
+ },
1537
+ });
1538
+ mountAudioInside(translator.t, panelCtx, audioPanel.shell.card, audioPanel.shell.list);
1539
+ /*
1540
+ * AUDIO — general sound and the four taste categories (ADR-0151 §2 item 4), apart from hearing accessibility.
1541
+ * ⚠️ MOUNTED BEFORE `initSettingsAudio`, by the siblings' order rule: this panel's master switch, volume and «restore»
1542
+ * are wired ONCE, at boot.
1543
+ */
1544
+ const soundPanel = mountPanel(panelCtx, {
1545
+ id: 'som',
1546
+ listId: 'audio-list',
1547
+ labels: () => ({
1548
+ title: t('menu.som'),
1549
+ listLabel: t('audio.grupo.rotulo'),
1550
+ resetLabel: t('menu.restoreDefaults'),
1551
+ closeLabel: t('pause.pmback'),
1552
+ }),
1553
+ render: () => {
1554
+ mountSoundInside(translator.t, panelCtx, soundPanel.shell.card, soundPanel.shell.list);
1555
+ audio?.renderAudio();
1556
+ },
1557
+ });
1558
+ mountSoundInside(translator.t, panelCtx, soundPanel.shell.card, soundPanel.shell.list);
1559
+ // The two hearing-panel rows that exist only where there is a subject — `ui/audio-rows-that-apply` says why, and it
1560
+ // is not wiring. ⚠️ Read at every opening: the topology is a function, and a game changes its demands between phases (ADR-0084).
1561
+ const hideRowsWithoutSubject = () => showOnlyRowsThatApply({
1562
+ find: $,
1563
+ caneWord: () => subjectWord(cartridge.accommodations, 'caneSpacing'),
1564
+ hasNavigationSound: () => contractSubjects({
1565
+ declaration: cartridge.declaration,
1566
+ actions: cartridge.preset ? presetActions(cartridge.preset) : [],
1567
+ players: players().length,
1568
+ }).has('navigationSound'),
1569
+ });
1570
+ hideRowsWithoutSubject();
1571
+ audio = initSettingsAudio({
1572
+ translator, $, srSay, store,
1573
+ // the page's settings store, and the ROOT'S door to its bus: the panel's subscription ends with `dispose()` (ADR-0220)
1574
+ settings: state, on: stateOn,
1575
+ /*
1576
+ * 🔴 THE AUDIO PANEL'S BROWSER COMES FROM HERE (ADR-0227). What ADR-0221 step 7d asks is not that nobody touches the
1577
+ * browser — it is that whoever touches it is whoever RECEIVED it. This root receives `doc` and `win` from the host
1578
+ * (`EngineHost`), so the reach moves place and not owner: out of a panel that does not know which page it is in,
1579
+ * into the only module of the tree that legitimately knows.
1580
+ */
1581
+ newElement: (tag) => doc.createElement(tag),
1582
+ speech: {
1583
+ voices: () => { try {
1584
+ return win.speechSynthesis?.getVoices() ?? [];
1585
+ }
1586
+ catch (e) {
1587
+ return [];
1588
+ } },
1589
+ speakSample: (sample, chosen) => {
1590
+ try {
1591
+ const ss = win.speechSynthesis;
1592
+ if (!ss)
1593
+ return;
1594
+ ss.cancel();
1595
+ const u = speech.utterance(sample);
1596
+ u.lang = 'pt-BR';
1597
+ if (chosen)
1598
+ u.voice = chosen;
1599
+ u.rate = 1;
1600
+ u.volume = 1;
1601
+ ss.speak(u);
1602
+ }
1603
+ catch (e) { /* the device refused to speak; the panel already says what it can do */ }
1604
+ },
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
+ },
1620
+ },
1621
+ audioOutputs: {
1622
+ canList: () => !!(win.navigator?.mediaDevices && win.navigator.mediaDevices.enumerateDevices),
1623
+ canRoute: () => {
1624
+ const w = win;
1625
+ return typeof (w.AudioContext ?? w.webkitAudioContext) !== 'undefined';
1626
+ },
1627
+ list: async () => {
1628
+ const md = win.navigator?.mediaDevices;
1629
+ if (!md?.enumerateDevices)
1630
+ return [];
1631
+ return (await md.enumerateDevices()).filter((d) => d.kind === 'audiooutput');
1632
+ },
1633
+ detect: async () => {
1634
+ const md = win.navigator?.mediaDevices;
1635
+ if (!md?.enumerateDevices)
1636
+ return [];
1637
+ // 📌 The permission is what gives the outputs NAMES: without it the browser lists anonymous devices, and a choice
1638
+ // between «Saída 1» and «Saída 2» is not a choice. The track is let go at once.
1639
+ await md.getUserMedia?.({ audio: true }).then((s) => s.getTracks().forEach((t) => t.stop())).catch(() => { });
1640
+ return (await md.enumerateDevices()).filter((d) => d.kind === 'audiooutput');
1641
+ },
1642
+ },
1643
+ audioCats: AUDIO_CATS,
1644
+ toggleBtn,
1645
+ getNumPlayers: () => players().length,
1646
+ getPlayers: () => cartridge.players ?? [],
1647
+ getSoundOn: () => mixer.soundOn,
1648
+ setSoundOn,
1649
+ getVolume: () => mixer.volume,
1650
+ setVolume,
1651
+ getAudioCat: () => mixer.audioCat,
1652
+ setCatGain,
1653
+ tts,
1654
+ getBlindMode: readBlindMode,
1655
+ // 📌 The `core/state` pattern: store, persist, notify. The game effects are a REACTION, and whoever reacts
1656
+ // subscribes to `on('blindMode', …)` — the same decision `ui/pause-icons` took for the icon.
1657
+ setBlindMode: state.setBlindModeValue,
1658
+ getCaneBlockDiv: () => state.caneBlockDiv,
1659
+ setCaneBlockDiv: state.setCaneBlockDivValue,
1660
+ fillExplain: overlays.fillExplain,
1661
+ });
1662
+ engineActions.audio = audioPanel.open;
1663
+ engineActions.som = soundPanel.open;
1664
+ }
1665
+ // 4b. NAVIGATION SOUND. Only the contract goes in: no tile, collision box or coin array.
413
1666
  const sonar = createAudioSonar({
414
- topology: () => cartucho.declaration.topology(),
415
- targetsOf: (i) => cartucho.declaration.targetsOf(i),
416
- nameAt: (at) => cartucho.declaration.nameAt(at),
417
- // Campo 2 + o barramento do mixer: o que o GUIA CONTÍNUO precisa e o bipe não precisava (#84 item 2). O
418
- // `roleAt` é o que deixa a rota contornar parede; o `catNode`/`audioOut`/`getVolume` são o que põem um
419
- // grafo PERMANENTE no mesmo cursor de volume que todo o resto do áudio usa.
420
- roleAt: (at) => cartucho.declaration.roleAt(at),
421
- tonePan, srSay, narrate: (texto) => tts.narrate(texto),
422
- catNode, audioOut, getVolume: () => volume,
423
- // ⚠️ A RESPOSTA, E NÃO A TABELA (#104). O `platform/audio-sonar` recebia o `VIZ_BY_KEY` e atravessava-o
424
- // com `pl.viz`; ele deixou de saber o que é um modo visual, e quem responde é aqui — a raiz é a única
425
- // camada que conhece os dois eixos E pode importar de `render/`.
426
- visaoComprometida: (pl) => {
1667
+ t: translator.t,
1668
+ topology: () => cartridge.declaration.topology(),
1669
+ targetsOf: (i) => cartridge.declaration.targetsOf(i),
1670
+ 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,
1677
+ // ⚠️ THE ANSWER, NOT THE TABLE (#104): `platform/audio-sonar` does not know what a visual mode is, so it is answered
1678
+ // here — the root is the only layer that knows both axes AND may import from `render/`.
1679
+ visionImpaired: (pl) => {
427
1680
  const v = pl.visual;
428
- return !!v && (ehCego(v) || ehBaixaVisao(v));
1681
+ return !!v && (isBlind(v) || isLowVision(v));
429
1682
  },
430
- getModoCego: lerModoCego, LOGICAL_W,
431
- // O jogador DERIVADO do foco: campo 4 respondendo "onde a criança está". Um jogo que não fornece lista
432
- // ainda tem sonar, e é isso que faz a pilha de acessibilidade não ser acessório.
433
- getPlayers: cartucho.sonarPlayers ?? (() => {
434
- const f = cartucho.declaration.focusOf(0);
435
- return f ? [{ i: 0, x: f.at.x, y: f.at.y, visual: PADRAO }] : [];
1683
+ 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 }] : [];
436
1689
  }),
437
- getNumPlayers: () => (cartucho.players ?? [null]).length,
438
- getAudioCtx: () => audioCtx, getSoundOn: () => soundOn, getAudioCat: () => audioCat,
1690
+ getNumPlayers: () => (cartridge.players ?? [null]).length,
1691
+ getAudioCtx: () => mixer.audioCtx, getSoundOn: () => mixer.soundOn, getAudioCat: () => mixer.audioCat,
1692
+ newContext: newAudioContext,
439
1693
  });
440
- // 5. Teclado remapeável — o melhor recorte da base (achado 11): esquema de teclas, sem mundo.
1694
+ // 5. Remappable keyboard — the best cut of the base (finding 11): a key scheme, no world.
441
1695
  //
442
- // ⚠️ O PADRÃO DO JOGO REGISTA-SE ANTES DO `initKB()`, e a ordem é a regra: quem lê o disco já tem de saber
443
- // qual é a fábrica sobre a qual o dado da criança se sobrepõe (ADR-0115). Registar depois deixaria o
444
- // primeiro arranque com a fábrica da ENGINE e o segundo com a do jogo — a pior espécie de defeito, porque
445
- // desaparece quando alguém vai ver.
446
- // 📌 E o registo aceita `null`, que é o que um jogo sem opinião produz: fica a fábrica da engine.
447
- /*
448
- * OS DOIS REGISTOS NUMA FUNÇÃO, porque são EFEITO GLOBAL e não valor: quem os chama por último ganha.
449
- *
450
- * ⚠️ É o que os torna diferentes de tudo o mais nesta raiz. Repontar uma leitura para o `cartucho` chega
451
- * para os campos que são lidos quando alguém pergunta; estes dois já foram escritos noutro sítio no
452
- * momento do arranque, então trocar de cartucho sem os reescrever deixa o mapa do anterior a valer —
453
- * calado, e exactamente no lugar onde uma criança que remapeou teclas iria notar primeiro.
454
- *
455
- * 📌 `null` é o valor honesto de «este jogo não tem opinião», e é também o que o `desmontar()` escreve.
456
- */
457
- function registrarMapeamentosDoCartucho() {
458
- registrarMapeamentoDoTeclado(cartucho.declaration.mapeamentoDoTeclado
459
- ? (jogadores, assento) => cartucho.declaration.mapeamentoDoTeclado(jogadores, assento)
460
- : null);
461
- // ⚠️ E O DO CONTROLE REGISTA-SE AQUI AINDA QUE ESTA RAIZ NÃO MONTE GAMEPAD NENHUM. Não é descuido: quem
462
- // chama `initGamepad` é o cartucho, e é exactamente por isso que o registo não pode viver lá — seria mais
463
- // um campo que um jogo pode esquecer, e esquecê-lo devolve o mapa da ENGINE a quem declarou outro, calado.
464
- registrarMapeamentoDoPad(cartucho.declaration.mapeamentoDoPad
465
- ? (jogadores, assento) => cartucho.declaration.mapeamentoDoPad(jogadores, assento)
466
- : null);
467
- }
468
- registrarMapeamentosDoCartucho();
469
- initKB();
470
- // ⚠️ O ESQUEMA DE ARRANQUE ALCANÇA NADA, e diz isso com `null` em vez de com um objeto vazio (issue #118).
471
- // Ele vive um instante — `assignControls()` logo abaixo substitui-o pelo esquema real —, mas enquanto vive
472
- // é um `KeyScheme` como qualquer outro, e a única forma honesta de um esquema que não alcança nada é
473
- // catorze ausências declaradas. Um `{}` fazia o tipo mentir sobre estar completo.
474
- const semAlcance = Object.fromEntries(ACTIONS.map((a) => [a, null]));
475
- // ⚠️ O FALLBACK É UMA CONSTANTE e não um literal novo a cada chamada: `getPlayers` é lido pelo runtime de
476
- // teclado a cada leitura de controlo, e devolver um array novo de cada vez faria qualquer comparação de
477
- // identidade mentir — um defeito que só aparece em quem compara, e tarde.
478
- const semJogadores = [{ ctrl: semAlcance }];
479
- // ⚠️ LÊ `cartucho.players`, E NÃO UM INSTANTÂNEO. Os getters já existiam; o que eles fechavam é que era um `const`
480
- // tirado no arranque. As linhas de `initPauseIcons` e do sonar, neste mesmo ficheiro, já liam a fonte viva —
481
- // esta era a que faltava. Com vários cartuchos numa raiz de composição (ADR-0142), o teclado ficava com os
482
- // jogadores do cartucho que arrancou primeiro.
483
- const players = () => cartucho.players ?? semJogadores;
1696
+ /*
1697
+ * THE MOUNTED CARTRIDGE'S TWO DEFAULT MAPPINGS (ADR-0115), held by THIS root (ADR-0232 D4): they were module registrations,
1698
+ * and a second root overwrote the first root's. `null` is the honest value of «this game has no opinion», and it is also
1699
+ * what `unmount()` sets. The keyboard config reads the mapping through a closure, so a `mount()` answers with the new
1700
+ * cartridge's; the pad table is REBUILT, because its memo belongs to one mapping — a kept memo would serve the previous
1701
+ * game's table.
1702
+ */
1703
+ let keyboardMappingNow = null;
1704
+ let padTableNow = createPadTable(null);
1705
+ function followCartridgeMappings(declaration) {
1706
+ const keyboardMapping = declaration?.keyboardMapping;
1707
+ keyboardMappingNow = keyboardMapping ? (players, seat) => keyboardMapping(players, seat) : null;
1708
+ const padMapping = declaration?.padMapping;
1709
+ padTableNow = createPadTable(padMapping ? (players, seat) => padMapping(players, seat) : null);
1710
+ }
1711
+ followCartridgeMappings(cartridge.declaration);
1712
+ // ⚠️ THE GAME'S DEFAULT IS KNOWN BEFORE `load()`, and the order is the rule: whoever reads the disk must already know which
1713
+ // factory the child's data overlays (ADR-0115).
1714
+ const keyboardConfig = createKeyboardConfig({
1715
+ store, mapping: (players, seat) => keyboardMappingNow?.(players, seat) ?? null,
1716
+ });
1717
+ keyboardConfig.load();
1718
+ // THE CHILD'S PAD MAPS, one cache for this root, handed to BOTH readers: the motor panel's wizard and the gamepad.
1719
+ const padMaps = createPadMaps(store);
1720
+ // (`withoutReach`, `withoutPlayers` and `players` are hoisted above `initPauseIcons` — issue #147.)
484
1721
  const keyboard = initKeyboardRuntime({
485
- getKB: () => kb, getNumPlayers: () => players().length, getPlayers: () => players(),
1722
+ getKB: keyboardConfig.kb, getNumPlayers: () => players().length, getPlayers: () => players(),
486
1723
  });
1724
+ /** The «Mapear teclado» panel, once mounted. Declared here because the menu navigation, just below, asks it whether it
1725
+ * is capturing a key — and it is born only with the motor panel, further on. */
1726
+ let keyboardControls = null;
487
1727
  keyboard.assignControls();
488
- // 6. Navegação de menu. Os três declínios entram como AUSÊNCIA DECLARADA, não como getter que devolve null.
1728
+ /*
1729
+ * THE THREE ANSWERS THE CARTRIDGE MAY REPLACE, resolved ONCE. They feed the menu navigation and, since ADR-0224, the
1730
+ * gamepad too — and writing the same `??` in two places is writing the same decision twice.
1731
+ */
1732
+ const isOnBar = cartridge.onBar ?? ((i) => pauseIcons.onBar(i));
1733
+ const navBar = cartridge.navBar ?? ((i, k, withStart) => pauseIcons.navBar(i, k, withStart));
1734
+ const setPauseActor = cartridge.setPauseActor ?? (() => { });
1735
+ // 6. Menu navigation.
489
1736
  const nav = initMenuNav({
490
- $, getActiveElement: () => doc.activeElement,
1737
+ $, t: translator.t, getActiveElement: () => doc.activeElement,
491
1738
  topVisibleOverlay: overlays.topVisibleOverlay, closeById: overlays.closeById,
492
1739
  getPauseMenu: (i) => $(`#vp-pause-${i}`),
493
- setPhase: cartucho.setPhase ?? (() => { }),
494
- setPauseActor: cartucho.setPauseActor ?? (() => { }),
1740
+ // ⚠️ NOT `cartridge.setPhase ?? (() => {})`: «no» at the card's root calls `setPhase('playing')` (`ui/menu-nav`) and
1741
+ // nothing more — it hides nothing —, so in a game without the hook Escape did not close the pause ADR-0144 opens. It
1742
+ // goes through the same `changePhase` as the «continuar» item: one way out, whatever door the child leaves by.
1743
+ setPhase: changePhase,
1744
+ setPauseActor,
495
1745
  srSay,
496
- // Sem opinião declarada, o índice fica LIGADO: quem precisa dele para se orientar não tem como saber
497
- // que ele existe se vier desligado (a mesma razão de o modo cego nascer com TTS e sonar).
498
- comIndice: cartucho.comIndice ?? (() => true),
499
- isNavigable: cartucho.isNavigable ?? (() => true),
1746
+ // With no declared opinion, the index is ON: whoever needs it to find their way cannot know it exists if it comes
1747
+ // switched off (the same reason blind mode is born with narration and sonar).
1748
+ withIndex: cartridge.withIndex ?? (() => true),
1749
+ explainItem: (text) => writeInFooter(text),
1750
+ isNavigable: cartridge.isNavigable ?? (() => true),
500
1751
  /*
501
- * ⚠️ ESTE PADRÃO ERA `() => false` / `() => {}`, E DESDE HOJE ISSO SERIA UM BURACO QUE EU ABRI. O
502
- * comentário que estava aqui dizia «um hospedeiro que não tenha barra de acessibilidade responde nunca e
503
- * nunca chama nada» — verdade até a etapa 2 do ADR-0106, quando esta raiz passou a MONTAR a barra.
1752
+ * ⚠️ THE DEFAULT IS THE ENGINE'S BAR, and not `() => false` / `() => {}`: this root MOUNTS the bar (ADR-0106 step 2).
1753
+ * With these two as no-ops the bar would exist and **could not be navigated by keyboard or gamepad** — reachable only
1754
+ * by pointer. For a blind child, who navigates by keyboard, a bar she cannot reach is the same as no bar — exactly
1755
+ * the offer-the-path-then-refuse-it that ADR-0106 §5 forbids.
504
1756
  *
505
- * Com a barra montada e estes dois em no-op, ela existiria e **não se conseguiria navegar por teclado nem
506
- * por controle**: alcançável só por ponteiro. Para uma criança cega, que navega por teclado, uma barra
507
- * que ela não alcança é o mesmo que barra nenhuma — e é exactamente o «oferece o caminho e depois
508
- * recusa-o» que o §5 do ADR-0106 proíbe.
1757
+ * The engine answers with ITS instance, the same one that mounted the bar. Whoever injects still rules.
509
1758
  *
510
- * A engine responde com a SUA instância, que é a mesma que montou a barra. Quem injecta continua a mandar.
511
- *
512
- * ⚠️ E FICA UMA METADE POR LIGAR, dita aqui em vez de descoberta: o `navBar` do `ui/menu-nav` recebe
513
- * `(i, k)` e não o terceiro argumento `temStart`, que é a borda do botão de pausa — a SEGUNDA saída do
514
- * modo (ADR-0044 item 7). No cartucho ela chega por outra rota (o encaminhador do gamepad, `main.ts:1470`)
515
- * que esta raiz ainda não monta. Logo: o direcional navega a barra; sair por START, por enquanto, não.
1759
+ * 📌 `ui/menu-nav` calls `navBar` with `(i, k)` and never the third argument, the START edge — the SECOND way out of
1760
+ * the mode (ADR-0044 item 7). That one arrives through the gamepad transport, which this root mounts below.
516
1761
  */
517
- naBarraDe: cartucho.naBarraDe ?? ((i) => pauseIcons.naBarraDe(i)),
518
- navBar: cartucho.navBar ?? ((i, k) => pauseIcons.navBar(i, k)),
519
- isCapturing: () => false,
520
- closePadWiz: () => { },
1762
+ onBar: isOnBar,
1763
+ navBar,
1764
+ // ⚠️ NOT `() => false`: the remapping panel captures a key (ADR-0151), and without this the arrow the child wants to
1765
+ // record would move the menu instead of landing on the key.
1766
+ isCapturing: () => keyboardControls?.isCapturing() ?? false,
1767
+ // Escape on the controller-mapping panel (`#padwiz`) closes it the way «Voltar» does: through the panel's own closer,
1768
+ // registered with the overlays, which cancels a running wizard without storing its map.
1769
+ closePadWiz: () => { overlays.closeById('padwiz'); },
521
1770
  whichPlayer: (code) => keyboard.whichPlayer(code),
522
1771
  actionOf: (code, i) => keyboard.actionOf(code, i),
523
- // Achado 12, RESOLVIDO NA ENGINE: o ctx tipava `win` com `fn: (e: never) => void`, o `window` real não
524
- // casava, e cada consumidor escrevia o mesmo adaptador de uma linha. A porta agora é genérica sobre
525
- // `WindowEventMap` (ver `EventTargetLike` em input/touch-bindings), então o `window` entra direto.
1772
+ // Finding 12, SOLVED IN THE ENGINE: the port is generic over `WindowEventMap` (see `EventTargetLike` in
1773
+ // input/touch-bindings), so the real `window` goes straight in, with no adapter per consumer.
526
1774
  win,
527
1775
  });
528
- // ⚠️ E AGORA LIGA. A `nav` era montada aqui e ficava desligada — `MenuNavApi.attach()` existia, `menu-nav.ts`
529
- // descrevia-a como estando ali "para o game.js instalar exatamente como antes", e `createGame` nunca a
530
- // chamava. O efeito num jogo que arranque pela engine: os diálogos de acessibilidade e o menu de pausa
531
- // respondem só ao RATO, o que é o pilar 2 a falhar por inteiro — e o `consumer-quiz` teve de a chamar à mão
532
- // depois do `createGame`, que é o sintoma da fronteira estar no lugar errado.
533
- //
534
- // Um fio que a engine MONTA e não liga é pior do que um que ela não monta: a ausência seria visível — o
535
- // objeto tem uma `nav`, e ela parece pronta.
1776
+ // ⚠️ AND IT IS SWITCHED ON. A wire the engine MOUNTS and does not switch on is worse than one it does not mount: the
1777
+ // absence would be visible — the object has a `nav`, and it looks ready — while the accessibility dialogs and the pause
1778
+ // menu answered only to the MOUSE, pillar 2 failing whole.
536
1779
  //
537
- // Instalar aqui é seguro antes de o jogo acabar de arrancar: sem diálogo aberto e sem menu de pausa,
538
- // `menuNavKey` não consome tecla nenhuma e a deixa seguir para quem for o dono.
1780
+ // Attaching here is safe before the game finishes booting: with no dialog open and no pause menu, `menuNavKey`
1781
+ // consumes no key and lets it go on to its owner.
1782
+ /*
1783
+ * 🔴 ONE BUTTON ONLY TAKES THE KEY BEFORE EVERYONE ELSE (ADR-0218, issue #201), and that is why this listener is registered
1784
+ * HERE, above `nav.attach()`, instead of beside the cool-down and the simulations further down.
1785
+ *
1786
+ * With the scan on, the child has ONE input in the world, and every press of it means the same thing: take what is showing.
1787
+ * It must not also move a menu, reach the game or feed the motor filters — so the press is stopped dead (`barrar`) and the
1788
+ * scan decides what happens. Listeners on one node run in the order they were registered, and `stopImmediatePropagation`
1789
+ * only reaches the ones after it: registered below the menu navigation, this would have let the menu move AND the scan take,
1790
+ * which is one press doing two things.
1791
+ *
1792
+ * ⚠️ `scanPress` is filled in much later, where the virtual controller exists. Until then, and whenever the scan is off,
1793
+ * this listener answers nothing — the same hoisting the pause card's host uses, and for the same reason: what has to run
1794
+ * first is not what can be built first.
1795
+ */
1796
+ const blockKey = (e) => { e.preventDefault(); e.stopImmediatePropagation(); };
1797
+ let scanPress = null;
1798
+ win.addEventListener('keydown', (e) => {
1799
+ if (!state.switchScan || !scanPress || keyboard.whichPlayer(e.code) < 0)
1800
+ return;
1801
+ // A HELD KEY IS ONE PRESS, not one a frame: a child who cannot let go would otherwise take an item every repeat.
1802
+ if (!e.repeat)
1803
+ scanPress(sourceOfEvent(e) ?? 'teclado');
1804
+ blockKey(e);
1805
+ }, true);
1806
+ win.addEventListener('keyup', (e) => {
1807
+ if (state.switchScan && scanPress && keyboard.whichPlayer(e.code) >= 0)
1808
+ blockKey(e);
1809
+ }, true);
1810
+ // ⚠️ AND NOW THE MENU NAVIGATION SWITCHES ON — after the block above, and the ORDER IS THE BEHAVIOUR: listeners on one
1811
+ // node run in registration order, so the scan sees the key first and can stop it. The other way round, one press would
1812
+ // move the menu AND take an item (ADR-0218). 🔴 No type sees a lost call: browser cases hold that the menus answer the
1813
+ // keyboard.
539
1814
  nav.attach();
540
- // ⚠️ E A ARMADILHA DE FOCO, que não existia em lado nenhum — os outros dois fios eram montados e deixados
541
- // desligados; este nunca tinha sido escrito. Com um overlay aberto, o Tab entrava no tabuleiro por baixo,
542
- // enquanto todo `.overlay__card` do documento diz `aria-modal="true"`. Uma promessa que o teclado desmente
543
- // é pior do que promessa nenhuma: quem usa leitor de tela sai para um jogo cujo estado não percebe.
1815
+ // ⚠️ AND THE FOCUS TRAP. With an overlay open, Tab went into the board underneath, while every `.overlay__card` in the
1816
+ // document says `aria-modal="true"`. A promise the keyboard contradicts is worse than no promise: a screen-reader user
1817
+ // steps out into a game whose state they do not understand.
544
1818
  initFocusTrap({
545
- overlayDeCima: overlays.topVisibleOverlay,
546
- focoAtual: () => doc.activeElement,
547
- focaveisDe: focaveisNoDom,
1819
+ topOverlay: overlays.topVisibleOverlay,
1820
+ currentFocus: () => doc.activeElement,
1821
+ focusablesIn: focusablesInDom,
548
1822
  win,
549
1823
  }).attach();
550
1824
  /**
551
- * O ALCANCE: entre os transportes DISPONÍVEIS a esta criança, algum carrega as ações deste jogo?
1825
+ * THE REACH: among the transports AVAILABLE to this child, does any carry this game's actions?
552
1826
  *
553
- * ⚠️ A detecção segue o que o projeto JÁ usa para a mesma pergunta (`isCoarsePointer` em `game/session`):
554
- * `pointer:coarse && hover:none` é toque, e o contrário é teclado. Ela erra num tablet COM teclado — e o
555
- * erro só é tolerável porque a tela INFORMA em vez de recusar. Ver o cabeçalho de `ui/reach-notice`.
1827
+ * ⚠️ The detection: `pointer:coarse && hover:none` is touch, and the opposite is keyboard. It is wrong on a tablet WITH
1828
+ * a keyboard — and the error is tolerable only because the screen INFORMS instead of refusing. See the header of
1829
+ * `ui/reach-notice`.
556
1830
  */
557
- const disponibilidade = o.disponibilidade ?? {
1831
+ const deviceAvailability = o.availability ?? {
558
1832
  gamepad: () => { try {
559
1833
  return [...(win.navigator?.getGamepads?.() ?? [])].some(Boolean);
560
1834
  }
561
1835
  catch {
562
1836
  return false;
563
1837
  } },
564
- toque: () => { try {
1838
+ touch: () => { try {
565
1839
  return win.matchMedia('(pointer:coarse)').matches && win.matchMedia('(hover:none)').matches;
566
1840
  }
567
1841
  catch {
568
1842
  return false;
569
1843
  } },
570
- teclado: () => { try {
1844
+ keyboard: () => { try {
571
1845
  return !(win.matchMedia('(pointer:coarse)').matches && win.matchMedia('(hover:none)').matches);
572
1846
  }
573
1847
  catch {
574
1848
  return true;
575
1849
  } },
576
1850
  /**
577
- * O RATO (ADR-0112) — e a sonda é `any-pointer` de propósito, não `pointer`.
1851
+ * THE MOUSE (ADR-0112) — and the probe is `any-pointer` on purpose, not `pointer`.
578
1852
  *
579
- * ⚠️ `(pointer:fine)` descreve o ponteiro PRIMÁRIO, então um tablet com rato ligado responde «coarse» e
580
- * o rato desaparecia — exactamente o aparelho que esta pergunta existe para achar. `any-pointer:fine` diz
581
- * «ALGUM dos dispositivos apontadores é fino», que é a pergunta certa: para desenhar basta um.
1853
+ * ⚠️ `(pointer:fine)` describes the PRIMARY pointer, so a tablet with a mouse plugged in answers coarse and the mouse
1854
+ * disappeared — exactly the device this question exists to find. `any-pointer:fine` says ANY of the pointing devices
1855
+ * is fine, which is the right question: to draw, one is enough.
582
1856
  *
583
- * ⚠️ ELA ERRA, e a direcção do erro é a que se aceita: um stylus também responde `fine`, e é um ponteiro
584
- * a sério — logo isso não é erro. O que pode faltar é um rato ligado depois do arranque, e por isso a
585
- * sonda é uma FUNÇÃO, avaliada a cada pergunta, como as três acima.
1857
+ * ⚠️ A stylus also answers `fine`, and it is a real pointer — so that is not an error. What can be missing is a mouse
1858
+ * plugged in after boot, which is why the probe is a FUNCTION, evaluated at every question, like the three above.
586
1859
  */
587
- rato: () => { try {
1860
+ mouse: () => { try {
588
1861
  return win.matchMedia('(any-pointer:fine)').matches;
589
1862
  }
590
1863
  catch {
591
1864
  return false;
592
1865
  } },
593
1866
  };
594
- // O segundo eixo entra aqui, e vem do jogo (ADR-0104 §A): quantas posições ele segura ao mesmo tempo.
595
- // ⚠️ O TERCEIRO EIXO ENTRA AQUI (ADR-0112), e vem do jogo tal como os outros dois. `?? false` e não um
596
- // padrão inventado: o campo é opcional de propósito — ver a nota nele —, e a ausência significa «este jogo
597
- // não desenha», que é a resposta certa para a esmagadora maioria dos trezentos.
598
- /*
599
- * O ALCANCE E O SEU AVISO, numa função, porque os dois dependem do cartucho e o segundo CRIA DOM.
600
- *
601
- * ⚠️ O aviso é o único sítio desta raiz que escreve um elemento a partir de uma resposta do jogo, e por
602
- * isso é o único que precisa de ser RETIRADO antes de ser reescrito: `mostrarAvisoDeAlcance` cria um `div`
603
- * com `id` fixo, então chamá-lo duas vezes deixaria dois — e o segundo cartucho ficaria com o aviso do
604
- * primeiro por baixo do seu.
605
- *
606
- * 📌 `retirarAvisoDeAlcance()` corre SEMPRE antes, e não só quando há o que mostrar: um cartucho que não
607
- * tem nada a avisar tem de apagar o aviso do anterior, e é esse o caso que se esquece.
608
- */
609
- function retirarAvisoDeAlcance() {
610
- const aviso = $(`#${REACH_NOTICE_ID}`);
611
- if (!aviso)
612
- return;
613
- // ⚠️ CAPACIDADE E NÃO TIPO, pela mesma razão que este ficheiro já escreve mais acima sobre o
614
- // `instanceof HTMLElement`: o hospedeiro pode ser um documento falso, e os que estes testes usam têm
615
- // `parentNode` mas não `remove`. Perguntar pelo método é o que funciona nos dois.
616
- if (typeof aviso.remove === 'function')
617
- aviso.remove();
1867
+ // The second axis (ADR-0104 §A) and the third (ADR-0112) come from the game, like the first: how many positions it
1868
+ // holds at once, and whether it needs a pointer. `?? false` and not an invented default: the field is optional on
1869
+ // purpose — see its note —, and its absence means the game does not draw.
1870
+ /*
1871
+ * THE REACH AND ITS NOTICE, in one function, because both depend on the cartridge and the second CREATES DOM.
1872
+ *
1873
+ * ⚠️ The notice is the only place in this root that writes an element from a game's answer, and so the only one that
1874
+ * must be REMOVED before being rewritten: `showReachNotice` creates a `div` with a fixed `id`, so calling it twice
1875
+ * would leave two — and the second cartridge would have the first one's notice under its own.
1876
+ *
1877
+ * 📌 `removeReachNotice()` ALWAYS runs first, not only when there is something to show: a cartridge with nothing to
1878
+ * warn about must erase the previous one's notice, and that is the case that gets forgotten.
1879
+ */
1880
+ function removeReachNotice() {
1881
+ const notice = $(`#${REACH_NOTICE_ID}`);
1882
+ if (!notice)
1883
+ return;
1884
+ // ⚠️ CAPABILITY AND NOT TYPE, for the reason this file gives about `instanceof HTMLElement`: the host may be a fake
1885
+ // document, and the ones these tests use have `parentNode` but no `remove`. Asking for the method works in both.
1886
+ if (typeof notice.remove === 'function')
1887
+ notice.remove();
618
1888
  else
619
- aviso.parentNode?.removeChild(aviso);
620
- }
621
- function derivarAlcance() {
622
- const acoes = cartucho.preset ? presetActions(cartucho.preset) : [];
623
- const a = alcance(transportesPadrao(disponibilidade), acoes, cartucho.declaration.holdsAtOnce(), cartucho.declaration.needsPointer?.() ?? false);
624
- retirarAvisoDeAlcance();
625
- // ⚠️ SÓ APARECE QUANDO HÁ O QUE DIZER. Um aviso que aparece sempre deixa de ser lido, e um jogo cujas
626
- // ações cabem no toque não tem nada a avisar — que é o caso comum e tem de continuar silencioso.
627
- if (acoes.length) {
628
- mostrarAvisoDeAlcance({
629
- procurar: (sel) => $(sel),
630
- criar: (tag) => doc.createElement(tag),
1889
+ notice.parentNode?.removeChild(notice);
1890
+ }
1891
+ function deriveReach() {
1892
+ const declaredActions = cartridge.preset ? presetActions(cartridge.preset) : [];
1893
+ const a = reach(defaultTransports(deviceAvailability), declaredActions, cartridge.declaration.holdsAtOnce(), cartridge.declaration.needsPointer?.() ?? false);
1894
+ removeReachNotice();
1895
+ // ⚠️ IT APPEARS ONLY WHEN THERE IS SOMETHING TO SAY. A notice that always appears stops being read, and a game whose
1896
+ // actions fit the touch screen has nothing to warn about — the common case, which must stay silent.
1897
+ if (declaredActions.length) {
1898
+ showReachNotice({
1899
+ find: (sel) => $(sel),
1900
+ create: (tag) => doc.createElement(tag),
631
1901
  t,
632
1902
  srAlert,
633
1903
  }, a);
634
1904
  }
635
1905
  return a;
636
1906
  }
637
- let alcanceAtual = derivarAlcance();
638
- // O ANÚNCIO DE QUE O LAÇO PAROU (ADR-0054). Entregue e não instalado: quem chama `startLoop` é o JOGO, que
639
- // é o dono do ticker. Um jogo que monte o laço sem passar isto continua a PARAR — parar não é opcional; o
640
- // que ele perde é dizer que parou.
641
- const aoFalhar = criarAvisoDeQueda({
642
- procurar: (sel) => $(sel),
643
- criar: (tag) => doc.createElement(tag),
644
- narrar: (texto) => tts.narrate(texto),
1907
+ let currentReach = deriveReach();
1908
+ /*
1909
+ * THE RESOLUTION IS THE ENGINE'S (ADR-0163), and the cartridge has no other. The Dev: «A Engine deve forçar isso e
1910
+ * guiar esta construção, de modo que o cartucho não tenha alternativa». The region gets the largest integer multiple
1911
+ * of 320×180 in REAL pixels that fits the stage, never less than 640×360, with the tolerance of ≤5 logical px cut
1912
+ * per side — and again at every window resize (ADR-0001).
1913
+ * ⚠️ The STAGE is the `#stage-wrap`/`.stage-wrap` shell when it exists; without it, the region's parent — the room it has.
1914
+ * ⚠️ BY CAPABILITY, like the rest: a double without `style.setProperty` is not resized, and the boot does not fall for it.
1915
+ */
1916
+ let appliedScale = null;
1917
+ /**
1918
+ * What departs is SAID (ADR-0163 rule 4): the region's measured size against the one the engine gave it, read when
1919
+ * `problems` is read — a cartridge that resizes the region after boot is seen then, and the line goes when it stops.
1920
+ */
1921
+ function regionResizedByCartridge() {
1922
+ const regionEl = $('#game-region');
1923
+ if (!appliedScale || !regionEl || typeof regionEl.getBoundingClientRect !== 'function')
1924
+ return null;
1925
+ const r = regionEl.getBoundingClientRect();
1926
+ if (!r.width || !r.height)
1927
+ return null; // not laid out: nothing measured, nothing to accuse
1928
+ const { width: scaledWidth, height: scaledHeight } = appliedScale;
1929
+ if (Math.abs(r.width - scaledWidth) < 1 && Math.abs(r.height - scaledHeight) < 1)
1930
+ return null;
1931
+ return `the cartridge sized #game-region to ${Math.round(r.width)}×${Math.round(r.height)} over the engine's `
1932
+ + `${Math.round(scaledWidth)}×${Math.round(scaledHeight)}: text and targets stop following the screen, so a child with low vision `
1933
+ + 'gets them small — the resolution is the engine\'s (ADR-0163): lay the game out inside the region and read `--ui-fs` and `--alvo-min`';
1934
+ }
1935
+ /*
1936
+ * THE CONTEXT OF THE TWO DRAWING REPORTERS (`ui/drawing-problems`), and the three readers are FUNCTIONS on purpose: the
1937
+ * region, the bar and the scale are things this root SWAPS — a `mount()` swaps the cartridge, the bar is born after the
1938
+ * root, and the scale changes at every `resize`. Passing them by value would freeze the first answer, and an element
1939
+ * out of the page still answers `getBoundingClientRect()` without complaint.
1940
+ */
1941
+ const drawingContext = {
1942
+ region: () => $('#game-region'),
1943
+ bar: () => a11yBar,
1944
+ scale: () => appliedScale,
1945
+ computedStyle: typeof win.getComputedStyle === 'function' ? (el) => win.getComputedStyle(el) : undefined,
1946
+ };
1947
+ function applyResolution() {
1948
+ const regionEl = $('#game-region');
1949
+ const stage = $('#stage-wrap') ?? $('.stage-wrap') ?? (regionEl?.parentElement ?? null);
1950
+ if (!regionEl || !stage || typeof regionEl.style?.setProperty !== 'function')
1951
+ return;
1952
+ const { w, h } = screenBaseSize(Math.max(1, players().length));
1953
+ appliedScale = stageScale(stage.clientWidth || w, stage.clientHeight || h, win.devicePixelRatio || 1, w, h);
1954
+ applyScale(regionEl, appliedScale);
1955
+ crtScanVars(); // the scanline period is one art pixel in REAL pixels, so it follows the scale (study item A5)
1956
+ reserveBarBand();
1957
+ }
1958
+ /**
1959
+ * THE ROOM THE GAME LEAVES FREE UNDER THE TOP EDGE (ADR-0148 §3, erratum of 2026-09-13; issue #160), written as
1960
+ * `--barra-a11y-h` on the region: the bar's own offset, the bar, the line of the pointed icon's NAME under it, and a
1961
+ * light gap of a quarter of that line's font (4 px at 640×360). 📏 Measured at 640×360: the variable said 44 px (the bar alone) and the name, at
1962
+ * 57–87 px, covered the quiz statement. The Dev: «é necessário que exista um leve espaçamento abaixo da barra».
1963
+ * 📌 The name line is counted whether or not a name is showing — reserving only while pointing would move the game
1964
+ * under the child's finger. Measured again at every scale and every typography step: both change the text's size.
1965
+ * ⚠️ 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.
1968
+ */
1969
+ function reserveBarBand() {
1970
+ reserveTopBand({
1971
+ region: $('#game-region'),
1972
+ bar: a11yBar,
1973
+ hud: hudMounted,
1974
+ ...(typeof win.getComputedStyle === 'function' ? { computedStyle: (el) => win.getComputedStyle(el) } : {}),
1975
+ });
1976
+ }
1977
+ /*
1978
+ * THE HUD the engine mounts from the cartridge's `hud` (ADR-0168; issue #162). Read on every animation frame while mounted —
1979
+ * the numbers are functions, so a game never has to say «refresh»; only a changed text is written, and a changed text
1980
+ * measures the room again, because a longer number can wrap.
1981
+ */
1982
+ let hudMounted = null;
1983
+ let hudFrame = false;
1984
+ function mountHud() {
1985
+ hudMounted?.remove();
1986
+ hudMounted = null;
1987
+ const regionEl = $('#game-region');
1988
+ const numbers = cartridge.hud ?? [];
1989
+ if (numbers.length && regionEl && typeof regionEl.appendChild === 'function')
1990
+ hudMounted = mountHudBands(translator.t, doc, regionEl, numbers);
1991
+ reserveBarBand();
1992
+ if (hudMounted && !hudFrame && typeof win.requestAnimationFrame === 'function') {
1993
+ hudFrame = true;
1994
+ const position = () => {
1995
+ if (!hudMounted) {
1996
+ hudFrame = false;
1997
+ return;
1998
+ }
1999
+ if (hudMounted.refresh())
2000
+ reserveBarBand();
2001
+ win.requestAnimationFrame(position);
2002
+ };
2003
+ win.requestAnimationFrame(position);
2004
+ }
2005
+ }
2006
+ mountHud();
2007
+ applyResolution();
2008
+ if (typeof win.addEventListener === 'function')
2009
+ win.addEventListener('resize', applyResolution);
2010
+ /*
2011
+ * THE SKIP LINK, when the page has none (study item C5; WCAG 2.4.1). The stylesheet rule, the dictionary sentence and its
2012
+ * layer (ADR-0102) already existed, and the element depended on each page remembering it: the quiz writes its own, and a
2013
+ * cartridge page that did not copy it left a keyboard no way over what precedes the game. First in the body, so it is the
2014
+ * first thing a keyboard reaches; a page's own link is kept. By capability: a host double without a body mounts nothing.
2015
+ */
2016
+ if (doc.body && typeof doc.body.insertBefore === 'function' && !$('.skip-link')) {
2017
+ const skipLink = doc.createElement('a');
2018
+ skipLink.className = 'skip-link';
2019
+ skipLink.setAttribute('href', '#game-region');
2020
+ skipLink.setAttribute('data-i18n', 'skip.toGame');
2021
+ skipLink.textContent = t('skip.toGame');
2022
+ doc.body.insertBefore(skipLink, doc.body.firstChild); // `data-i18n`: every `setLocale` rewrites it, the boot's included
2023
+ }
2024
+ /*
2025
+ * THE LETTER CASE REACHES THE PAGE (ADR-0028; ADR-0149 §1). The stylesheet capitalises under `:root[data-letras="upper"]`,
2026
+ * and nothing wrote that attribute: the communication button's first position kept «capitals» in the state and showed
2027
+ * natural case (reported by the Dev). Written at every change, and at boot only when the child CHOSE a case — the state's
2028
+ * default is `upper` (ADR-0028) while the cycle's default is position (c), natural case (ADR-0149), and writing the default
2029
+ * would put every new child's game in capitals.
2030
+ */
2031
+ const writeBox = (c) => {
2032
+ if (doc.documentElement?.dataset)
2033
+ doc.documentElement.dataset.letras = c;
2034
+ };
2035
+ if (store.get(KEYS.letterCase, null) !== null)
2036
+ writeBox(state.letterCase);
2037
+ stateOn('letterCase', writeBox);
2038
+ {
2039
+ /*
2040
+ * THE BAR IS HUD, AND THE GAME DOES NOT WRITE OVER IT (ADR-0148 §3). `#title-icons` is `position:absolute` INSIDE
2041
+ * `#game-region`, and a game's heading can take the same pixels: the child looking for blind mode finds the question's
2042
+ * title over the buttons, with no error, no type, no console — and whoever depends on the row most is whoever cannot
2043
+ * see it is covered.
2044
+ *
2045
+ * 📌 THE ENGINE SAYS, and does not fix — because it cannot. Pushing the game's content would mean changing the width
2046
+ * and height `ui/layout` locks to an integer multiple of real pixels, which is ADR-0001's scale. The engine has the
2047
+ * rectangle; the game draws, and now knows where not to draw. The reserved room itself (`--barra-a11y-h`, next to
2048
+ * `--tap` and `--alvo-min`) is written by `reserveBarBand`, inside `applyResolution`.
2049
+ *
2050
+ * ⚠️ BY CAPABILITY AND NOT BY TYPE: a fake document has no `getBoundingClientRect`, and reading it blindly would bring
2051
+ * the boot down where there is no DOM. Without the measure, nobody is accused: silence beats an invented accusation.
2052
+ */
2053
+ const intruders = barIntruderProblems(drawingContext);
2054
+ if (intruders.length) {
2055
+ hostProblems.push(`the game draws over the accessibility bar (${intruders.slice(0, 4).join(', ')}): a child who needs its buttons `
2056
+ + 'to start cannot reach them — read `--barra-a11y-h` on #game-region and leave that room free (ADR-0148)');
2057
+ }
2058
+ }
2059
+ const announceFailure = createCrashNotice({
2060
+ t: translator.t,
2061
+ find: (sel) => $(sel),
2062
+ create: (tag) => doc.createElement(tag),
2063
+ narrate: (text) => tts.narrate(text),
645
2064
  });
646
2065
  /*
647
- * ⚠️ MOSTRAR REFAZ OS ITENS ANTES DE REVELAR, e a ordem é a regra: o §5 do ADR-0106 diz que a criança nunca
648
- * vê um item que não acciona, e a tabela de acções deste jogo pode ter mudado desde a montagem. Revelar
649
- * primeiro e refazer depois deixaria um piscar em que ela vê o que não pode usar.
2066
+ * ⚠️ SHOWING REBUILDS THE ITEMS BEFORE REVEALING, and the order is the rule: ADR-0106 §5 says the child never sees an
2067
+ * item that does not act, and this game's action table may have changed since mounting. Revealing first and
2068
+ * rebuilding after would leave a blink in which she sees what she cannot use.
650
2069
  */
651
- const pausa = {
652
- mostrar: (i) => {
2070
+ const pauseControls = {
2071
+ show: (i) => {
653
2072
  pauseIcons.reflectPauseIcons();
654
- const cartao = $(`#vp-pause-${i}`);
655
- if (cartao)
656
- cartao.hidden = false;
2073
+ const findPauseCard = $(`#vp-pause-${i}`);
2074
+ if (!findPauseCard)
2075
+ return;
2076
+ findPauseCard.hidden = false;
2077
+ updateCaption();
2078
+ // 🔴 THE CURSOR LANDS ON ITEM 1, at the root (ADR-0158: «a saída é onde o cursor cai ao abrir»). Without it, opened
2079
+ // by SELECT no item was marked, and the first arrow jumped to item 2.
2080
+ showPauseOptions(findPauseCard, 'raiz');
657
2081
  },
658
- esconder: (i) => {
659
- const cartao = $(`#vp-pause-${i}`);
660
- if (cartao)
661
- cartao.hidden = true;
2082
+ hide: (i) => {
2083
+ const findPauseCard = $(`#vp-pause-${i}`);
2084
+ if (findPauseCard)
2085
+ findPauseCard.hidden = true;
2086
+ updateCaption();
662
2087
  },
663
2088
  };
664
2089
  /*
665
- * AS COISAS PESADAS COMEÇAM A DESCER AQUI, e a linha é deliberadamente a ÚLTIMA coisa do arranque.
2090
+ * ===================== THE KEYS THAT OPEN THE PAUSE (ADR-0144, ADR-0155) =====================
2091
+ *
2092
+ * ⚠️ BUBBLING, NOT CAPTURE. `menuNavKey` runs in CAPTURE with `stopPropagation()`, and the header of `ui/menu-nav` keeps
2093
+ * TWO preserved defects about it — today's correct behaviour depends on that `stopPropagation()` and not on the
2094
+ * registered chain. Putting a second meaning in the same phase would lean on that accidental safety net. In bubbling,
2095
+ * `menuNavKey` always has the first refusal, and the game's OWN listener — which lives in `#game-region`, under the
2096
+ * window — runs before these. Whoever owns the key stays its owner.
666
2097
  *
667
- * ⚠️ SEM `await`. O arranque não espera por 241 MB — se esperasse, a primeira tela de uma escola com 3G
668
- * ficaria em branco durante minutos e a criança concluiria que o jogo não abre. O `catch` vazio é a mesma
669
- * regra escrita duas vezes: uma falha de rede aqui não pode derrubar um jogo que hoje nem usa a voz.
2098
+ * 📏 AND `menuNavKey` DOES NOT EAT these keys: `menuKeyIntent` (`ui/menu-intent`) has no branch for `start`, `select`
2099
+ * or `action4` (only `action2`, `action3` and the four directions), so it finds no intent and lets them go. That is
2100
+ * why the guards below exist — in real situations the key arrives here and is not ours.
2101
+ *
2102
+ * ⚠️ THE ENGINE OPENS THE CARD; THE GAME STOPS THE WORLD (ADR-0144 §2). Freezing physics and silencing the ambience are
2103
+ * the game's, so the second half is a request, `setPhase('paused')`, and not an order.
2104
+ */
2105
+ /** The seat that owns a key, if it is ITS `presetAction` position; otherwise `null`. */
2106
+ function seatOfPosition(code, presetAction) {
2107
+ // ⚠️ THE KEY'S OWNER DECIDES THE SEAT, as in `menuNavKey`: whoever pressed opens THEIR pause. A key that is nobody's
2108
+ // (`-1`) cannot be any seat's «start» — asking seat 0 about it would hand Player 1's pause to whoever pressed a loose
2109
+ // key.
2110
+ const keyOwner = keyboard.whichPlayer(code);
2111
+ if (keyOwner < 0)
2112
+ return null;
2113
+ return keyboard.actionOf(code, keyOwner) === presetAction ? keyOwner : null;
2114
+ }
2115
+ /*
2116
+ * ===================== THE QUICK PAUSE — START (ADR-0155) =====================
670
2117
  *
671
- * 🔴 E O RELATÓRIO NÃO VAI PARA `problems`, embora a primeira versão o fizesse. Duas razões medidas, e a
672
- * primeira é a que importa:
2118
+ * The game FREEZES (`setPhase('paused')` is asked, as ADR-0144 §2 asked for the card), the directional goes to the BAR,
2119
+ * and the word PAUSED appears in the centre. No card is drawn: it is the print view.
673
2120
  *
674
- * 1. **CHEGA DEPOIS DE O LEITOR SE IR EMBORA.** `problems` é devolvido na linha abaixo, sincronamente; a
675
- * descarga é de fundo, logo TODA linha dela entraria num vector que o consumidor já leu. Quem faz
676
- * `if (motor.problems.length) …` não veria nada, e quem o lesse mais tarde veria uma lista que cresceu
677
- * depois do arranque. Um relatório que chega depois do leitor não é um relatório — é a forma exacta do
678
- * `srSay` a escrever onde não havia `#sr-status`.
679
- * 2. **AFOGAVA O QUE SE PODE RESOLVER.** Sem rede — uma escola sem rede, que é o alvo e não a excepção —
680
- * são OITO falhas a empurrar para uma lista que o ADR-0106 §2 construiu para dizer o que FALTA NO
681
- * HOSPEDEIRO. A criança perde a barra de acessibilidade e a linha que o diz fica em nono lugar.
2121
+ * 📌 ONE WAY OUT, by any door. «Voltar» on the bar, START again and SELECT all end in `pauseIcons.leaveBar`, and the
2122
+ * `onLeaveBar` hook is what unfreezes. Two ways out written apart would be two chances for one of them to leave the
2123
+ * world stopped with the child back on the character.
682
2124
  *
683
- * 📌 O canal certo é o que a própria função já tem: `aoProgredir`, entregue a quem chama. Um consumidor que
684
- * queira mostrar «faltam 241 MB» ou «a voz não desceu» tem por onde; a engine não inventa uma superfície.
2125
+ * ⚠️ AND THE STATE IS IN-QUICK-PAUSE, not on-the-bar: a host without `#title-icons` has no bar to enter, and the quick
2126
+ * pause still freezes and says PAUSED — with START leaving it. Asking the bar would trap that child in a stopped game.
685
2127
  */
686
- if (o.baixarPesados !== false) {
687
- void baixarPesados({ aoProgredir: o.aoProgredirPesados })
688
- .catch(() => { });
2128
+ let pausedWord = null;
2129
+ /*
2130
+ * THE FOOTER LEGEND of the frozen screen (ADR-0155 erratum): «Ação 2: confirmar · Ação 3: voltar · Ação 4: menu ·
2131
+ * START: voltar ao jogo». It is the menus' second door — `action4` — and it also tells the child SELECT is not the
2132
+ * only one: the stopped screen teaches how to leave it and where to.
2133
+ * (`pauseCaption` is declared before `changePhase`; see there.)
2134
+ */
2135
+ function showPaused() {
2136
+ const regionEl = $('#game-region');
2137
+ if (!pausedWord && regionEl && typeof regionEl.appendChild === 'function') {
2138
+ pausedWord = doc.createElement('div');
2139
+ pausedWord.className = 'pausa-rapida';
2140
+ // The screen reader already hears the entry into the bar, which says the game stopped and how to go back; the word
2141
+ // is for the eyes, and said twice it trampled the announcement that teaches the way out.
2142
+ pausedWord.setAttribute('aria-hidden', 'true');
2143
+ regionEl.appendChild(pausedWord);
2144
+ }
2145
+ if (!pausedWord)
2146
+ return;
2147
+ // resolved WHEN SHOWN: the language may have changed since boot
2148
+ pausedWord.textContent = t('pause.quick');
2149
+ pausedWord.hidden = false;
2150
+ updateCaption();
2151
+ }
2152
+ /**
2153
+ * THE BUTTON LEGEND FOLLOWS THE SCREEN (ADR-0164 rule 3): on the quick pause it says the quick pause's buttons, on the
2154
+ * pause card (SELECT) the menu's — «2: confirmar · 3: voltar» —, and nothing in play. Asked again whenever one of them
2155
+ * opens or closes; a card hidden by a path that calls nothing here (the print mode) is seen by the observer below.
2156
+ */
2157
+ function updateCaption() {
2158
+ const cardOpen = !!$('.screen-pause:not([hidden])');
2159
+ const text = inQuickPause.size ? t('pause.quick.legenda') : cardOpen ? t('pause.card.legenda') : null;
2160
+ if (!text) {
2161
+ if (pauseCaption)
2162
+ pauseCaption.hidden = true;
2163
+ return;
2164
+ }
2165
+ if (!pauseCaption) {
2166
+ const home = screenFooter($('#game-region'));
2167
+ if (!home)
2168
+ return;
2169
+ pauseCaption = doc.createElement('div');
2170
+ pauseCaption.className = 'pausa-legenda';
2171
+ pauseCaption.setAttribute('aria-hidden', 'true');
2172
+ home.appendChild(pauseCaption);
2173
+ }
2174
+ writeCaption(pauseCaption, text); // resolved when shown: the language may have changed since boot
2175
+ pauseCaption.hidden = false;
2176
+ }
2177
+ /*
2178
+ * EVERY CHANGE OF CONTEXT IS ANNOUNCED (ADR-0159 rule 3): «Opening a menu or panel speaks its title; closing it speaks
2179
+ * where the child is back to; entering play is announced.» 📏 Measured in the dist: SELECT opened the card in silence,
2180
+ * and a panel opened, closed and went back to the root without a word.
2181
+ * 📌 ONE PLACE, by comparison: the observer below asks where the child is now — a panel, a list of the card, or the
2182
+ * game — and speaks only when that changed. An arrow changes no `hidden`, so it still says only the item.
2183
+ */
2184
+ let screenAnnounced = 'jogo';
2185
+ const rootWithIndex = () => (cartridge.withIndex ?? (() => true))();
2186
+ /*
2187
+ * 🔴 THE QUESTION LIVES IN `ui/where-the-child-is` (ADR-0221 step 7c); what stays here is answering where it reads the
2188
+ * document from. Whether this is a panel, a list of the card or the game, and what it is called, is a rule with a
2189
+ * reason — not wiring — and ADR-0221's erratum measures a root's debt in branches.
2190
+ */
2191
+ const whereIsTheChild = () => whereTheChildIs({
2192
+ t: translator.t,
2193
+ topVisibleOverlay: overlays.topVisibleOverlay,
2194
+ pauseCard: () => $('.screen-pause:not([hidden])'),
2195
+ focused: () => doc.activeElement,
2196
+ withIndex: rootWithIndex,
2197
+ });
2198
+ function announceContext() {
2199
+ const nowMs = whereIsTheChild();
2200
+ if (nowMs.key === screenAnnounced)
2201
+ return;
2202
+ const cameFromMenu = screenAnnounced !== 'jogo';
2203
+ screenAnnounced = nowMs.key;
2204
+ if (nowMs.sentence)
2205
+ srSay(nowMs.sentence);
2206
+ // back in play from a menu — the quick pause says its own exit
2207
+ else if (cameFromMenu && !inQuickPause.size)
2208
+ srSay(t('sr.a11y.barExit'));
2209
+ }
2210
+ {
2211
+ const regionEl = $('#game-region');
2212
+ const Observer = win.MutationObserver;
2213
+ if (regionEl && Observer && typeof regionEl.appendChild === 'function') {
2214
+ new Observer((records) => {
2215
+ const classes = records.map((r) => r.target.classList);
2216
+ if (classes.some((c) => c?.contains('screen-pause')))
2217
+ updateCaption();
2218
+ if (classes.some((c) => c?.contains('screen-pause') || c?.contains('overlay') || c?.contains('pause-menu')))
2219
+ announceContext();
2220
+ // ADR-0166: the pad leaves when the card or a panel opens, and comes back when the last of them closes
2221
+ if (classes.some((c) => c?.contains('screen-pause') || c?.contains('overlay')))
2222
+ reflectPadInMenus();
2223
+ // a simulation stops while a menu is open and comes back with the game (issue #182)
2224
+ if (classes.some((c) => c?.contains('screen-pause') || c?.contains('overlay')))
2225
+ recomposeWorldFilter();
2226
+ }).observe(regionEl, { attributes: true, attributeFilter: ['hidden'], subtree: true });
2227
+ }
2228
+ }
2229
+ /*
2230
+ * THE SCREEN FOOTER: one column, at the bottom of the region, with the EXPLANATION of the pointed icon above the quick
2231
+ * pause's LEGEND. The Dev's request — the name under the row, what it does in the footer (`CLAUDE.md` §4, the three
2232
+ * zones).
2233
+ * ⚠️ ONE COLUMN and not two bands positioned apart: the quick pause lands the cursor on the first icon on entering, so
2234
+ * both appear together from the first instant, and two absolute boxes covered each other when one wrapped.
2235
+ */
2236
+ function screenFooter(regionEl) {
2237
+ if (footer || !regionEl || typeof regionEl.appendChild !== 'function')
2238
+ return footer;
2239
+ footer = doc.createElement('div');
2240
+ footer.className = 'rodape-da-tela';
2241
+ regionEl.appendChild(footer);
2242
+ return footer;
2243
+ }
2244
+ /**
2245
+ * The button legend as one dark chip per «name: function» (ADR-0164 rule 3) — the dictionary writes the items joined
2246
+ * by « · », and each becomes its own element so the background sits behind the words and not across the screen.
2247
+ */
2248
+ function writeCaption(home, text) {
2249
+ home.textContent = '';
2250
+ text.split('·').map((s) => s.trim()).filter(Boolean).forEach((item, i) => {
2251
+ // a space between chips, by capability: a host document without createTextNode still gets the chips
2252
+ if (i > 0 && typeof doc.createTextNode === 'function')
2253
+ home.appendChild(doc.createTextNode(' '));
2254
+ const name = doc.createElement('span');
2255
+ name.className = 'lg-nome';
2256
+ name.textContent = item;
2257
+ home.appendChild(name);
2258
+ });
2259
+ }
2260
+ function explainIconInFooter(k) {
2261
+ writeInFooter(k ? t(`icon.${k}.dica`) : null);
2262
+ }
2263
+ /*
2264
+ * THE SOUND CAPTION (study item D3). In the footer column, above the button legend and the explanation (ADR-0164 rule 4:
2265
+ * «the sound caption above, the explanation below it»); `aria-hidden`, because whoever listens heard the sound itself.
2266
+ * 📌 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
+ * (`core/caption-duration`, plan phase 5c); a new caption restarts it.
2268
+ */
2269
+ let soundCaption = null;
2270
+ let clearSoundCaption = null;
2271
+ function writeSoundCaption(text) {
2272
+ if (!state.captionsOn || !text)
2273
+ return;
2274
+ if (!soundCaption) {
2275
+ const home = screenFooter($('#game-region'));
2276
+ if (!home)
2277
+ return;
2278
+ soundCaption = doc.createElement('div');
2279
+ soundCaption.className = 'legenda-de-som';
2280
+ soundCaption.setAttribute('aria-hidden', 'true');
2281
+ home.appendChild(soundCaption);
2282
+ }
2283
+ const captionHome = soundCaption;
2284
+ captionHome.textContent = text;
2285
+ captionHome.hidden = false;
2286
+ if (clearSoundCaption !== null)
2287
+ clearTimeout(clearSoundCaption);
2288
+ clearSoundCaption = setTimeout(() => { captionHome.hidden = true; captionHome.textContent = ''; }, captionDuration(text, state.captionPpm));
2289
+ }
2290
+ /** The footer says ONE explanation at a time: the pointed icon's, or the reason a locked item is locked (ADR-0161). */
2291
+ function writeInFooter(text) {
2292
+ if (!barExplanation && text) {
2293
+ const home = screenFooter($('#game-region'));
2294
+ if (home) {
2295
+ barExplanation = doc.createElement('div');
2296
+ barExplanation.className = 'barra-explicacao';
2297
+ barExplanation.setAttribute('aria-live', 'polite');
2298
+ home.insertBefore(barExplanation, home.firstChild);
2299
+ }
2300
+ }
2301
+ if (!barExplanation)
2302
+ return;
2303
+ barExplanation.textContent = text ?? '';
2304
+ barExplanation.hidden = !text;
2305
+ }
2306
+ function enterQuickPause(seat) {
2307
+ inQuickPause.add(seat);
2308
+ recomposeWorldFilter(); // the quick pause is a menu: the simulation stops (issue #182)
2309
+ pauseIcons.enterBar(seat);
2310
+ if (!pauseIcons.onBar(seat))
2311
+ srSay(t('sr.a11y.quickPause'));
2312
+ showPaused();
2313
+ changePhase('paused');
2314
+ }
2315
+ /** Leaves by any door. `to` says where: the game (unfreezes) or the card (stays stopped). */
2316
+ function leaveQuickPause(seat, to) {
2317
+ if (pauseIcons.onBar(seat)) {
2318
+ pauseIcons.leaveBar(seat, to === 'cartao');
2319
+ return;
2320
+ } // the hook finishes the job
2321
+ if (to === 'jogo')
2322
+ srSay(t('sr.a11y.barExit'));
2323
+ endQuickPause(seat, to === 'cartao');
2324
+ }
2325
+ function endQuickPause(seat, toOtherScreen) {
2326
+ if (!inQuickPause.delete(seat))
2327
+ return; // the simulation returns through the observer: leaving writes the card's `hidden`
2328
+ if (pausedWord && inQuickPause.size === 0)
2329
+ pausedWord.hidden = true;
2330
+ if (!toOtherScreen)
2331
+ changePhase('playing');
2332
+ }
2333
+ function toggleQuickPauseByStart(e) {
2334
+ const seat = seatOfPosition(e.code, 'start');
2335
+ if (seat === null)
2336
+ return;
2337
+ // GUARD 1 — A PANEL IS OPEN. With an overlay visible, `menuNavKey` receives the key, finds no intent in it and leaves
2338
+ // without consuming it. Without this guard the quick pause would open UNDER the panel the child is in. Escape closes
2339
+ // a panel, not START.
2340
+ if (overlays.topVisibleOverlay())
2341
+ return;
2342
+ // START AGAIN LEAVES — the second way out of the mode that ADR-0044 item 7 already gave START.
2343
+ if (inQuickPause.has(seat)) {
2344
+ leaveQuickPause(seat, 'jogo');
2345
+ e.preventDefault();
2346
+ return;
2347
+ }
2348
+ // GUARD 2 — THE CARD IS OPEN. With it open and a «start» key other than `Enter`, `menuNavKey` finds no intent and
2349
+ // lets it through. Closing the card belongs to «Voltar ao jogo» and to Escape.
2350
+ const findPauseCard = $(`#vp-pause-${seat}`);
2351
+ if (findPauseCard && findPauseCard.hidden === false)
2352
+ return;
2353
+ 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();
2357
+ }
2358
+ win.addEventListener('keydown', toggleQuickPauseByStart);
2359
+ /*
2360
+ * ===================== SELECT OPENS THE MENUS (ADR-0155) =====================
2361
+ *
2362
+ * The card of ADR-0151. `select` is mapped (`KeyF`, `input/default-bindings`), and ADR-0086 kept it for «what belongs
2363
+ * to the session» — the pause menus are that.
2364
+ *
2365
+ * ⚠️ FROM THE QUICK PAUSE TO THE CARD the game does NOT unfreeze: the bar is left in silence (saying «back to the game»
2366
+ * with the card opening would be a lie) and the phase is already `paused` — asking for it again would be a second
2367
+ * `paused` in a stopped game.
2368
+ */
2369
+ /** Opens the seat's card, whichever door it comes from — the SELECT key, the ☰ or the touch pill. Returns whether it opened. */
2370
+ function openSeatMenus(seat) {
2371
+ if (overlays.topVisibleOverlay())
2372
+ return false;
2373
+ const findPauseCard = $(`#vp-pause-${seat}`);
2374
+ if (!findPauseCard || findPauseCard.hidden === false)
2375
+ return false;
2376
+ const alreadyStopped = inQuickPause.has(seat);
2377
+ if (alreadyStopped)
2378
+ leaveQuickPause(seat, 'cartao');
2379
+ // ⚠️ SHOWING COMES FIRST, and the order is the defence: a game without `setPhase` must get the card all the same.
2380
+ pauseControls.show(seat);
2381
+ if (!alreadyStopped)
2382
+ changePhase('paused');
2383
+ return true;
2384
+ }
2385
+ function openMenusBySelect(e) {
2386
+ const seat = seatOfPosition(e.code, 'select');
2387
+ if (seat === null)
2388
+ return;
2389
+ if (openSeatMenus(seat))
2390
+ e.preventDefault();
2391
+ }
2392
+ win.addEventListener('keydown', openMenusBySelect);
2393
+ /*
2394
+ * THE MENUS' SECOND DOOR: `action4`, only INSIDE the quick pause (ADR-0155 erratum). Outside it `action4` is the game's,
2395
+ * and the engine does not touch it — that is the pair that keeps the door from stealing a verb mid-match.
2396
+ * 📏 `menuNavKey` has no intent for `action4` and, on the bar, lets it rise without consuming it: it arrives here.
2397
+ */
2398
+ function openMenusByAction4(e) {
2399
+ const seat = seatOfPosition(e.code, 'action4');
2400
+ if (seat === null || !inQuickPause.has(seat))
2401
+ return;
2402
+ if (overlays.topVisibleOverlay())
2403
+ return;
2404
+ leaveQuickPause(seat, 'cartao');
2405
+ pauseControls.show(seat);
2406
+ e.preventDefault();
2407
+ }
2408
+ win.addEventListener('keydown', openMenusByAction4);
2409
+ /*
2410
+ * ===================== THE VIRTUAL CONTROLLER (ADR-0143, plan phase 4) =====================
2411
+ *
2412
+ * 🔴 `mountTouchControls`, `initTouch` and `initTouchBindings` are wired HERE: in a school where the device is a tablet
2413
+ * with no keyboard, a game booted by this root has to be playable, and nothing else would call them.
2414
+ *
2415
+ * 📌 THE PAD IS DRAWN ONLY WHEN THE CARTRIDGE ASKS FOR IT (`onScreenPad`, ADR-0166): a game played by touching its own
2416
+ * elements needs none. What it lacks is said in `problems` (`touchGaps`); with no `preset` it carries only START and
2417
+ * SELECT, the doors to the pause, which is not declinable (ADR-0122).
2418
+ */
2419
+ const touchHostEl = o.host.touchHost ?? $('#game-region');
2420
+ const touchUsable = !!touchHostEl && typeof touchHostEl.appendChild === 'function';
2421
+ const seat0CardOpen = () => {
2422
+ const c = $('#vp-pause-0');
2423
+ return !!c && c.hidden === false;
2424
+ };
2425
+ /** A menu the directional moves is open: an overlay, the seat-0 card, or the quick pause (ADR-0157). */
2426
+ const menuWithDpad = () => !!overlays.topVisibleOverlay() || seat0CardOpen() || inQuickPause.has(0);
2427
+ /** A position's key handed to the menus, which read keys — stamped with who produced it (ADR-0109). */
2428
+ const keyToMenu = (code, origin) => {
2429
+ // 🔴 THE KEYBOARD'S KEY IS ALREADY IN THE WORLD (ADR-0223). This function translates POSITION → key, and it exists for
2430
+ // the transports that produce no keys: the finger, the eyes, the face, the hands, the voice, the scan. The keyboard
2431
+ // does — the event that got here IS the key — so dispatching it again would move the menu TWICE.
2432
+ // 📌 Because this is written here, the keyboard conductor need not ask whether a menu is open: that question has ONE
2433
+ // answer, the controller's, and this line is what makes it true for the keyboard too.
2434
+ if (!origin || origin === 'teclado')
2435
+ return;
2436
+ const eventTarget = $('#game-region') ?? doc.body;
2437
+ eventTarget.dispatchEvent(stampSource(new KeyboardEvent('keydown', { code, key: code, bubbles: true, cancelable: true }), origin));
2438
+ };
2439
+ const labelledActions = () => {
2440
+ const preset = cartridge.preset;
2441
+ if (!preset)
2442
+ return [];
2443
+ const labelOf = labellerFrom(preset);
2444
+ return presetActions(preset).flatMap((a) => {
2445
+ const label = labelOf(a);
2446
+ return label ? [{ action: a, label }] : [];
2447
+ });
2448
+ };
2449
+ const touchPad = initTouch({
2450
+ $, srSay, store, win, t: translator.t,
2451
+ gameActions: labelledActions,
2452
+ root: doc.documentElement,
2453
+ isMobile: deviceAvailability.touch,
2454
+ viewport: () => ({ w: win.innerWidth, h: win.innerHeight }),
2455
+ frontOverlay: overlays.frontOverlay,
2456
+ // Touch always belongs to Player 1 (`touch-bindings`), and with the card or a panel open the child touches the menu's
2457
+ // buttons DIRECTLY (ADR-0166, which undid ADR-0157's «the pad stays over the menus»). The only two callers that show
2458
+ // the pad — the touch listener below and `reflectPadInMenus` — already ask `isMenuOpen()` first.
2459
+ padAllowed: () => players().length <= 1,
2460
+ });
2461
+ /** A menu the child touches directly is open: the pause card or a settings panel (ADR-0166 rule 2). */
2462
+ function isMenuOpen() {
2463
+ return !!overlays.topVisibleOverlay() || !!$('.screen-pause:not([hidden])');
2464
+ }
2465
+ /** The pad was in view (or asked for by a touch) when a menu took the screen — it comes back when the menu goes. */
2466
+ let padBeforeMenu = false;
2467
+ function reflectPadInMenus() {
2468
+ const pad = $('#touch-controls');
2469
+ if (!pad)
2470
+ return;
2471
+ if (isMenuOpen()) {
2472
+ if (!pad.hidden) {
2473
+ padBeforeMenu = true;
2474
+ touchPad.hideTouchControls('menu');
2475
+ }
2476
+ }
2477
+ else if (padBeforeMenu) {
2478
+ padBeforeMenu = false;
2479
+ touchPad.showTouchControls();
2480
+ }
2481
+ }
2482
+ const cartridgeActions = () => new Set(cartridge.preset ? presetActions(cartridge.preset) : []);
2483
+ function drawPad() {
2484
+ if (!touchUsable || !touchHostEl)
2485
+ return;
2486
+ // ADR-0166: a cartridge that does not ask for the pad gets none — and one swapped in by `mount()` takes the last one away
2487
+ if (!cartridge.onScreenPad) {
2488
+ const old = $('#touch-controls');
2489
+ old?.parentNode?.removeChild(old);
2490
+ return;
2491
+ }
2492
+ const padMap = touchPad.getTouchMap();
2493
+ const short = cartridge.preset ? shortLabellerFrom(cartridge.preset) : () => null;
2494
+ const pad = mountTouchControls({ find: (sel) => $(sel), create: (tag) => doc.createElement(tag), t: translator.t }, {
2495
+ map: padMap,
2496
+ gameActions: cartridgeActions(),
2497
+ // The FUNCTION of each slot (ADR-0165): the game's SHORT word, said after the button's name in its accessible
2498
+ // name; the face shows the name. `start`/`select` are system positions the engine names itself.
2499
+ slotLabel: (slot) => (slot === 'start' ? t('touch.start') : slot === 'select' ? t('touch.select')
2500
+ // only what the game names is drawn (ADR-0162), so its word always exists
2501
+ : (short(padMap[slot]) ?? '')),
2502
+ dpad: store.get(KEYS.padDir, 'stick') === 'cross' ? 'cruz' : 'analogico',
2503
+ });
2504
+ if (!pad.parentNode)
2505
+ touchHostEl.appendChild(pad);
2506
+ }
2507
+ padGapProblems = () => (!cartridge.onScreenPad ? [] : touchUsable
2508
+ ? touchGaps({ map: touchPad.getTouchMap(), gameActions: cartridgeActions() })
2509
+ : ['the virtual pad has nowhere to mount: set `host.touchHost`, or give #game-region room for children. '
2510
+ + 'Without it, a child on a keyboardless tablet cannot play, nor reach the pause']);
2511
+ /**
2512
+ * The screen's START: seat 0's QUICK PAUSE, like the key (ADR-0155) — and a second touch leaves it.
2513
+ *
2514
+ * ⚠️ THE PAD STAYS IN VIEW on the quick pause: the START pill IS the way out for someone who has only a finger, and
2515
+ * hiding it would leave the child in a stopped game with no door. With the card open, START closes it.
2516
+ */
2517
+ function togglePauseByTouch() {
2518
+ if (overlays.topVisibleOverlay())
2519
+ return;
2520
+ if (inQuickPause.has(0)) {
2521
+ leaveQuickPause(0, 'jogo');
2522
+ return;
2523
+ }
2524
+ if (seat0CardOpen()) {
2525
+ changePhase('playing');
2526
+ return;
2527
+ }
2528
+ enterQuickPause(0);
2529
+ }
2530
+ const touchBindings = initTouchBindings({
2531
+ $, win,
2532
+ getSearch: () => win.location?.search ?? '',
2533
+ getControls: () => keyboard.controlsState().controls,
2534
+ getPlayers: () => players(),
2535
+ /*
2536
+ * 🔴 TOUCH PRESSES THE VIRTUAL CONTROLLER (ADR-0223), like every other transport: one decision, not a second copy of
2537
+ * it inside the pad — a copy that, when it existed, left a cartridge listening to `onCommand` deaf to the finger.
2538
+ * 📌 Arrows and not direct references: `virtualController` is born further below (the temporal dead zone).
2539
+ */
2540
+ press: (action, source) => virtualController.press(action, source),
2541
+ release: (action, source) => virtualController.release(action, source),
2542
+ playerEdge,
2543
+ heldKeys: keys,
2544
+ attractOnInput: () => false,
2545
+ // a touch inside a menu does not bring the pad over it; it only remembers that the child is on touch (ADR-0166)
2546
+ showTouchControls: () => { if (isMenuOpen()) {
2547
+ padBeforeMenu = true;
2548
+ return;
2549
+ } touchPad.showTouchControls(); },
2550
+ hideTips: () => { },
2551
+ togglePause: togglePauseByTouch,
2552
+ /*
2553
+ * THE SELECT PILL (ADR-0155): the menus by touch. Without it, someone with only a finger could not reach «Sair», the
2554
+ * number of players or the settings — SELECT was a key. ⚠️ THE PAD LEAVES when the card opens (ADR-0166,
2555
+ * `reflectPadInMenus`): over the card it would cover the buttons that are now her way out («Voltar ao jogo»).
2556
+ */
2557
+ openMenus: () => { openSeatMenus(0); },
2558
+ getTouchMap: () => touchPad.getTouchMap(),
2559
+ // ✅ This root HAS the map, so the screen's START reads the action it carries.
2560
+ getStartAction: () => touchPad.getTouchMap().start,
2561
+ getStickTravelPx: () => touchPad.getStickTravelPx(),
2562
+ getStickDeadPx: () => touchPad.getStickDeadPx(),
2563
+ });
2564
+ drawPad();
2565
+ touchBindings.attach();
2566
+ /*
2567
+ * A LANGUAGE CHANGED MID-GAME REACHES WHAT THE ENGINE DREW (study item C6; ADR-0031). 📏 Measured in the quiz: after
2568
+ * `setLocale('en')` the icon bar's names, the card's name, the button legend and the PAUSED word stayed in the old
2569
+ * language — `applyDom` reaches only `[data-i18n]`, and these are written by code. An open panel redraws itself
2570
+ * (`ui/mount-panel`), keeping focus where it was; nothing here moves focus.
2571
+ * 🔴 THE BOOT IS THE SAME EVENT: the pad and the bar are drawn in the fallback language, and the preferred one arrives
2572
+ * through `setLocale` (measured in the `dist`: «Cima/Baixo» on an English page). This listener replaced the
2573
+ * `localeReady()` repaints that did it for the boot alone.
2574
+ */
2575
+ /**
2576
+ * The 👄, when it exists. Declared HERE because the language change — just below — must reach it, and it is born further
2577
+ * down: the temporal dead zone again.
2578
+ */
2579
+ let voiceControl = null;
2580
+ /*
2581
+ * 🔴 AND THE CARTRIDGE NEEDS TO KNOW TOO (ADR-0225): changing the flag took `<html lang>`, the footer, the bar and the
2582
+ * panels to the new language and left the ACTIVITY — the quiz's statement — in the old one. The frame followed; the
2583
+ * activity did not.
2584
+ *
2585
+ * 📌 The door is the engine's and not the event's, by the rule the Dev wrote for reading and speech (ADR-0216): «o jogo
2586
+ * não deve precisar saber como isso funciona». A cartridge that had to subscribe to `i18n:change` on the WINDOW would
2587
+ * have to know the event's name, the object it is dispatched on and the order in which the engine handles it — and
2588
+ * would reach a global to do it, which ADR-0221 step 7d refuses to a new module.
2589
+ */
2590
+ const localeListeners = [];
2591
+ {
2592
+ // through the root's door, released by `dispose()`; the window's `i18n:change` stays the host's page-level signal
2593
+ localeOn(() => {
2594
+ pauseIcons.reflectPauseIcons();
2595
+ updateCaption();
2596
+ if (pausedWord && !pausedWord.hidden)
2597
+ pausedWord.textContent = t('pause.quick');
2598
+ drawPad();
2599
+ touchBindings.rewire();
2600
+ /*
2601
+ * 🔴 AND WHAT THE ENGINE HEARS CHANGES TOO (ADR-0225). What it DRAWS follows since study item C6; what it SAYS follows
2602
+ * by itself (`tts` reads `bcp47()` at every utterance) and what it READS too (at every `listen()`). Command
2603
+ * recognition chose its language ONCE — and listening in the old language is worse than stopping, because the
2604
+ * grammar follows the menu and the new words would feed the old model.
2605
+ */
2606
+ void voiceControl?.languageChanged();
2607
+ // ⚠️ THE CARTRIDGE LAST, on purpose: when it redraws, the bar, the caption and the pad are already in the new language,
2608
+ // so it never measures a half-translated screen. And a cartridge that throws does not take the engine's frame with it.
2609
+ for (const listener of localeListeners) {
2610
+ try {
2611
+ listener();
2612
+ }
2613
+ catch (e) { /* the cartridge failed; the engine goes on */ }
2614
+ }
2615
+ });
2616
+ }
2617
+ /*
2618
+ * ===================== MOTOR ACCESSIBILITY — the engine's panel (ADR-0151 §2 item 5) =====================
2619
+ *
2620
+ * 🔴 THE OLD PANEL DOES NOT SERVE, and not by taste: `ui/settings-mobility` mounts Easy Mode and the two toggles, and
2621
+ * ADR-0151 took the three out of this panel («dificuldade é opção do jogo»; the toggles live on the ☝️). So this is a
2622
+ * NEW panel (`#motora`), and the old one still serves whoever mounts it with their own markup.
2623
+ *
2624
+ * 📌 ITS ROWS are the Dev's list: the CONTROLLER SIZE in four steps, one per persona; mapping the keyboard (for 1, 2 and
2625
+ * 3–4 players), the gamepad and the touch pad; waiting between presses; the camera; the microphone. Each is offered
2626
+ * only where it has a subject.
2627
+ *
2628
+ * ⚠️ IT EXISTS ONLY WHERE THERE IS A TOUCH HOST: without one there is no pad size to choose, and the submenu's door
2629
+ * falls away by itself (`engineActions.motora` is not defined).
2630
+ */
2631
+ if (pauseMountPoint && pauseUsable && touchUsable) {
2632
+ const mobilityCtx = {
2633
+ find: (sel) => $(sel),
2634
+ create: (tag) => doc.createElement(tag),
2635
+ host: pauseMountPoint,
2636
+ overlays,
2637
+ localeOn, // a language change redraws an open panel, and `dispose()` releases it (ADR-0232 D4)
2638
+ };
2639
+ let padSteps = null;
2640
+ let padHint = null;
2641
+ let padSizeRow = null;
2642
+ /** Reflects the keyboard-mapping rows (defined further below, with the `#ctrl` panel). */
2643
+ let reflectKeyboard = () => { };
2644
+ let currentPersona = closestPersona(store.getNum(KEYS.padBtnMm, 12.5));
2645
+ const specDoPad = () => ({
2646
+ label: t('motora.pad'),
2647
+ values: PERSONAS_DO_PAD.map((p) => t(p.label)),
2648
+ current: currentPersona,
2649
+ });
2650
+ const mobilityPanel = mountPanel(mobilityCtx, {
2651
+ id: 'motora',
2652
+ labels: () => ({
2653
+ title: t('menu.motora'),
2654
+ listLabel: t('menu.motora'),
2655
+ resetLabel: t('menu.restoreDefaults'),
2656
+ closeLabel: t('pause.pmback'),
2657
+ }),
2658
+ // Read again at every opening: the size may have changed elsewhere, and the labels follow the language of now.
2659
+ render: () => {
2660
+ currentPersona = closestPersona(store.getNum(KEYS.padBtnMm, 12.5));
2661
+ // ADR-0166 + ADR-0106 §5: the pad's size is offered only to a cartridge that has a pad — hidden, not locked, because
2662
+ // there is nothing to unlock. Read at each opening: `mount()` may have swapped the cartridge.
2663
+ if (padSizeRow)
2664
+ padSizeRow.hidden = !cartridge.onScreenPad;
2665
+ if (padSteps)
2666
+ updateSteps(padSteps, specDoPad());
2667
+ // ⚠️ THE HINT IN THE LANGUAGE OF NOW, before the footer collects it: written at boot, it came out in the fallback
2668
+ // language.
2669
+ if (padHint)
2670
+ padHint.textContent = t('motora.pad.dica');
2671
+ reflectKeyboard();
2672
+ },
2673
+ });
2674
+ const rowNode = doc.createElement('div');
2675
+ rowNode.className = 'ctrl-row ctrl-row--passos';
2676
+ padSizeRow = rowNode; // offered or not is decided at each opening (`render` above)
2677
+ const explanation = doc.createElement('span');
2678
+ explanation.className = 'opt-hint';
2679
+ padHint = explanation;
2680
+ const envelope = doc.createElement('span');
2681
+ envelope.appendChild(explanation);
2682
+ rowNode.appendChild(envelope);
2683
+ padSteps = mountSteps(mobilityCtx, specDoPad());
2684
+ padSteps.id = 'opt-pad-persona';
2685
+ rowNode.appendChild(padSteps);
2686
+ mobilityPanel.shell.list.appendChild(rowNode);
2687
+ padSteps.addEventListener('passo', (ev) => {
2688
+ const fresh = nextStep(currentPersona, PERSONAS_DO_PAD.length, ev.detail);
2689
+ if (fresh === currentPersona)
2690
+ return; // at the end, a step that did not happen is not announced
2691
+ currentPersona = fresh;
2692
+ touchPad.setPadMm(PERSONAS_DO_PAD[fresh].mm);
2693
+ updateSteps(padSteps, specDoPad());
2694
+ srSay(`${t('motora.pad')}: ${t(PERSONAS_DO_PAD[fresh].label)}`);
2695
+ });
2696
+ engineActions.motora = mobilityPanel.open;
2697
+ let keyboardMode = 1;
2698
+ let seatInMap = 0;
2699
+ const modeScheme = (conf, i) => (keyboardMode === 1 ? conf.solo
2700
+ : keyboardMode === 2 ? (conf.p2[i] ?? conf.p2[0]) : (conf.p4[i] ?? conf.p4[0]));
2701
+ const modeLabel = (m) => t(m === 1 ? 'motora.teclado.1' : m === 2 ? 'motora.teclado.2' : 'motora.teclado.34');
2702
+ const SIDES = ['leftShoulder', 'leftTrigger', 'rightShoulder', 'rightTrigger'];
2703
+ 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);
2709
+ return actionWord ? [{ action: presetAction, label: actionWord }] : [];
2710
+ });
2711
+ };
2712
+ let seatSteps = null;
2713
+ const seatSpec = () => ({
2714
+ label: t('ctrl.assento'),
2715
+ values: Array.from({ length: keyboardMode }, (_, i) => t('ctrl.jogador', { n: i + 1 })),
2716
+ current: seatInMap,
2717
+ });
2718
+ const keyboardPanel = mountPanel(mobilityCtx, {
2719
+ id: 'ctrl',
2720
+ labels: () => ({
2721
+ title: modeLabel(keyboardMode),
2722
+ listLabel: modeLabel(keyboardMode),
2723
+ resetLabel: t('menu.restoreDefaults'),
2724
+ closeLabel: t('pause.pmback'),
2725
+ }),
2726
+ render: () => {
2727
+ if (seatSteps) {
2728
+ seatInMap = Math.min(seatInMap, keyboardMode - 1);
2729
+ updateSteps(seatSteps, seatSpec());
2730
+ seatSteps.closest('.ctrl-row').hidden = keyboardMode === 1;
2731
+ }
2732
+ keyboardControls?.render(seatInMap);
2733
+ },
2734
+ });
2735
+ {
2736
+ // THE SEAT, by steps — only in the modes of more than one: «◀ Teclado de: Jogador 2 ▶».
2737
+ const seatRow = doc.createElement('div');
2738
+ seatRow.className = 'ctrl-row ctrl-row--passos';
2739
+ seatSteps = mountSteps(mobilityCtx, seatSpec());
2740
+ seatSteps.id = 'ctrl-assento';
2741
+ seatRow.appendChild(seatSteps);
2742
+ keyboardPanel.shell.card.insertBefore(seatRow, keyboardPanel.shell.list);
2743
+ seatSteps.addEventListener('passo', (ev) => {
2744
+ const nextValue = nextStep(seatInMap, keyboardMode, ev.detail);
2745
+ if (nextValue === seatInMap)
2746
+ return;
2747
+ seatInMap = nextValue;
2748
+ updateSteps(seatSteps, seatSpec());
2749
+ keyboardControls?.render(seatInMap);
2750
+ srSay(`${t('ctrl.assento')}: ${t('ctrl.jogador', { n: nextValue + 1 })}`);
2751
+ });
2752
+ }
2753
+ /** The four-player mode carries the first three seats of the three-player mode — see the header above. */
2754
+ const syncThree = (conf) => {
2755
+ conf.p3.forEach((left, i) => {
2756
+ const de = conf.p4[i];
2757
+ if (de)
2758
+ for (const a of ACTIONS)
2759
+ left[a] = de[a] ? [...de[a]] : de[a];
2760
+ });
2761
+ };
2762
+ keyboardControls = initSettingsControls({
2763
+ t: translator.t, $, srSay, srAlert,
2764
+ gameActions: actionsToMap,
2765
+ store: {
2766
+ saveKB: (conf) => { if (keyboardMode === 4)
2767
+ syncThree(conf); keyboardConfig.save(conf); },
2768
+ // ⚠️ «RESTORE» FOR THIS MODE, not for the whole keyboard: whoever resets the two-player keyboard does not erase the one-player one.
2769
+ resetKB: () => {
2770
+ const factory = keyboardConfig.factoryWithGame();
2771
+ const kb = keyboardConfig.kb();
2772
+ if (keyboardMode === 1)
2773
+ kb.solo = factory.solo;
2774
+ else if (keyboardMode === 2)
2775
+ kb.p2 = factory.p2;
2776
+ else {
2777
+ kb.p4 = factory.p4;
2778
+ kb.p3 = factory.p3;
2779
+ }
2780
+ keyboardConfig.save(kb);
2781
+ return kb;
2782
+ },
2783
+ },
2784
+ kb: keyboardConfig.kb(),
2785
+ setKB: keyboardConfig.set,
2786
+ kbFor: (i) => modeScheme(keyboardConfig.kb(), i),
2787
+ defaultSchemeFor: (i) => modeScheme(keyboardConfig.factoryWithGame(), i),
2788
+ getNumPlayers: () => keyboardMode,
2789
+ applyControls: () => { keyboard.refreshControls(); },
2790
+ assignControls: () => { keyboard.assignControls(); },
2791
+ fillExplain: overlays.fillExplain,
2792
+ });
2793
+ // THE CAPTURE GETS THE KEY BEFORE EVERYTHING ELSE: while capturing, the menu navigation already steps aside, and this
2794
+ // keeps the recorded key from rising on to START, SELECT or the game.
2795
+ win.addEventListener('keydown', (e) => {
2796
+ if (keyboardControls?.isCapturing() && keyboardControls.handleCaptureKeydown(e))
2797
+ e.stopPropagation();
2798
+ }, true);
2799
+ // THE THREE ROWS in the motor panel, each a DOOR to `#ctrl` in its mode.
2800
+ const keyboardRows = [];
2801
+ for (const rowMode of [1, 2, 4]) {
2802
+ const rowT = doc.createElement('div');
2803
+ rowT.className = 'ctrl-row';
2804
+ const envelope = doc.createElement('span');
2805
+ const strongLabel = doc.createElement('strong');
2806
+ envelope.appendChild(strongLabel);
2807
+ rowT.appendChild(envelope);
2808
+ const rowButton = doc.createElement('button');
2809
+ rowButton.className = 'mode-btn';
2810
+ rowButton.setAttribute('type', 'button');
2811
+ rowButton.id = `opt-teclado-${rowMode}`;
2812
+ rowButton.addEventListener('click', () => {
2813
+ keyboardMode = rowMode;
2814
+ seatInMap = 0;
2815
+ keyboardPanel.open();
2816
+ });
2817
+ rowT.appendChild(rowButton);
2818
+ mobilityPanel.shell.list.appendChild(rowT);
2819
+ keyboardRows.push({ mode: rowMode, row: rowT, strong: strongLabel, button: rowButton });
2820
+ }
2821
+ /** Labels in the language of now, and who appears: with no named positions there is nothing to map; «3–4» without sides. */
2822
+ const reflectKeyboardRows = () => {
2823
+ const declaredActions = cartridge.preset ? presetActions(cartridge.preset) : [];
2824
+ const hasSides = declaredActions.some((a) => SIDES.includes(a));
2825
+ for (const { mode: rowMode, row: l, strong: strongLabel, button: rowButton } of keyboardRows) {
2826
+ strongLabel.textContent = modeLabel(rowMode);
2827
+ rowButton.textContent = t('motora.abrir');
2828
+ rowButton.setAttribute('aria-label', modeLabel(rowMode));
2829
+ l.hidden = actionsToMap().length === 0 || (rowMode === 4 && hasSides);
2830
+ }
2831
+ };
2832
+ reflectKeyboardRows();
2833
+ /*
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.
2838
+ */
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
+ const closeControl = () => {
2842
+ if (padWizard?.state()) {
2843
+ padWizard.close(false);
2844
+ return;
2845
+ }
2846
+ controlPanel.shell.overlay.hidden = true;
2847
+ overlays.restoreFocus?.('padwiz');
2848
+ };
2849
+ const controlPanel = mountPanel(mobilityCtx, {
2850
+ id: 'padwiz',
2851
+ labels: () => ({
2852
+ title: t('motora.controle'),
2853
+ listLabel: t('motora.controle'),
2854
+ resetLabel: t('menu.restoreDefaults'),
2855
+ closeLabel: t('pause.pmback'),
2856
+ }),
2857
+ render: () => { },
2858
+ closeOwn: () => closeControl(),
2859
+ });
2860
+ controlPanel.shell.reset.hidden = true;
2861
+ controlPanel.shell.close.addEventListener('click', closeControl); // `closeOwn` means this panel wires its own button
2862
+ const controlSentence = doc.createElement('p');
2863
+ controlSentence.id = 'padwiz-prompt';
2864
+ controlSentence.setAttribute('aria-live', 'assertive');
2865
+ const controlProgress = doc.createElement('p');
2866
+ controlProgress.id = 'padwiz-progress';
2867
+ controlProgress.className = 'opt-hint';
2868
+ controlPanel.shell.card.insertBefore(controlSentence, controlPanel.shell.list);
2869
+ controlPanel.shell.card.insertBefore(controlProgress, controlPanel.shell.list);
2870
+ padWizard = createPadWizard({
2871
+ maps: padMaps, t: translator.t,
2872
+ getGamepads: () => {
2873
+ const nav = win.navigator;
2874
+ return typeof nav?.getGamepads === 'function' ? nav.getGamepads() : null;
2875
+ },
2876
+ actionLabel: (presetAction) => actionsToMap().find((x) => x.action === presetAction)?.label ?? null,
2877
+ say: (phrase) => { controlSentence.textContent = phrase; srSay(phrase); },
2878
+ progress: (text) => { controlProgress.textContent = text; },
2879
+ srAlert,
2880
+ onClose: () => {
2881
+ controlPanel.shell.overlay.hidden = true;
2882
+ overlays.restoreFocus?.('padwiz');
2883
+ },
2884
+ });
2885
+ const controlMappingRow = doc.createElement('div');
2886
+ controlMappingRow.className = 'ctrl-row';
2887
+ const controlMappingLabel = doc.createElement('span');
2888
+ const controlStrong = doc.createElement('strong');
2889
+ controlMappingLabel.appendChild(controlStrong);
2890
+ controlMappingRow.appendChild(controlMappingLabel);
2891
+ const controlButton = doc.createElement('button');
2892
+ controlButton.className = 'mode-btn';
2893
+ controlButton.setAttribute('type', 'button');
2894
+ controlButton.id = 'opt-controle';
2895
+ controlButton.addEventListener('click', () => {
2896
+ controlPanel.open();
2897
+ padWizard?.open();
2898
+ });
2899
+ controlMappingRow.appendChild(controlButton);
2900
+ mobilityPanel.shell.list.appendChild(controlMappingRow);
2901
+ const reflectControlRow = () => {
2902
+ controlStrong.textContent = t('motora.controle');
2903
+ controlButton.textContent = t('motora.abrir');
2904
+ controlButton.setAttribute('aria-label', t('motora.controle'));
2905
+ controlMappingRow.hidden = actionsToMap().length === 0; // nothing named, nothing to map
2906
+ };
2907
+ reflectControlRow();
2908
+ /*
2909
+ * MAPEAR TOQUE (ADR-0151 §2; issue #182): which function each on-screen pad button carries, with `input/touch`'s own
2910
+ * editor (`renderTouchMap`, slot → one of the functions the game names). Offered only to a cartridge with a pad (ADR-0166),
2911
+ * and only for the buttons the pad DRAWS — a slot whose function the game does not name is not drawn (ADR-0162), so its
2912
+ * row would change nothing on screen. The pad is redrawn with every choice.
2913
+ */
2914
+ const touchPanel = mountPanel(mobilityCtx, {
2915
+ id: 'touchcfg',
2916
+ listId: 'touchmap-list',
2917
+ labels: () => ({
2918
+ title: t('motora.toque'),
2919
+ listLabel: t('motora.toque'),
2920
+ resetLabel: t('menu.restoreDefaults'),
2921
+ closeLabel: t('pause.pmback'),
2922
+ }),
2923
+ render: () => { touchPad.renderTouchMap(); hideSlotsWithoutAction(); },
2924
+ });
2925
+ touchPanel.shell.reset.hidden = true;
2926
+ const hideSlotsWithoutAction = () => {
2927
+ const named = cartridgeActions();
2928
+ const padMap = touchPad.getTouchMap();
2929
+ for (const sel of Array.from(touchPanel.shell.list.querySelectorAll('select[data-slot]'))) {
2930
+ const rowNode = sel.closest('.ctrl-row');
2931
+ if (rowNode)
2932
+ rowNode.hidden = !named.has(padMap[sel.dataset.slot ?? ''] ?? '');
2933
+ }
2934
+ };
2935
+ // after the select's own listener (it writes the map), the pad is drawn again with the new function
2936
+ touchPanel.shell.list.addEventListener('change', () => { drawPad(); hideSlotsWithoutAction(); });
2937
+ const touchMappingRow = doc.createElement('div');
2938
+ touchMappingRow.className = 'ctrl-row';
2939
+ const touchEnvelope = doc.createElement('span');
2940
+ const touchStrong = doc.createElement('strong');
2941
+ touchEnvelope.appendChild(touchStrong);
2942
+ touchMappingRow.appendChild(touchEnvelope);
2943
+ const touchButton = doc.createElement('button');
2944
+ touchButton.className = 'mode-btn';
2945
+ touchButton.setAttribute('type', 'button');
2946
+ touchButton.id = 'opt-toque';
2947
+ touchButton.addEventListener('click', () => touchPanel.open());
2948
+ touchMappingRow.appendChild(touchButton);
2949
+ mobilityPanel.shell.list.appendChild(touchMappingRow);
2950
+ const reflectTouchRow = () => {
2951
+ touchStrong.textContent = t('motora.toque');
2952
+ touchButton.textContent = t('motora.abrir');
2953
+ touchButton.setAttribute('aria-label', t('motora.toque'));
2954
+ touchMappingRow.hidden = !cartridge.onScreenPad || actionsToMap().length === 0; // no pad, or nothing named
2955
+ };
2956
+ reflectTouchRow();
2957
+ /*
2958
+ * 🔴 NO STICKY-KEYS ROW IN THIS PANEL (the Dev: «Tire a linha de acessibilidade motora»): the bar's ☝️ is the one
2959
+ * surface for that cycle (ADR-0218), and two surfaces of one setting was what the row had become.
2960
+ *
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.
2965
+ */
2966
+ /*
2967
+ * «ESPERAR ENTRE TOQUES» (ADR-0217; GAG Advanced/Motor, issue #182). The row beside the sticky keys, and the other half of
2968
+ * the same problem: that one is for a hand that cannot HOLD, this one for a hand that cannot press ONCE.
2969
+ *
2970
+ * ⚠️ OFF BY DEFAULT and offered as a choice, because for a child with no tremor it is half a second lost between every two
2971
+ * presses — in a game of reaction, the game. Hidden where the game holds no key, like its neighbour: what it refuses is a
2972
+ * second press, and a game nobody presses twice has none to refuse.
2973
+ */
2974
+ const cooldownRowSpec = () => ({ id: 'opt-cooldown', label: t('motor.espera'), hint: t('motor.espera.dica') });
2975
+ const { row: cooldownRow, control: cooldownButton } = controlRow(mobilityCtx, cooldownRowSpec());
2976
+ mobilityPanel.shell.list.appendChild(cooldownRow);
2977
+ const reflectCooldown = () => {
2978
+ labelRow(cooldownRow, cooldownRowSpec());
2979
+ const on = state.inputCooldown > 0;
2980
+ toggleBtn(cooldownButton, on);
2981
+ cooldownButton.textContent = toggleLabel(t, on);
2982
+ markChanged(t, cooldownRow, on !== (DEFAULTS.inputCooldown > 0));
2983
+ cooldownRow.hidden = !cartridge.declaration.holdsKeys();
2984
+ };
2985
+ cooldownButton.addEventListener('click', () => {
2986
+ state.setInputCooldownValue(state.inputCooldown > 0 ? 0 : COOLDOWN_MS);
2987
+ reflectCooldown();
2988
+ srSay(`${t('motor.espera')}: ${t(state.inputCooldown > 0 ? 'state.on' : 'state.off')}`);
2989
+ });
2990
+ reflectCooldown();
2991
+ /*
2992
+ * PLAYING WITH THE CAMERA, IN THE PANEL (issue #182; ADR-0215): «webcam (gestos/rosto/olhos)» was one of the motor rows the
2993
+ * Dev listed as missing, and until the 📷 existed there was nothing to put in it. Now there is, and this row is the same
2994
+ * setting the bar's 📷 cycles — one stored value (`incl_camera_control`), two surfaces, the panel's being the one that says
2995
+ * what each position does.
2996
+ *
2997
+ * ⚠️ HIDDEN WHERE THERE IS NO CAMERA TO ASK FOR, the same rule the icon uses: a row that offers a device the browser does
2998
+ * not have is the dead button of ADR-0106 §5, and a child who picks it waits for a permission dialog that never comes.
2999
+ *
3000
+ * 📌 AND THE MICROPHONE ROW IS RIGHT BELOW, since 2026-09-21: it used to be missing on purpose, because with no voice-command
3001
+ * transport a «microfone» row would switch nothing. The transport landed (issue #184), so the row has a subject.
3002
+ */
3003
+ const CAMERA_MODE_WORD = {
3004
+ off: 'state.off', hands: 'camera.hands', face: 'camera.face', eyes: 'camera.eyes',
3005
+ };
3006
+ const cameraRowSpec = () => ({
3007
+ label: t('motora.camera'),
3008
+ values: CAMERA_CONTROLS.map((m) => t(CAMERA_MODE_WORD[m])),
3009
+ current: Math.max(0, CAMERA_CONTROLS.indexOf(state.cameraControl)),
3010
+ });
3011
+ const cameraRow = doc.createElement('div');
3012
+ cameraRow.className = 'ctrl-row ctrl-row--passos';
3013
+ const cameraHint = doc.createElement('span');
3014
+ cameraHint.className = 'opt-hint';
3015
+ const cameraWrap = doc.createElement('span');
3016
+ cameraWrap.appendChild(cameraHint);
3017
+ cameraRow.appendChild(cameraWrap);
3018
+ const cameraSteps = mountSteps(mobilityCtx, cameraRowSpec());
3019
+ cameraSteps.id = 'opt-camera';
3020
+ cameraRow.appendChild(cameraSteps);
3021
+ mobilityPanel.shell.list.appendChild(cameraRow);
3022
+ const reflectCamera = () => {
3023
+ updateSteps(cameraSteps, cameraRowSpec());
3024
+ cameraHint.textContent = t('motora.camera.dica');
3025
+ cameraRow.hidden = !canCaptureMedia;
3026
+ };
3027
+ cameraSteps.addEventListener('passo', (ev) => {
3028
+ const next = nextStep(Math.max(0, CAMERA_CONTROLS.indexOf(state.cameraControl)), CAMERA_CONTROLS.length, ev.detail);
3029
+ const mode = CAMERA_CONTROLS[next];
3030
+ if (mode === state.cameraControl)
3031
+ return; // at the end of the line nothing moved, and nothing is announced
3032
+ state.setCameraControlValue(mode);
3033
+ reflectCamera();
3034
+ srSay(`${t('motora.camera')}: ${t(CAMERA_MODE_WORD[mode])}`);
3035
+ });
3036
+ // the 📷 and this row are one setting: whoever changes it, both show it
3037
+ stateOn('cameraControl', () => { reflectCamera(); });
3038
+ reflectCamera();
3039
+ /*
3040
+ * PLAYING BY SPEAKING, IN THE PANEL (issue #182's «microfone» row; ADR-0189): the same stored answer the bar's 👄 writes
3041
+ * (`incl_voice_control`), on the surface that has room to say what it does. Two surfaces of one setting, and neither may
3042
+ * name it differently — which is the correction ADR-0218 had just made to the ☝️.
3043
+ *
3044
+ * ⚠️ HIDDEN WHERE THERE IS NO MICROPHONE TO ASK FOR, the rule the 📷 above follows and the one ADR-0106 §5 states: a row
3045
+ * that offers a device this browser cannot even ask for is a dead control, and a child who picks it waits for a permission
3046
+ * dialog that never comes. What happens when the microphone EXISTS and is refused is another matter and is already
3047
+ * answered: `ui/voice-control` says why and puts the answer back to off, and this row follows it like the icon does.
3048
+ */
3049
+ const voiceRowSpec = () => ({ id: 'opt-voice', label: t('motora.voz'), hint: t('motora.voz.dica') });
3050
+ const { row: voiceRow, control: voiceButton } = controlRow(mobilityCtx, voiceRowSpec());
3051
+ mobilityPanel.shell.list.appendChild(voiceRow);
3052
+ const reflectVoice = () => {
3053
+ labelRow(voiceRow, voiceRowSpec());
3054
+ toggleBtn(voiceButton, state.voiceControl);
3055
+ voiceButton.textContent = toggleLabel(t, state.voiceControl);
3056
+ markChanged(t, voiceRow, state.voiceControl !== DEFAULTS.voiceControl);
3057
+ voiceRow.hidden = !canCaptureMedia;
3058
+ };
3059
+ voiceButton.addEventListener('click', () => {
3060
+ 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
+ reflectVoice();
3064
+ srSay(`${t('motora.voz')}: ${t(state.voiceControl ? 'state.on' : 'state.off')}`);
3065
+ });
3066
+ stateOn('voiceControl', () => { reflectVoice(); });
3067
+ reflectVoice();
3068
+ reflectKeyboard = () => {
3069
+ reflectKeyboardRows();
3070
+ reflectControlRow();
3071
+ reflectTouchRow();
3072
+ reflectCooldown();
3073
+ reflectCamera();
3074
+ reflectVoice();
3075
+ };
689
3076
  }
690
3077
  /*
691
- * ⚠️ `declaration` E `declines` SÃO GETTERS; o resto não é, e a assimetria é a decisão.
3078
+ * THE MOTOR EMPATHY SIMULATIONS REACH THE GAME HERE (ADR-0181): in the window's capture, after the menu navigation registered
3079
+ * its own, and before any cartridge hears a game key. A refused key and its release stop here; a tapped key passes and is
3080
+ * released at once by a synthetic keyup, which this filter lets through.
3081
+ */
3082
+ const empathyFilter = createEmpathyFilter();
3083
+ /*
3084
+ * AND THE COOL-DOWN, WHICH IS THE OPPOSITE OF THEM (ADR-0217): the simulations above make play harder so an adult can feel
3085
+ * what a motor disability costs; this refuses the SECOND press of a hand that shakes, which is a child losing a turn she did
3086
+ * not play. It sits in the same pass because the question is the same one — does this key reach the game — and it comes
3087
+ * FIRST: a press the cool-down refuses never happened, so it must not teach the simulations that a key is held.
3088
+ */
3089
+ const cooldown = createInputCooldown();
3090
+ stateOn('inputCooldown', () => { cooldown.reset(); }); // turning it off must not leave a press refused by an old wait
3091
+ const releasedByFilter = new WeakSet();
3092
+ const block = (e) => { e.preventDefault(); e.stopImmediatePropagation(); };
3093
+ win.addEventListener('keydown', (e) => {
3094
+ if (keyboard.whichPlayer(e.code) < 0)
3095
+ return;
3096
+ if (cooldown.keydown(e.code, win.performance.now(), state.inputCooldown, e.repeat || keys.has(e.code)) === 'refuse') {
3097
+ block(e);
3098
+ return;
3099
+ }
3100
+ const decision = empathyFilter.keydown(e.code, e.repeat, { noChords: state.oneButton, noGripStrength: state.noGripStrength });
3101
+ if (decision === 'barrar') {
3102
+ block(e);
3103
+ return;
3104
+ }
3105
+ if (decision === 'tocar') {
3106
+ const eventTarget = e.target ?? win;
3107
+ setTimeout(() => {
3108
+ // a release the keyboard's own press produced
3109
+ const released = stampSource(new KeyboardEvent('keyup', { code: e.code, key: e.key, bubbles: true, cancelable: true }), 'teclado');
3110
+ releasedByFilter.add(released);
3111
+ eventTarget.dispatchEvent(released);
3112
+ }, 0);
3113
+ }
3114
+ }, true);
3115
+ win.addEventListener('keyup', (e) => {
3116
+ if (releasedByFilter.has(e) || keyboard.whichPlayer(e.code) < 0)
3117
+ return;
3118
+ if (empathyFilter.keyup(e.code) === 'barrar')
3119
+ block(e);
3120
+ }, true);
3121
+ // Playing on the keyboard HIDES the pad — the same per-modality switch `input/keydown` makes. Only some player's keys:
3122
+ // a browser shortcut is not the child changing device.
3123
+ // 🔴 IN CAPTURE (`true`): `ui/menu-nav` consumes a menu's key in the window's capture with `stopPropagation()`, and a
3124
+ // bubbling listener never heard it — in a menu, the child moved to the keyboard and the pad stayed over the card (seen
3125
+ // by the Dev). `stopPropagation` does not silence another listener on the SAME node, so registration order does not matter.
3126
+ win.addEventListener('keydown', (e) => {
3127
+ if (sourceOfEvent(e) === 'toque')
3128
+ return; // the key the pad itself handed to a menu
3129
+ if (keyboard.whichPlayer(e.code) >= 0) {
3130
+ touchPad.hideTouchControls();
3131
+ padBeforeMenu = false;
3132
+ } // on the keyboard now
3133
+ }, true);
3134
+ /*
3135
+ * 🔴 THE BROWSER THE HEAVY FILES AND THE RECOGNISERS USE IS LENT HERE, ONCE (ADR-0232 D4, issue #207): the checked cache, the
3136
+ * hash and `fetch` are read from the HOST's window, and the modules below receive them instead of reaching the globals.
3137
+ * 📌 `caches` and `crypto.subtle` are ABSENT outside a secure context, and absent is an answer each module already gives: the
3138
+ * download reports every file, a loader names the files it cannot find, and nothing is kept unverified.
3139
+ */
3140
+ const heavyCaches = win.caches;
3141
+ const hasHeavyFile = checkedCacheHas(heavyCaches);
3142
+ // 📌 The microphone and the audio context the recognisers open, from the same window: `undefined` is a device without one.
3143
+ const mediaDevices = win.navigator?.mediaDevices;
3144
+ const getUserMedia = mediaDevices?.getUserMedia?.bind(mediaDevices);
3145
+ const HostAudioContext = win.AudioContext;
3146
+ /*
3147
+ * THE HEAVY FILES START COMING DOWN HERE, and the line is deliberately the LAST thing of the boot.
692
3148
  *
693
- * Os dois pertencem à metade do JOGO (ADR-0139 §1), logo têm de seguir o cartucho que estiver montado —
694
- * um campo fixo aqui devolveria, depois de um `mount()`, a declaração do cartucho que arrancou primeiro.
695
- * `pausa`, `tts`, `overlays`, `nav`, `keyboard` e o sonar são da PÁGINA e existem uma vez só, que é a
696
- * decisão inteira do ADR-0117 §2 — e é por isso que eles ficam como estão.
3149
+ * ⚠️ NO `await`. The start does not wait for the heavy files — if it did, a 3G school's first screen would stay blank for minutes
3150
+ * and the child would conclude the game does not open. The empty `catch` is the same rule written twice: a network failure here
3151
+ * cannot bring down a game that may not even use the voice.
697
3152
  *
698
- * 📌 `problems` e `alcance` ainda são fixos, e ainda descrevem o arranque. É a dívida que o ADR-0142
699
- * nomeia e que o `mount()` fecha.
3153
+ * 🔴 AND THE REPORT DOES NOT GO TO `problems`, for two reasons, and the first is the one that matters:
3154
+ *
3155
+ * 1. **IT ARRIVES AFTER THE READER HAS GONE.** `problems` is returned synchronously; the download runs in the background,
3156
+ * so EVERY line of it would land in an array the consumer has already read. Whoever does `if (motor.problems.length) …`
3157
+ * would see nothing, and whoever read it later would see a list that grew after boot.
3158
+ * 2. **IT WOULD DROWN WHAT CAN BE FIXED.** Without a network — a school without one is the target, not the exception —
3159
+ * every file is a failure pushed into a list ADR-0106 §2 built to say what the HOST LACKS. The child loses the
3160
+ * accessibility bar and the line that says so sits under all of them.
3161
+ *
3162
+ * 📌 The right channel is the one the function already has: `onHeavyProgress`, handed to whoever calls. A consumer who
3163
+ * wants to show «N MB left» or «the voice did not come down» has a way; the engine invents no surface.
700
3164
  */
3165
+ 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,
3174
+ cacheStorage: heavyCaches, fetch: win.fetch, digest: sha256With(win.crypto?.subtle), base: doc.baseURI,
3175
+ })
3176
+ .catch(() => { });
3177
+ }
701
3178
  /*
702
- * A PILHA DE CENAS É DA RAIZ, e não do retorno, porque o `desmontar()` tem de a alcançar. Nasce uma vez
703
- * (ADR-0117 §2: a página tem uma) e é esvaziada entre cartuchos, nunca substituída.
3179
+ * ⚠️ `declaration`, `declines`, `problems` AND `reach` ARE GETTERS; the rest are not, and the asymmetry is the decision.
3180
+ *
3181
+ * The first two belong to the GAME's half (ADR-0139 §1), and the last two are derived from it, so all four must follow
3182
+ * the mounted cartridge — a plain field here would return, after a `mount()`, what the cartridge that booted first had.
3183
+ * `pause`, `tts`, `overlays`, `nav`, `keyboard` and the sonar belong to the PAGE and exist once, which is the whole
3184
+ * decision of ADR-0117 §2.
3185
+ */
3186
+ /*
3187
+ * THE SCENE STACK BELONGS TO THE ROOT, and not to the return value, because `unmount()` must reach it. Born once
3188
+ * (ADR-0117 §2: the page has one) and emptied between cartridges, never replaced.
704
3189
  */
705
- const cenasDaRaiz = criarPilha();
706
- function montar(declaration, ganchos = {}) {
707
- // ⚠️ LANÇA, NÃO DIAGNOSTICA — a mesma regra do arranque, e por isso a mesma frase. Uma declaração
708
- // malformada é pré-condição: `problems` é para lacunas com que se consegue jogar, e isto não é uma.
709
- const malformada = conformanceProblems(declaration);
710
- if (malformada.length) {
711
- recusarDeclaracao('mount', malformada);
3190
+ const rootScenes = createSceneStack();
3191
+ // The hooks carry the REQUIRED answer to the accommodations (ADR-0153). The public type lets them be omitted, so an
3192
+ // absent value becomes `{}` — the cartridge that did not answer — and is refused below with ADR-0153's sentence, never
3193
+ // with a TypeError on `hooks.preset`.
3194
+ 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);
712
3200
  }
713
- cartucho = { ...ganchos, declaration };
714
- registrarMapeamentosDoCartucho();
715
- alcanceAtual = derivarAlcance();
716
- }
717
- function desmontar() {
718
- registrarMapeamentoDoTeclado(null);
719
- registrarMapeamentoDoPad(null);
720
- retirarAvisoDeAlcance();
721
- // ⚠️ `pop()` E NÃO UM `clear()`: cada `exit()` é a limpeza de DOM daquela cena, e saltá-la deixaria na
722
- // página o que o cartucho anterior desenhou. O laço tem fim porque `pop()` devolve `null` na pilha vazia.
723
- while (cenasDaRaiz.pop()) { /* o `exit()` de cada cena É o teardown dela */ }
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);
3210
+ cartridge = { ...hooks, declaration };
3211
+ mountHud(); // the numbers are the cartridge's: the new one's replace the old one's, and the room is measured again
3212
+ followCartridgeMappings(declaration);
3213
+ redrawGameOptions(); // the rows are the new cartridge's, drawn or cleared before its door is weighed
3214
+ pauseIcons.reflectPauseIcons(); // the bar follows the new cartridge: the hourglass exists only where time runs by itself
3215
+ currentReach = deriveReach();
3216
+ // The pad has the SHAPE of the preset, so it changes with the cartridge; the window listeners stay (`rewire`, not `attach`).
3217
+ drawPad();
3218
+ touchBindings.rewire();
3219
+ }
3220
+ /**
3221
+ * THE PAGE LINKS THE ENGINE STYLESHEET, or it is told (study item B4). `createGame` injects no CSS; without
3222
+ * `style.css` the panels, the footer band, the target floor and the focus rings are all missing, and nothing said so.
3223
+ * Read when `problems` is read, by the sentinel only that stylesheet declares — a stylesheet loading late is not
3224
+ * accused; a host without `getComputedStyle` measures nothing and accuses nothing.
3225
+ */
3226
+ function stylesheetMissing() {
3227
+ if (typeof win.getComputedStyle !== 'function' || !doc.documentElement)
3228
+ return [];
3229
+ const rootStyles = win.getComputedStyle(doc.documentElement);
3230
+ if (!rootStyles || typeof rootStyles.getPropertyValue !== 'function')
3231
+ return [];
3232
+ return rootStyles.getPropertyValue('--incl-engine-stylesheet').trim() ? [] : [`the page does not link the engine stylesheet \
3233
+ (package export \`the-inclusionist-engine/style.css\`): panels, the footer band, the target floor and the focus rings are \
3234
+ unstyled, so a child who plays by keyboard cannot see where focus is — link that stylesheet`];
3235
+ }
3236
+ /*
3237
+ * THE FLASH SAMPLER (study item B2, cut 2). Each animation frame the world's canvas is drawn into 160×120, the relative
3238
+ * luminance is computed per pixel (WCAG's sRGB formula) and averaged into the 16×12 grid of `core/flash-threshold` — per
3239
+ * pixel and then averaged, so a small bright area weighs by its area; a direct 16×12 downscale samples a few pixels and a
3240
+ * flash between them goes unseen. Registered from the frame after the call, gone when the time is up.
3241
+ */
3242
+ /*
3243
+ * STORAGE OUTSIDE THE ENGINE'S SCOPES (study item E2). The keys of both storages are photographed at boot; `problems`
3244
+ * names the ones that appeared since and sit outside every scope. Only what appeared: on a shared origin (localhost)
3245
+ * the keys already there are other pages'. By capability: a host without storage, or one that throws, measures nothing.
3246
+ */
3247
+ function storageKeys() {
3248
+ const keysFound = new Set();
3249
+ for (const name of ['localStorage', 'sessionStorage']) {
3250
+ try {
3251
+ const area = win[name];
3252
+ if (!area || typeof area.key !== 'function')
3253
+ continue;
3254
+ for (let i = 0; i < area.length; i++) {
3255
+ const k = area.key(i);
3256
+ if (k !== null)
3257
+ keysFound.add(k);
3258
+ }
3259
+ }
3260
+ catch { /* private mode or a host double: nothing to read */ }
3261
+ }
3262
+ return keysFound;
3263
+ }
3264
+ const keysAtBoot = storageKeys();
3265
+ function storageOutsideScope() {
3266
+ const keysSinceBoot = [...storageKeys()].filter((k) => !keysAtBoot.has(k));
3267
+ const outsideScopes = keysOutsideScopes(keysSinceBoot);
3268
+ if (!outsideScopes.length)
3269
+ return [];
3270
+ return [`the cartridge stored keys outside the engine's scopes (${outsideScopes.slice(0, 5).join(', ')}): a child's settings `
3271
+ + 'kept there do not follow them to the next game, and a game\'s own collide with other games\' — what belongs to '
3272
+ + 'the child goes under incl_* through the engine\'s settings, what belongs to the game under incl.<game>.* (storage-keys.gameKey)'];
3273
+ }
3274
+ const measuredProblems = [];
3275
+ /*
3276
+ * THE CHILD READS ALOUD, AND THE GAME RECEIVES TEXT (ADR-0216, issue #200). The engine owns the microphone, the route and
3277
+ * the promise of privacy; the cartridge calls `listen()`. ⚠️ Recognition on the device or nothing: `platform/reading` only
3278
+ * uses the browser's recogniser where it says it recognises locally, and refuses otherwise instead of quietly sending a
3279
+ * child's voice to a server.
3280
+ */
3281
+ /*
3282
+ * 📌 THE READING THREAD GOES WITH THE CARTRIDGE (issue #185), and the closer lives OUT HERE rather than on the `Reading`
3283
+ * object: a root that mounts another game keeps the same reading object, and a worker holding a compiled model of up to
3284
+ * 378 MiB for a cartridge that never listens is a school machine's memory spent on nothing. It is opened again at the next
3285
+ * `listen()`, which is also the moment the child is willing to wait. ⚠️ Out here because `Reading` is the CARTRIDGE's
3286
+ * vocabulary (ADR-0216): a method only this file calls has no business in a contract seven repositories read.
3287
+ */
3288
+ let closeReadingThread = () => { };
3289
+ const reading = (() => {
3290
+ const browserApis = win;
3291
+ 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;
3294
+ const listener = createReading({
3295
+ language: () => bcp47(),
3296
+ api: (browserApis.SpeechRecognition ?? browserApis.webkitSpeechRecognition ?? null),
3297
+ now: () => win.performance.now(),
3298
+ every: (fn, ms) => win.setInterval(fn, ms),
3299
+ stopEvery: (h) => win.clearInterval(h),
3300
+ report: (rowNode) => { if (!measuredProblems.includes(rowNode))
3301
+ measuredProblems.push(rowNode); },
3302
+ /**
3303
+ * THE ENGINE'S OWN RECOGNISER, and it arrives late on purpose (ADR-0216 §5): `platform/reading-runtime` is what names the
3304
+ * model files, so a game that never listens — and a child of a game that does, until the first `listen()` — loads none of
3305
+ * it. Only reached where the device's own recogniser cannot serve the language (ADR-0200 erratum).
3306
+ */
3307
+ /*
3308
+ * 🔴 AND IT RUNS IN A THREAD OF ITS OWN (issue #185). 📏 Measured in the lab: transcribing on the main thread cut the
3309
+ * recording in gaps of 4 s — the child goes on reading and the words she says while the page is busy are not in the
3310
+ * sound at all. In a worker the biggest gap was 264 ms.
3311
+ * ⚠️ WHERE THERE IS NO `Worker` the reading still works, on this thread, and the LINE SAYS SO: an engine that quietly
3312
+ * fell back would put the defect back exactly where nobody looks for it.
3313
+ */
3314
+ model: o.uses?.reading
3315
+ ? async (language) => {
3316
+ /*
3317
+ * ⚠️ A LOCAL NAMED `Worker`, HOLDING THE HOST'S, AND THE LITERAL BELOW IN EXACTLY THIS FORM (ADR-0232 D4). A bundler
3318
+ * emits the worker's file and rewrites its address only for `new Worker(new URL('…', import.meta.url), { … })`
3319
+ * written out — which is what makes the thread travel with whoever installs the package; a plain string would
3320
+ * resolve against the PAGE and 404 in every game whose folders differ. The bundler reads the NAME; the VALUE is the
3321
+ * window this root was lent, so the thread is opened by the host and no global is reached.
3322
+ */
3323
+ const Worker = browserApis.Worker;
3324
+ if (typeof Worker === 'function') {
3325
+ const { createReadingInWorker } = await import('../platform/reading-in-worker.js');
3326
+ readingThread?.close();
3327
+ readingThread = createReadingInWorker({
3328
+ base: doc.baseURI,
3329
+ language,
3330
+ spawn: () => new Worker(new URL('../platform/reading-worker.js', import.meta.url), { type: 'module' }),
3331
+ // 📌 THE SENTENCE IS WRITTEN HERE and the module hands over only the REASON: a thread that fails to open when
3332
+ // 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.
3334
+ 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>)';
3338
+ if (!measuredProblems.includes(line))
3339
+ measuredProblems.push(line);
3340
+ },
3341
+ });
3342
+ closeReadingThread = () => { readingThread?.close(); readingThread = null; };
3343
+ return readingThread;
3344
+ }
3345
+ measuredProblems.push('reading: this browser has no `Worker`, so the transcription runs on the same thread that '
3346
+ + '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) });
3350
+ }
3351
+ : undefined,
3352
+ /**
3353
+ * AND THE MICROPHONE, for the model route only: the browser's own recogniser opens one itself. It is built at the first
3354
+ * reading and let go at the end of each — a track left running is a browser still saying «this page is listening».
3355
+ */
3356
+ record: o.uses?.reading
3357
+ ? async (options) => {
3358
+ const { createMicrophone } = await import('../platform/microphone.js');
3359
+ microphone ??= createMicrophone({
3360
+ getUserMedia, createContext: (rate) => new HostAudioContext({ sampleRate: rate }), now: () => win.performance.now(),
3361
+ });
3362
+ return microphone.record(options);
3363
+ }
3364
+ : undefined,
3365
+ });
3366
+ const notDeclared = 'reading: this game called `reading.listen()` without declaring `uses: { reading: true }` — the child '
3367
+ + 'speaks and nothing answers, because a delivery built from this declaration carries no reading model; declare it';
3368
+ return {
3369
+ ready: () => (o.uses?.reading ? listener.ready() : Promise.resolve({ can: false, why: 'no-model' })),
3370
+ stop: () => listener.stop(),
3371
+ listen: (options) => {
3372
+ if (!o.uses?.reading) {
3373
+ if (!measuredProblems.includes(notDeclared))
3374
+ measuredProblems.push(notDeclared);
3375
+ return Promise.reject(new Error('reading was not declared by this game: `uses: { reading: true }`'));
3376
+ }
3377
+ return listener.listen(options);
3378
+ },
3379
+ };
3380
+ })();
3381
+ /*
3382
+ * PLAYING WITH THE EYES (ADR-0213; issues #194, #196): the stored 👀 position drives `ui/eye-control` — the camera, the reading, the
3383
+ * keys stamped `olhos` on `#game-region` for seat 0, and the regions drawn over the game. What cannot start is said, lands here in
3384
+ * `problems`, and puts the 👀 back to off. A stored position opens the camera at start, which asks the child's permission.
3385
+ */
3386
+ /*
3387
+ * THE VIRTUAL CONTROLLER CARRIES COMMANDS TO THE GAME (ADR-0111 erratum; issue #197). The keyboard reaches it by the child's scheme,
3388
+ * in the window's capture after the menu navigation and the motor simulations (a key they refused stopped there); a transport that
3389
+ * reads positions presses the controller directly.
3390
+ */
3391
+ const deliverCommand = (cmd) => { cartridge.onCommand?.(cmd); };
3392
+ /*
3393
+ * THE SCAN ITSELF (ADR-0218): the list is the positions this cartridge declared AND NAMED, because the chip says the game's
3394
+ * 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).
3396
+ */
3397
+ const gameRegion = $('#game-region');
3398
+ const scanChip = gameRegion ? mountScanOverlay(doc, gameRegion) : null;
3399
+ let scanner = null;
3400
+ let scanFrame = 0;
3401
+ const scanWord = (item) => scanItemText(item, (a) => (cartridge.preset ? labellerFrom(cartridge.preset)(a) : null), t('scan.nothing'));
3402
+ // 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
+ const scanShow = (item) => { if (scanChip?.showing(scanWord(item)))
3404
+ reserveBarBand(); };
3405
+ const scanTick = () => {
3406
+ if (!scanner)
3407
+ return;
3408
+ scanShow(scanner(win.performance.now()).showing.item);
3409
+ scanFrame = win.requestAnimationFrame(scanTick);
3410
+ };
3411
+ const stopScan = () => {
3412
+ if (scanFrame)
3413
+ win.cancelAnimationFrame(scanFrame);
3414
+ scanFrame = 0;
3415
+ scanner = null;
3416
+ scanChip?.hide();
3417
+ reserveBarBand(); // the room the chip was keeping goes back to the game
3418
+ };
3419
+ const startScan = () => {
3420
+ if (scanner)
3421
+ 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);
3425
+ scanTick();
3426
+ };
3427
+ scanPress = (source) => {
3428
+ if (!scanner)
3429
+ return;
3430
+ // 📌 THE CHIP IS NOT REDRAWN HERE, and a surviving mutation is why: the frame loop above draws every frame, so a second
3431
+ // drawing path only saved the sixteen milliseconds until the next one — a line that could disagree with the loop and could
3432
+ // never be seen doing it.
3433
+ const out = scanner(win.performance.now(), { press: true });
3434
+ const action = out.commanded;
3435
+ 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);
3440
+ win.setTimeout(() => virtualController.release(action, source, 0), SWITCH_SCAN_DEFAULTS.pulseMs);
3441
+ };
3442
+ stateOn('switchScan', (on) => { if (on)
3443
+ startScan();
3444
+ else
3445
+ stopScan(); });
3446
+ whenDisposed(stopScan); // the scan's frames are this root's, and an ended root keeps none running (ADR-0220)
3447
+ if (state.switchScan)
3448
+ startScan();
3449
+ const virtualController = createVirtualController({
3450
+ 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`.
3454
+ holdKey: markKeyFrom,
3455
+ releaseKey: releaseKey, menuKey: keyToMenu, deliver: deliverCommand,
3456
+ });
3457
+ /*
3458
+ * 🔴 THE KEYBOARD CONDUCTOR, AND ONLY THAT (ADR-0223). It resolves the action and PRESSES the virtual controller, like the
3459
+ * other transports — so there is ONE `deliver`, called from one place.
3460
+ *
3461
+ * 📏 What that guarantees:
3462
+ * · with a menu open, a release is delivered only for a press the game HEARD — the controller's `held` memory;
3463
+ * · a press swallowed by a menu followed by a release does not deliver a release without a press;
3464
+ * · the question is asked the INVERSE way and does not age: what is not the keyboard is not this conductor's. A
3465
+ * list of transports to exclude would age with the list, as it did when voice and scan arrived.
3466
+ *
3467
+ * 📌 And it does not ask whether a menu is open. That question has ONE answer, the controller's; what makes it true
3468
+ * for the keyboard is `keyToMenu` above, which does not redispatch a key that is already in the world.
3469
+ */
3470
+ for (const kind of ['keydown', 'keyup']) {
3471
+ win.addEventListener(kind, (e) => {
3472
+ if (e.repeat || !cartridge.onCommand)
3473
+ return;
3474
+ const source = sourceOfEvent(e);
3475
+ // ⚠️ NO STAMP IS THE REAL KEYBOARD: an event the child produced carries no expando. The only stamp that belongs to
3476
+ // this conductor is `teclado`, and it exists for the release the motor filter synthesises.
3477
+ if (source && source !== 'teclado')
3478
+ return;
3479
+ const seat = keyboard.whichPlayer(e.code);
3480
+ if (seat < 0)
3481
+ return;
3482
+ const action = keyboard.actionOf(e.code, seat);
3483
+ if (!action)
3484
+ return;
3485
+ if (kind === 'keydown')
3486
+ virtualController.press(action, source, seat);
3487
+ else
3488
+ virtualController.release(action, source, seat);
3489
+ }, true);
3490
+ }
3491
+ /*
3492
+ * 🔴 LOSING FOCUS IS THE KEYUP THAT NEVER ARRIVES. With a key down, a click on the browser's own bar, an on-screen
3493
+ * keyboard or a switch-access program taking focus, or a tab change leaves the page without its keyup: the key stays
3494
+ * held, the character keeps walking, and the cartridge believes the button is still down. The window's `blur` is the
3495
+ * only signal left, so it dispatches the keyup each held key is owed — through every listener a real one would reach
3496
+ * (the simulations, the cool-down, this conductor), which is what keeps them all agreeing that the key is up.
3497
+ *
3498
+ * Which keys those are is `input/state`'s answer (`letGoOfTheKeyboard`); stamped `teclado` because this engine never
3499
+ * dispatches an unsigned synthetic key (ADR-0109), and the keyboard's own stamp is what the motor filter's release uses.
3500
+ */
3501
+ win.addEventListener('blur', () => letGoOfTheKeyboard((code) => {
3502
+ win.dispatchEvent(stampSource(new KeyboardEvent('keyup', { code, bubbles: true, cancelable: true }), 'teclado'));
3503
+ }));
3504
+ /*
3505
+ * 🔴 THE GAMEPAD, MOUNTED BY THE ENGINE (ADR-0224), like every other transport: the virtual controller is a local of
3506
+ * this function, and the one door (ADR-0223) has to reach it. A physical gamepad works because the child plugged one
3507
+ * in, not because a game remembered to ask.
3508
+ *
3509
+ * 📌 Almost every port of `GamepadCtx` is answered here with what this root already has. The rest are the cartridge's
3510
+ * world and arrive in one field (`GamepadGameHooks`), with **each absence having a written meaning** — never guessed.
3511
+ * A cartridge that declares nothing has a working gamepad.
3512
+ */
3513
+ // 📌 The absences are resolved in `input/gamepad`, in a table: what an absence MEANS is a decision, and a composition
3514
+ // root carries wiring (ADR-0221, erratum). Only `worldRunning`'s is from here, because only whoever mounts knows which
3515
+ // menus it has open.
3516
+ const gameHooks = padGameAnswers(cartridge.gamepad, () => !menuWithDpad());
3517
+ const gamepad = initGamepad({
3518
+ $, padMaps, input, t: translator.t,
3519
+ padTable: (players, seat) => padTableNow(players, seat), // the MOUNTED cartridge's table, rebuilt by `mount()`
3520
+ oneButton: () => state.oneButton, // the motor empathy, read each frame from the settings store (ADR-0232)
3521
+ getGamepads: () => win.navigator?.getGamepads?.() ?? [],
3522
+ // THE GAME'S WORD for a position: the engine knows the position exists, only the cartridge knows what it is called —
3523
+ // and it already declared that in the `preset` to exist.
3524
+ actionLabel: (action) => (cartridge.preset ? labellerFrom(cartridge.preset)(action) : null),
3525
+ srSay, srAlert,
3526
+ frontOverlay: overlays.frontOverlay,
3527
+ // ⚠️ «PAUSE MENU» HERE IS EVERY MENU WITH A DIRECTIONAL, not only the card: the transport's `steerPause` already
3528
+ // handles the shared dialog before the card, which is the panel open on top. The same question the controller asks.
3529
+ pauseMenu: menuWithDpad,
3530
+ 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.
3533
+ pause: () => { enterQuickPause(0); },
3534
+ resume: togglePauseByTouch,
3535
+ isAttractActive: gameHooks.attractActive,
3536
+ stopAttract: gameHooks.stopAttract,
3537
+ // A PHYSICAL button makes the on-screen pad vanish — the same per-modality switch as the keyboard.
3538
+ isTouchMode: () => { const p = $('#touch-controls'); return !!p && !p.hidden; },
3539
+ hideTouchControls: () => { touchPad.hideTouchControls(); padBeforeMenu = false; },
3540
+ // ⚠️ SEEDED AT EVERY READ and not once: the cartridge repopulates the list at every restart, and a seat seeded only at
3541
+ // boot would leave the new players without a `pad` — invisible to the transport, with no error anywhere.
3542
+ getPlayers: () => seatEveryPlayer(players()),
3543
+ getNumPlayers: () => players().length,
3544
+ navTitle: gameHooks.navTitle,
3545
+ onBar: isOnBar,
3546
+ // ✅ THE BAR'S SECOND WAY OUT: `ui/menu-nav` calls `navBar` with `(i, k)` and never the third argument, the START edge —
3547
+ // the SECOND way out of the mode (ADR-0044 item 7). With the gamepad mounted by the root, it arrives here.
3548
+ navBar,
3549
+ sharedDialogOpen: nav.sharedDialogOpen,
3550
+ navDialog: nav.navDialog,
3551
+ getPauseMenu: (i) => $(`#vp-pause-${i}`),
3552
+ navPause: nav.navPause,
3553
+ setPauseActor,
3554
+ playerEdge,
3555
+ press: (action, source, player) => virtualController.press(action, source, player),
3556
+ release: (action, source, player) => virtualController.release(action, source, player),
3557
+ modalInput: gameHooks.modalInput,
3558
+ hasModal: gameHooks.hasModal,
3559
+ joinPlayer: gameHooks.joinPlayer,
3560
+ respawnPlayer: gameHooks.respawnPlayer,
3561
+ clearWaitingBadge: gameHooks.clearWaitingBadge,
3562
+ wizardStep: gameHooks.wizardStep,
3563
+ wizardTick: gameHooks.wizardTick,
3564
+ });
3565
+ /*
3566
+ * AND THE ENGINE POLLS, because whoever mounts polls. ⚠️ The game loop is the CARTRIDGE's (`core/loop.startLoop` is
3567
+ * called by it), so the root has nowhere to hang a frame — it opens its own, as it already does for the scan and the
3568
+ * camera. A gamepad read every frame is the price written in ADR-0224's negative consequence.
3569
+ */
3570
+ let padFrameHandle = 0;
3571
+ const anyPadConnected = () => (win.navigator?.getGamepads?.() ?? []).some(Boolean);
3572
+ const pollPad = () => { gamepad.pollPads(); padFrameHandle = win.requestAnimationFrame(pollPad); };
3573
+ const stopPollingPad = () => { if (padFrameHandle)
3574
+ win.cancelAnimationFrame(padFrameHandle); padFrameHandle = 0; };
3575
+ /*
3576
+ * ⚠️ THE LOOP EXISTS ONLY WHILE A GAMEPAD IS CONNECTED, and that is pillar 1 deciding: a `requestAnimationFrame` that
3577
+ * never sleeps costs battery on a school Chromebook, and the vast majority of machines will never see a gamepad.
3578
+ * 📌 `gamepadconnected` is the event the specification requires to arrive before the pad appears in the list, and the
3579
+ * query at boot covers the root born with one already connected (another cartridge's `mount()`, for example).
3580
+ * 📌 A host WITHOUT frames — the node project is one — is a capability of the environment and stays quiet (ADR-0169):
3581
+ * with no frames there is no game running for the gamepad to drive.
3582
+ */
3583
+ const startPollingPad = () => { if (padFrameHandle || typeof win.requestAnimationFrame !== 'function')
3584
+ return; padFrameHandle = win.requestAnimationFrame(pollPad); };
3585
+ win.addEventListener('gamepadconnected', startPollingPad);
3586
+ win.addEventListener('gamepaddisconnected', () => { if (!anyPadConnected())
3587
+ stopPollingPad(); });
3588
+ if (anyPadConnected())
3589
+ startPollingPad();
3590
+ const gazeRegion = $('#game-region');
3591
+ if (canCaptureMedia && gazeRegion) {
3592
+ // PLAYING THROUGH THE WEBCAM (ADR-0215): one stored position, off · hands · face · eyes; `ui/camera-control` starts only the control at
3593
+ // that position. Each control opens the camera itself and lets it go when the position moves on.
3594
+ const visionLoop = {
3595
+ requestFrame: (cb) => win.requestAnimationFrame(cb), cancelFrame: (h) => win.cancelAnimationFrame(h),
3596
+ now: () => win.performance.now(), every: (cb, ms) => win.setInterval(cb, ms), stopEvery: (h) => win.clearInterval(h),
3597
+ };
3598
+ const cameraDeps = {
3599
+ t: translator.t, doc, region: gazeRegion, base: doc.baseURI, hasFile: hasHeavyFile, loop: visionLoop, controller: virtualController, say: srSay, alert: srAlert,
3600
+ report: (rowNode) => { if (!measuredProblems.includes(rowNode))
3601
+ measuredProblems.push(rowNode); },
3602
+ turnOff: () => state.setCameraControlValue('off'),
3603
+ };
3604
+ // the eyes: the relative reading and the four-zone cycle (ADR-0213), presses from `olhos`, the eye lines and the regions' outlines
3605
+ const eyes = createEyeControl(cameraDeps);
3606
+ // the face: the Dev's face map (ADR-0210), presses from `rosto`, the eyes, brows and lips lines
3607
+ const face = createFaceControl({ ...cameraDeps, openFeed: videoFeed(doc, win.navigator.mediaDevices) });
3608
+ // the hands: the Gesture Recognizer and the Dev's hands map (ADR-0210), presses from `gestos`, the hands' lines (issue #191)
3609
+ const hands = createHandControl({ ...cameraDeps, openFeed: videoFeed(doc, win.navigator.mediaDevices) });
3610
+ const cameraControls = { eyes, face, hands };
3611
+ stateOn('cameraControl', (mode) => { followCameraMode(mode, cameraControls); });
3612
+ followCameraMode(state.cameraControl, cameraControls);
3613
+ whenDisposed(() => { followCameraMode('off', cameraControls); }); // the camera closes with the root that opened it (ADR-0220)
3614
+ }
3615
+ /*
3616
+ * PLAYING BY SPEAKING (ADR-0189, ADR-0193, ADR-0194; issue #184): the stored 👄 drives `ui/voice-control` — the recogniser
3617
+ * from the delivery, the microphone that stays open, and presses stamped `fala` on the virtual controller.
3618
+ *
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».
3621
+ */
3622
+ 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
+ voiceControl = createVoiceControl({
3633
+ t: translator.t, base: doc.baseURI, language: () => bcp47(), controller: virtualController, menuWords,
3634
+ say: srSay, alert: srAlert,
3635
+ report: (line) => { if (!measuredProblems.includes(line))
3636
+ measuredProblems.push(line); },
3637
+ turnOff: () => { state.setVoiceControlValue(false); },
3638
+ after: (fn, ms) => { win.setTimeout(fn, ms); },
3639
+ // ⚠️ 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)),
3641
+ getUserMedia, createContext: () => new HostAudioContext(),
3642
+ });
3643
+ 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); });
3650
+ }
3651
+ /*
3652
+ /*
3653
+ * THE FLASH SAMPLER LIVES IN `platform/flash-sampler` (ADR-0221, issue #203); what stays here is what only the root
3654
+ * knows: which is THIS cartridge's world canvas, and where a failure goes.
3655
+ */
3656
+ const sampleWorldFlashes = (ms) => sampleFlashes({
3657
+ canvas: () => {
3658
+ const declaredWorld = cartridge.declaration.world();
3659
+ const eventTarget = declaredWorld.kind === 'element' ? $(declaredWorld.selector) : null;
3660
+ return eventTarget?.tagName === 'CANVAS' ? eventTarget : eventTarget?.querySelector('canvas') ?? null;
3661
+ },
3662
+ scratch: () => doc.createElement('canvas'),
3663
+ frame: typeof win.requestAnimationFrame === 'function' ? (cb) => { win.requestAnimationFrame(cb); } : undefined,
3664
+ report: (rowNode) => { measuredProblems.push(rowNode); },
3665
+ }, ms);
3666
+ function unmountAll() {
3667
+ closeReadingThread();
3668
+ followCartridgeMappings(null);
3669
+ removeReachNotice();
3670
+ hudMounted?.remove();
3671
+ hudMounted = null;
3672
+ reserveBarBand();
3673
+ // ⚠️ `pop()` AND NOT A `clear()`: each `exit()` is that scene's DOM cleanup, and skipping it would leave on the page
3674
+ // what the previous cartridge drew. The loop ends because `pop()` returns `null` on an empty stack.
3675
+ while (rootScenes.pop()) { /* each scene's `exit()` IS its teardown */ }
3676
+ }
3677
+ function dispose() {
3678
+ unmountAll();
3679
+ // 📌 The gamepad's polling loop belongs to the ROOT since ADR-0224, so it dies with it: a `requestAnimationFrame` that
3680
+ // survives `dispose()` reads the Gamepad API forever, in a root that has no players any more (ADR-0220).
3681
+ stopPollingPad();
3682
+ for (const release of endOfLife.splice(0))
3683
+ release(); // drained as it goes: a second `dispose()` finds nothing to release
3684
+ listeners.releaseAll();
724
3685
  }
725
3686
  return {
726
- get declaration() { return cartucho.declaration; },
3687
+ get declaration() { return cartridge.declaration; },
727
3688
  get declines() { return declines(); },
728
- mount: montar,
729
- unmount: desmontar,
730
- pausa,
3689
+ mount: mountAll,
3690
+ unmount: unmountAll,
3691
+ dispose,
3692
+ pause: pauseControls,
731
3693
  tts,
3694
+ reading,
3695
+ captionSound: writeSoundCaption,
3696
+ gameSpeed: () => state.gameSpeed,
3697
+ menuIndexOn: () => state.menuIndexOn,
3698
+ t: translator.t,
3699
+ localeReady: translator.ready,
3700
+ say: srSay,
3701
+ alert: srAlert,
3702
+ mirrorAnnouncements: announcer.mirrorTo,
3703
+ libras: { isOpen: libras.isOpen, say: libras.say, tick: libras.tick, toggle: () => { libras.toggle(translator.t); } },
3704
+ settings: state,
3705
+ input,
3706
+ keyboardConfig,
3707
+ audio: mixer,
3708
+ crt,
3709
+ lq,
3710
+ measureFlashes: sampleWorldFlashes,
732
3711
  overlays,
733
3712
  nav,
734
3713
  keyboard,
3714
+ controller: virtualController,
735
3715
  sonar,
736
- aplicarFiltroDeVisao,
737
- cenas: cenasDaRaiz,
3716
+ applyVisionFilter: setVisionFilter,
3717
+ scenes: rootScenes,
3718
+ onLocaleChange: (fn) => { localeListeners.push(fn); },
738
3719
  cvdFilters,
739
- get problems() { return [...problemasDoHospedeiro, ...problemasDoCartucho()]; },
740
- aoFalhar,
741
- get alcance() { return alcanceAtual; },
3720
+ get problems() { return [...hostProblems, ...stylesheetMissing(), ...measureCartridgeProblems(), ...translator.dictionaryGaps(), ...measuredProblems, ...storageOutsideScope()]; },
3721
+ onFailure: announceFailure,
3722
+ get reach() { return currentReach; },
742
3723
  };
743
3724
  }