@the-inclusionist/engine 8.0.0-rc.1 → 8.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 (50) hide show
  1. package/app/public/vendor/fonts/playwrite-ar.woff2 +0 -0
  2. package/app/public/vendor/fonts/playwrite-br.woff2 +0 -0
  3. package/app/public/vendor/fonts/playwrite-ca.woff2 +0 -0
  4. package/app/public/vendor/fonts/playwrite-cl.woff2 +0 -0
  5. package/app/public/vendor/fonts/playwrite-co.woff2 +0 -0
  6. package/app/public/vendor/fonts/playwrite-mx.woff2 +0 -0
  7. package/app/public/vendor/fonts/playwrite-us-modern.woff2 +0 -0
  8. package/app/public/vendor/fonts/playwrite-us-trad.woff2 +0 -0
  9. package/app/public/vendor/fonts.css +21 -0
  10. package/dist-pkg/boot/create-game.d.ts +33 -4
  11. package/dist-pkg/boot/create-game.js +72 -11
  12. package/dist-pkg/core/contract.d.ts +60 -0
  13. package/dist-pkg/core/contract.js +42 -0
  14. package/dist-pkg/i18n/en.js +11 -0
  15. package/dist-pkg/i18n/es.js +11 -0
  16. package/dist-pkg/i18n/pt.js +14 -0
  17. package/dist-pkg/input/default-bindings.js +20 -12
  18. package/dist-pkg/input/gamepad.d.ts +15 -1
  19. package/dist-pkg/input/gamepad.js +30 -9
  20. package/dist-pkg/input/keyboard.d.ts +32 -0
  21. package/dist-pkg/input/keyboard.js +46 -6
  22. package/dist-pkg/input/keydown.d.ts +13 -0
  23. package/dist-pkg/input/keydown.js +9 -0
  24. package/dist-pkg/input/latch-edge.d.ts +28 -0
  25. package/dist-pkg/input/latch-edge.js +45 -0
  26. package/dist-pkg/input/latch-sync.d.ts +47 -0
  27. package/dist-pkg/input/latch-sync.js +43 -0
  28. package/dist-pkg/input/pad-defaults.d.ts +22 -0
  29. package/dist-pkg/input/pad-defaults.js +34 -0
  30. package/dist-pkg/input/touch-bindings.d.ts +10 -0
  31. package/dist-pkg/input/touch-bindings.js +6 -0
  32. package/dist-pkg/platform/pesados-catalogo.d.ts +14 -0
  33. package/dist-pkg/platform/pesados-catalogo.js +177 -0
  34. package/dist-pkg/platform/pesados.d.ts +31 -0
  35. package/dist-pkg/platform/pesados.js +75 -0
  36. package/dist-pkg/platform/voice-plan.d.ts +20 -1
  37. package/dist-pkg/platform/voice-plan.js +20 -1
  38. package/dist-pkg/platform/vozes-prontas.d.ts +28 -0
  39. package/dist-pkg/platform/vozes-prontas.js +61 -0
  40. package/dist-pkg/ui/fonts.d.ts +24 -0
  41. package/dist-pkg/ui/fonts.js +66 -1
  42. package/dist-pkg/ui/pause-icons.d.ts +36 -2
  43. package/dist-pkg/ui/pause-icons.js +9 -1
  44. package/dist-pkg/ui/settings-motor.d.ts +21 -1
  45. package/dist-pkg/ui/settings-motor.js +22 -4
  46. package/dist-pkg/ui/settings-typo.d.ts +18 -4
  47. package/dist-pkg/ui/settings-typo.js +16 -11
  48. package/docs/CREDITS.md +15 -12
  49. package/docs/LICENSES.md +170 -144
  50. package/package.json +2 -1
@@ -0,0 +1,177 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // platform/pesados-catalogo.ts — O QUE É PESADO, DE ONDE VEM, E QUANTO PESA.
3
+ //
4
+ // 📌 SEPARADO DO BUSCADOR de propósito: a lista é DADO e muda por decisão registada; o buscador é regra e
5
+ // muda por defeito encontrado. Juntos, cada correcção de uma URL mexeria no ficheiro que decide a ordem das
6
+ // descargas, e cada correcção da ordem mexeria na lista que um registo governa.
7
+ //
8
+ // ========================= 🔴 A PRIMEIRA VERSÃO DESTE FICHEIRO COMETEU O DEFEITO QUE ELE SERVE =========================
9
+ // Ela escrevia o host à mão (`const HF = 'https://huggingface.co/…'`), a lista das quatro vozes outra vez, e
10
+ // uma segunda derivação do caminho `idioma/locale/nome/qualidade/…`. As três coisas JÁ EXISTEM no
11
+ // `platform/voice-plan.ts`, que o ADR-0114 designou como o sítio ÚNICO onde o host é nomeado — e cujo próprio
12
+ // comentário nomeia as três vezes que este repositório pagou por tabelas duplicadas (o `DomQuery`, os rótulos
13
+ // de movimento reduzido, as chaves de armazenamento).
14
+ //
15
+ // 📏 QUEM APANHOU FOI O INVENTÁRIO, e não pela duplicação: o `nada-vem-de-fora` recusou uma URL nova sem razão
16
+ // escrita. A URL era só a PROVA; declará-la teria feito o gate ficar verde com a duplicação lá dentro. ⚠️ É a
17
+ // razão de a saída ser APAGAR a cópia e não desculpá-la — declarar duas vezes o mesmo endereço é precisamente
18
+ // a cláusula que o `nada-de-cdn-a-mao` conta com um número.
19
+ import { VOZES_NEURAIS, urlDoModelo, urlDaConfig } from './voice-plan.js';
20
+ /** O nome da Cache Storage. Versionado: mudar o conteúdo do catálogo não deve servir bytes velhos. */
21
+ export const CACHE_PESADOS = 'incl-pesados-v1';
22
+ /**
23
+ * O PESO DE CADA VOZ, MEDIDO EM 2026-09-09 e não estimado — `Content-Length` do modelo e corpo da configuração.
24
+ *
25
+ * 📌 O PESO MORA AQUI E O ENDEREÇO MORA NO `voice-plan`, e a divisão não é arrumação: o `voice-plan` responde
26
+ * «que vozes a engine garante e onde estão», que é decisão do ADR-0110; isto responde «quanto custa descê-las
27
+ * hoje», que é uma medição com data e que muda quando o fornecedor recomprime um ficheiro.
28
+ *
29
+ * ⚠️ UMA VOZ NOVA NO `voice-plan` SEM MEDIÇÃO AQUI FICA SEM `bytes`, e o aviso de «faltam N MB» passaria a
30
+ * sub-reportar em silêncio. É por isso que o gate exige `bytes > 0` em toda entrada de voz COM url: o buraco
31
+ * é pequeno e mudo, que é a forma de defeito que este repositório persegue.
32
+ */
33
+ const PESO_MEDIDO = Object.freeze({
34
+ 'pt_BR-faber-medium': { modelo: 63_201_294, config: 4_855 },
35
+ 'en_US-ryan-medium': { modelo: 63_201_294, config: 4_883 },
36
+ 'en_US-amy-medium': { modelo: 63_201_294, config: 4_882 },
37
+ 'es_MX-claude-high': { modelo: 63_122_309, config: 4_963 },
38
+ });
39
+ /**
40
+ * ⚠️ O `urlDoModelo` devolve `null` para um identificador que não se deixe ler, e a razão viaja com a entrada
41
+ * em vez de a entrada desaparecer. Uma URL inventada dá 404 na escola; um `null` COM razão dá para reportar
42
+ * antes de sair de casa, que é a regra que o `voice-plan` já escreve na própria função.
43
+ */
44
+ const RAZAO_ID_TORTO = 'o identificador da voz não tem a forma `locale-nome-qualidade`, então o caminho no '
45
+ + 'fornecedor não se deixa derivar. Corrija o identificador no `platform/voice-plan.ts`.';
46
+ /**
47
+ * AS DUAS ENTRADAS DE UMA VOZ. ⚠️ SÃO DUAS E NÃO UMA: o `.onnx` é o modelo e o `.onnx.json` é a configuração,
48
+ * e o piper recusa-se a falar sem a segunda. Um catálogo que só trouxesse a primeira produziria uma voz
49
+ * «baixada» que não fala — que é pior do que uma voz em falta, porque a primeira parece resolvida.
50
+ */
51
+ function entradasDaVoz(v) {
52
+ const peso = PESO_MEDIDO[v.voice];
53
+ return [
54
+ { id: `voz:${v.voice}`, url: urlDoModelo(v), bytes: peso?.modelo, porQueNaoTemFonte: RAZAO_ID_TORTO },
55
+ { id: `voz:${v.voice}:cfg`, url: urlDaConfig(v), bytes: peso?.config, porQueNaoTemFonte: RAZAO_ID_TORTO },
56
+ ];
57
+ }
58
+ /**
59
+ * O RUNTIME DE VISÃO — **MediaPipe**, decidido pelo Dev em 2026-09-09 (ADR-0124): «piper-tts, mediapipe
60
+ * (webgazer não), e LPCP: devem acompanhar a engine».
61
+ *
62
+ * 🔴 ESTA ENTRADA DIZIA «a #129 ainda não escolheu o fornecedor» DEPOIS DE ELE TER ESCOLHIDO, e a linha
63
+ * sobreviveu ao registo que a contradizia. Não era só trabalho em falta: era uma afirmação FALSA a dirigir
64
+ * quem a lesse para uma issue já fechada. O Dev teve de perguntar três vezes.
65
+ *
66
+ * 📏 MEDIDO EM 2026-09-09, como as vozes e no mesmo minuto: os três ficheiros respondem 200 em jsDelivr com
67
+ * `Access-Control-Allow-Origin: *`, na versão FIXADA — 155 439 + 323 377 + 11 756 954 bytes.
68
+ *
69
+ * ⚠️ CDN FIXADA É PERMITIDA E O ADR-0116 DIZ PORQUÊ: o que o pilar 8 proíbe é depender da rede DEPOIS do
70
+ * primeiro dia. Isto desce na INSTALAÇÃO, com o resto — e é a diferença inteira para o WebGazer, que busca
71
+ * quando a criança liga o controle por olhar, logo a máquina que nunca o ligou fica sem ele para sempre.
72
+ * 📌 A versão vai na URL, que é o que o `check:precache` exige de qualquer entrada externa: bytes diferentes
73
+ * chegam por endereço diferente, e uma entrada fixada nunca congela.
74
+ *
75
+ * 🎯 SÃO OS TRÊS FICHEIROS E NÃO SÓ O `.wasm`: o `vision_bundle.mjs` é quem o carrega e o
76
+ * `vision_wasm_internal.js` é a cola do Emscripten. Baixar o wasm sozinho é a mesma armadilha do `.onnx` sem
77
+ * o `.onnx.json` — uma coisa «baixada» que não corre.
78
+ *
79
+ * ⬜ O que continua por fazer é a FIAÇÃO (issue #11): estes bytes descem e ainda ninguém os lê. O
80
+ * `tests/o-que-desce-tem-quem-leia.node.test.js` é onde essa dívida está declarada.
81
+ */
82
+ const MP = 'https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@1.0.1';
83
+ const MP_MODELOS = 'https://storage.googleapis.com/mediapipe-models';
84
+ /**
85
+ * 🔴 A PRIMEIRA VERSÃO DESTA LISTA TRAZIA O RUNTIME E NENHUM MODELO, e o Dev apanhou-o ao perguntar o que
86
+ * tinha ficado de fora. 11,7 MB de WebAssembly sem um `.task` não reconhecem coisa nenhuma — é o `.onnx` sem
87
+ * o `.onnx.json` outra vez, no ficheiro que escreve essa lição doze linhas acima.
88
+ *
89
+ * 📏 MEDIDOS EM 2026-09-09, todos 200 com CORS aberto. `float16` e não `float32`: metade do peso, e a precisão
90
+ * que se perde é irrelevante para dizer onde está um íris num ecrã de 320×180.
91
+ */
92
+ const MEDIAPIPE = Object.freeze([
93
+ { id: 'visao:runtime', url: `${MP}/vision_bundle.mjs`, bytes: 155_439 },
94
+ { id: 'visao:runtime:cola', url: `${MP}/wasm/vision_wasm_internal.js`, bytes: 323_377 },
95
+ { id: 'visao:runtime:wasm', url: `${MP}/wasm/vision_wasm_internal.wasm`, bytes: 11_756_954 },
96
+ { id: 'visao:modelo:rosto', url: `${MP_MODELOS}/face_landmarker/face_landmarker/float16/1/face_landmarker.task`, bytes: 3_758_596 },
97
+ { id: 'visao:modelo:gestos', url: `${MP_MODELOS}/gesture_recognizer/gesture_recognizer/float16/1/gesture_recognizer.task`, bytes: 8_373_440 },
98
+ { id: 'visao:modelo:maos', url: `${MP_MODELOS}/hand_landmarker/hand_landmarker/float16/1/hand_landmarker.task`, bytes: 7_819_105 },
99
+ ]);
100
+ /**
101
+ * O WEBGAZER — **volta em 2026-09-09, ao lado do MediaPipe** (ADR-0132): «Traga o WebGazer de volta. Vamos
102
+ * usar ambos.»
103
+ *
104
+ * 🎯 ELES NÃO SE SOBREPÕEM ONDE IMPORTA, e é essa medição que produziu a decisão: o MediaPipe diz ONDE O ÍRIS
105
+ * ESTÁ — `FACE_LANDMARKS_LEFT_IRIS` são conjuntos de conexões sobre marcos —, e o WebGazer diz PARA ONDE A
106
+ * CRIANÇA OLHA NO ECRÃ, que é um modelo de regressão com calibração. Nenhuma das quinze tarefas do
107
+ * `tasks-vision` faz a segunda.
108
+ *
109
+ * ⚠️ E O DEFEITO DELE NUNCA FOI O FORNECEDOR: era ser PREGUIÇOSO. Buscado quando a criança liga o controle
110
+ * por olhar, a máquina que nunca o ligou fica sem ele, e numa escola sem rede não acontece nada — sem erro e
111
+ * sem explicação. Aqui desce na INSTALAÇÃO com tudo o resto, e o defeito desaparece com a capacidade intacta.
112
+ * 📌 Fica a dívida que o ADR-0132 nomeia: o `<script src>` do `ui/webcam.ts` tem de sair no mesmo commit em
113
+ * que a fiação o ler daqui, senão passam a existir dois caminhos para o mesmo ficheiro.
114
+ */
115
+ const WEBGAZER = Object.freeze([
116
+ { id: 'visao:olhar', url: 'https://webgazer.cs.brown.edu/webgazer.js', bytes: 1_895_169 },
117
+ ]);
118
+ /**
119
+ * O RUNTIME DE VOZ — **piper**, decidido no ADR-0127, e o Dev disse para que serve: «PiperTTS é o que será
120
+ * usado para ler para o usuário. Precisa ser carregado com a engine».
121
+ *
122
+ * 🔴 ATÉ AQUI ELE SÓ CHEGAVA PELA PORTA DO CARTUCHO (ADR-0094), e três dos seis jogos não a declaram — nesses,
123
+ * a engine descarregava 241 MB de modelos e não tinha com que os tocar. Descer os modelos sem o motor é a
124
+ * mesma armadilha do `.onnx` sem o `.onnx.json`, um nível acima.
125
+ *
126
+ * 📏 MEDIDO EM 2026-09-09 em jsDelivr, versões FIXADAS, todos 200 com `Access-Control-Allow-Origin: *`.
127
+ * ⚠️ SÃO CINCO FICHEIROS E NÃO UM: o `piper-tts-web.js` é só a entrada (23 KB) — os dois pedaços com hash no
128
+ * nome são o corpo e a tabela de vozes, e o `onnxruntime-web` é quem corre o modelo. Trazer só a entrada dá
129
+ * um módulo que importa o que não está lá.
130
+ * 📌 `ort-wasm-simd-threaded` e não o `jsep`: o jsep é o caminho WebGPU e pesa 21,7 MB contra 11,2 — e o
131
+ * hardware do pilar 1 não é onde a WebGPU se ganha.
132
+ */
133
+ const PP = 'https://cdn.jsdelivr.net/npm/@mintplex-labs/piper-tts-web@1.0.5/dist';
134
+ const ORT = 'https://cdn.jsdelivr.net/npm/onnxruntime-web@1.20.1/dist';
135
+ const PIPER = Object.freeze([
136
+ { id: 'voz:runtime', url: `${PP}/piper-tts-web.js`, bytes: 23_646 },
137
+ { id: 'voz:runtime:corpo', url: `${PP}/piper-o91UDS6e.js`, bytes: 158_217 },
138
+ { id: 'voz:runtime:tabela', url: `${PP}/voices_static-D_OtJDHM.js`, bytes: 147_377 },
139
+ { id: 'voz:runtime:ort', url: `${ORT}/ort.min.js`, bytes: 446_284 },
140
+ { id: 'voz:runtime:ort-wasm', url: `${ORT}/ort-wasm-simd-threaded.wasm`, bytes: 11_246_032 },
141
+ ]);
142
+ export const PESADOS = Object.freeze([
143
+ ...VOZES_NEURAIS.flatMap(entradasDaVoz),
144
+ ...PIPER,
145
+ /*
146
+ * 🔴 O RUNTIME DE VISÃO — decidido e SEM FONTE, e a ausência é medida.
147
+ *
148
+ * A issue #11 diz «MediaPipe» e o `ui/webcam.ts` faz WebGazer, buscado de `webgazer.cs.brown.edu` por um
149
+ * `<script src>` com preguiça no PRIMEIRO USO — sem SRI, sem `crossorigin`, e a falhar em silêncio numa
150
+ * escola sem rede. `git grep -i mediapipe` em `app/js` devolve ZERO (ADR-0119).
151
+ *
152
+ * ⚠️ NÃO PONHO AQUI A URL DO WEBGAZER. Trocar o fornecedor é decisão da #129, e escrevê-la aqui seria
153
+ * decidi-la de lado — a mesma coisa que o ADR-0119 apanhou: um subsistema que a engine DECLARA possuir e
154
+ * que na prática é outra coisa.
155
+ */
156
+ ...MEDIAPIPE,
157
+ ...WEBGAZER,
158
+ /*
159
+ * 🔴 O ACERVO DE ARTE — a quarta coisa pesada do ADR-0119, e a única SEM FONTE. Medido: `art/` tem DOIS
160
+ * ficheiros — um README e um `ATTRIBUTION.csv` de 40 bytes, só o cabeçalho. Zero arte.
161
+ *
162
+ * ⚠️ ATÉ 2026-09-09 ESTA LINHA CULPAVA A COISA ERRADA. Dizia que a quarentena estava vazia e que a arte
163
+ * era CC BY-SA 3.0 com autoria por recurso — descrevendo o Liberated Pixel Cup, que o ADR-0133 recusou:
164
+ * os dois braços dele são share-alike ou GPL, e nenhum está na lista fechada de quatro licenças.
165
+ *
166
+ * 📌 A razão de continuar sem fonte MUDOU e é mais simples: não há acervo escolhido. A arte entra sob
167
+ * CC0, CC BY 3.0, CC BY 4.0 ou OGA-BY, com uma linha de livro por recurso e a URL da origem — e nada
168
+ * disso é uma URL única que um buscador possa pedir. A fonte desta linha é o dia em que houver acervo.
169
+ */
170
+ {
171
+ id: 'arte:acervo',
172
+ url: null,
173
+ porQueNaoTemFonte: 'não há acervo escolhido: `art/` tem só README e cabeçalho do CSV. O ADR-0133 fechou '
174
+ + 'a lista em CC0, CC BY 3.0/4.0 e OGA-BY, e a arte entra recurso a recurso com autoria e URL de '
175
+ + 'origem — não por uma URL solta que este buscador possa pedir.',
176
+ },
177
+ ]);
@@ -0,0 +1,31 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ import { CACHE_PESADOS, PESADOS, type Pesado } from './pesados-catalogo.js';
3
+ export { CACHE_PESADOS, PESADOS };
4
+ export type { Pesado };
5
+ /** O que aconteceu com cada entrada, para quem chama poder dizê-lo a uma pessoa. */
6
+ export interface RelatorioPesado {
7
+ readonly id: string;
8
+ readonly estado: 'ja-tinha' | 'baixado' | 'falhou' | 'sem-fonte';
9
+ readonly bytes?: number;
10
+ readonly erro?: string;
11
+ }
12
+ export interface OpcoesDosPesados {
13
+ /** `caches` do navegador. Injectado para o gate não precisar de um. */
14
+ readonly cacheStorage?: CacheStorage;
15
+ /** `fetch`. Injectado pela mesma razão. */
16
+ readonly buscar?: typeof fetch;
17
+ /** Chamado a cada entrada resolvida — é o que deixa a interface dizer o que está a acontecer. */
18
+ readonly aoProgredir?: (r: RelatorioPesado) => void;
19
+ /** Só estas ids, se dado. Serve ao consumidor que quer as vozes e não o resto. */
20
+ readonly apenas?: readonly string[];
21
+ }
22
+ /**
23
+ * BAIXA O QUE FALTA, UM DE CADA VEZ, E DEVOLVE O QUE ACONTECEU COM CADA UM.
24
+ *
25
+ * ⚠️ AS ENTRADAS SEM `url` NÃO SÃO SALTADAS EM SILÊNCIO — devolvem `sem-fonte`. É a diferença entre «este
26
+ * subsistema ainda não tem de onde vir» e «este subsistema está tratado», e é exactamente a distinção que o
27
+ * ADR-0119 mediu em falta: a engine PROMETIA quatro coisas e entregava uma, sem nada a dizê-lo.
28
+ */
29
+ export declare function baixarPesados(opcoes?: OpcoesDosPesados): Promise<RelatorioPesado[]>;
30
+ /** O peso do que ainda falta, em bytes — para um aviso poder dizer «faltam 241 MB» antes de começar. */
31
+ export declare function pesoPorBaixar(relatorio: readonly RelatorioPesado[]): number;
@@ -0,0 +1,75 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // platform/pesados.ts — AS COISAS PESADAS, BAIXADAS NO PRIMEIRO CARREGAMENTO (ADR-0110, ADR-0116, ADR-0119).
3
+ //
4
+ // ========================= O QUE ISTO É =========================
5
+ // O pilar 8 diz «PWA no primeiro dia ONLINE, depois OFFLINE-FIRST», e o ADR-0116 tirou a contradição que
6
+ // travava isto: instalar já é um acto de rede, logo buscar na instalação não viola nada. O que faltava era
7
+ // alguém a buscar.
8
+ //
9
+ // 📏 MEDIDO EM 2026-09-09, e é o estado que este ficheiro existe para mudar: das quatro coisas pesadas que a
10
+ // engine promete (fontes · voz neural · runtime de visão · arte do LCP), só as FONTES viajavam de verdade.
11
+ // As vozes eram porta do cartucho, o runtime de visão era um `<script src>` de CDN buscado com preguiça no
12
+ // PRIMEIRO USO, e a arte do LCP não existe.
13
+ //
14
+ // ⚠️ E ISTO NÃO BLOQUEIA O JOGO. A criança joga enquanto os 241 MB descem; o que não pode acontecer é ela
15
+ // chegar ao segundo dia, sem rede, e descobrir que a voz nunca foi buscada.
16
+ //
17
+ // ========================= AS TRÊS REGRAS QUE A FORMA IMPÕE =========================
18
+ // 1. **UM DE CADA VEZ.** Quatro descargas de 60 MB em paralelo num link de escola disputam a mesma banda e
19
+ // nenhuma acaba primeiro — e o jogo, que precisa da rede para as próprias imagens, fica atrás delas.
20
+ // 2. **NUNCA LANÇA.** Uma falha de rede é REPORTADA e a lista continua. Um `throw` aqui derrubaria o
21
+ // arranque de um jogo por causa de um recurso que ele nem usa hoje.
22
+ // 3. **IDEMPOTENTE.** O que já está na Cache Storage não é buscado outra vez — é o que torna isto seguro de
23
+ // chamar em todo arranque em vez de só «no primeiro», que ninguém sabe detectar com honestidade.
24
+ import { CACHE_PESADOS, PESADOS } from './pesados-catalogo.js';
25
+ export { CACHE_PESADOS, PESADOS };
26
+ /**
27
+ * BAIXA O QUE FALTA, UM DE CADA VEZ, E DEVOLVE O QUE ACONTECEU COM CADA UM.
28
+ *
29
+ * ⚠️ AS ENTRADAS SEM `url` NÃO SÃO SALTADAS EM SILÊNCIO — devolvem `sem-fonte`. É a diferença entre «este
30
+ * subsistema ainda não tem de onde vir» e «este subsistema está tratado», e é exactamente a distinção que o
31
+ * ADR-0119 mediu em falta: a engine PROMETIA quatro coisas e entregava uma, sem nada a dizê-lo.
32
+ */
33
+ export async function baixarPesados(opcoes = {}) {
34
+ const cs = opcoes.cacheStorage ?? (typeof caches !== 'undefined' ? caches : undefined);
35
+ const buscar = opcoes.buscar ?? (typeof fetch !== 'undefined' ? fetch : undefined);
36
+ const alvos = opcoes.apenas
37
+ ? PESADOS.filter((p) => opcoes.apenas.includes(p.id))
38
+ : PESADOS;
39
+ const out = [];
40
+ const conta = (r) => { out.push(r); opcoes.aoProgredir?.(r); };
41
+ if (!cs || !buscar) {
42
+ for (const p of alvos)
43
+ conta({ id: p.id, estado: 'falhou', erro: 'sem Cache Storage ou sem fetch' });
44
+ return out;
45
+ }
46
+ const cache = await cs.open(CACHE_PESADOS);
47
+ for (const p of alvos) {
48
+ if (!p.url) {
49
+ conta({ id: p.id, estado: 'sem-fonte', erro: p.porQueNaoTemFonte });
50
+ continue;
51
+ }
52
+ try {
53
+ if (await cache.match(p.url)) {
54
+ conta({ id: p.id, estado: 'ja-tinha' });
55
+ continue;
56
+ }
57
+ const resp = await buscar(p.url);
58
+ if (!resp.ok) {
59
+ conta({ id: p.id, estado: 'falhou', erro: `HTTP ${resp.status}` });
60
+ continue;
61
+ }
62
+ await cache.put(p.url, resp.clone());
63
+ conta({ id: p.id, estado: 'baixado', bytes: p.bytes });
64
+ }
65
+ catch (e) {
66
+ conta({ id: p.id, estado: 'falhou', erro: e instanceof Error ? e.message : String(e) });
67
+ }
68
+ }
69
+ return out;
70
+ }
71
+ /** O peso do que ainda falta, em bytes — para um aviso poder dizer «faltam 241 MB» antes de começar. */
72
+ export function pesoPorBaixar(relatorio) {
73
+ const feitos = new Set(relatorio.filter((r) => r.estado === 'ja-tinha' || r.estado === 'baixado').map((r) => r.id));
74
+ return PESADOS.filter((p) => p.url && !feitos.has(p.id)).reduce((s, p) => s + (p.bytes ?? 0), 0);
75
+ }
@@ -25,8 +25,27 @@ export declare const VOZES_NEURAIS: readonly VozNeural[];
25
25
  *
26
26
  * ⚠️ UM SÍTIO SÓ É A METADE QUE O REGISTO PEDE. Um endereço repetido no ponto de uso é como o CDN do
27
27
  * WebGazer chegou ao `ui/webcam` — escrito à mão, sem política, e sem ninguém a poder mudá-lo de uma vez.
28
+ *
29
+ * ========================= 🔴 MUDOU DE ESPELHO EM 2026-09-09, E A REGRA É «BUSCAR DE ONDE O LEITOR LÊ» ====
30
+ * Era `rhasspy/piper-voices`, medido a 08/09 como o espelho que o `piper.ttstool.com` usa. Depois de o
31
+ * `platform/pesados` passar a descer os modelos no primeiro carregamento, mediu-se o outro lado — o
32
+ * `@mintplex-labs/piper-tts-web@1.0.4` INSTALADO, lido do bundle e não do README — e ele busca em
33
+ * `diffusionstudio/piper-voices`, com uma guarda que recusa qualquer URL fora de `huggingface.co`.
34
+ *
35
+ * 🎯 ENDEREÇOS DIFERENTES SIGNIFICAM CACHE DIFERENTE: a Cache Storage é indexada pela URL do pedido, logo os
36
+ * 241 MB que desciam no primeiro dia NÃO ERAM LIDOS POR NINGUÉM, e a biblioteca descarregava tudo outra vez
37
+ * no dia em que a voz fosse pedida. Até 482 MB num link de escola para uma voz.
38
+ *
39
+ * 📏 E OS DOIS ESPELHOS SERVEM OS MESMOS BYTES — 63 201 294 para o `pt_BR-faber-medium`, caminhos idênticos,
40
+ * `Access-Control-Allow-Origin: *` nos dois. Medido nos dois no mesmo minuto, o que torna esta troca uma
41
+ * correcção de facto e não uma preferência entre fornecedores.
42
+ *
43
+ * ⚠️ A CLÁUSULA DO ADR-0114 NÃO SE MEXE: o host continua nomeado num ponto só. O que mudou foi QUAL, e a
44
+ * regra que decide isso passa a estar escrita para não voltar a divergir — **busca-se de onde o LEITOR lê**.
45
+ * No dia em que a engine for dona do runtime (ADR-0124 cláusula 2), o leitor passa a ser ela e é ela que
46
+ * escolhe; até lá, o único leitor é a porta do cartucho.
28
47
  */
29
- export declare const HOST_DOS_MODELOS = "https://huggingface.co/rhasspy/piper-voices/resolve/main/";
48
+ export declare const HOST_DOS_MODELOS = "https://huggingface.co/diffusionstudio/piper-voices/resolve/main/";
30
49
  /**
31
50
  * O CAMINHO DO MODELO, DERIVADO DO IDENTIFICADOR — e não uma segunda tabela.
32
51
  *
@@ -40,8 +40,27 @@ export const VOZES_NEURAIS = Object.freeze([
40
40
  *
41
41
  * ⚠️ UM SÍTIO SÓ É A METADE QUE O REGISTO PEDE. Um endereço repetido no ponto de uso é como o CDN do
42
42
  * WebGazer chegou ao `ui/webcam` — escrito à mão, sem política, e sem ninguém a poder mudá-lo de uma vez.
43
+ *
44
+ * ========================= 🔴 MUDOU DE ESPELHO EM 2026-09-09, E A REGRA É «BUSCAR DE ONDE O LEITOR LÊ» ====
45
+ * Era `rhasspy/piper-voices`, medido a 08/09 como o espelho que o `piper.ttstool.com` usa. Depois de o
46
+ * `platform/pesados` passar a descer os modelos no primeiro carregamento, mediu-se o outro lado — o
47
+ * `@mintplex-labs/piper-tts-web@1.0.4` INSTALADO, lido do bundle e não do README — e ele busca em
48
+ * `diffusionstudio/piper-voices`, com uma guarda que recusa qualquer URL fora de `huggingface.co`.
49
+ *
50
+ * 🎯 ENDEREÇOS DIFERENTES SIGNIFICAM CACHE DIFERENTE: a Cache Storage é indexada pela URL do pedido, logo os
51
+ * 241 MB que desciam no primeiro dia NÃO ERAM LIDOS POR NINGUÉM, e a biblioteca descarregava tudo outra vez
52
+ * no dia em que a voz fosse pedida. Até 482 MB num link de escola para uma voz.
53
+ *
54
+ * 📏 E OS DOIS ESPELHOS SERVEM OS MESMOS BYTES — 63 201 294 para o `pt_BR-faber-medium`, caminhos idênticos,
55
+ * `Access-Control-Allow-Origin: *` nos dois. Medido nos dois no mesmo minuto, o que torna esta troca uma
56
+ * correcção de facto e não uma preferência entre fornecedores.
57
+ *
58
+ * ⚠️ A CLÁUSULA DO ADR-0114 NÃO SE MEXE: o host continua nomeado num ponto só. O que mudou foi QUAL, e a
59
+ * regra que decide isso passa a estar escrita para não voltar a divergir — **busca-se de onde o LEITOR lê**.
60
+ * No dia em que a engine for dona do runtime (ADR-0124 cláusula 2), o leitor passa a ser ela e é ela que
61
+ * escolhe; até lá, o único leitor é a porta do cartucho.
43
62
  */
44
- export const HOST_DOS_MODELOS = 'https://huggingface.co/rhasspy/piper-voices/resolve/main/';
63
+ export const HOST_DOS_MODELOS = 'https://huggingface.co/diffusionstudio/piper-voices/resolve/main/';
45
64
  /**
46
65
  * O CAMINHO DO MODELO, DERIVADO DO IDENTIFICADOR — e não uma segunda tabela.
47
66
  *
@@ -0,0 +1,28 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ import { type EstadosDasVozes, type VozNeural } from './voice-plan.js';
3
+ import type { RelatorioPesado } from './pesados.js';
4
+ /**
5
+ * O ESTADO DE CADA VOZ A PARTIR DO QUE O BUSCADOR RELATOU.
6
+ *
7
+ * ⚠️ `emCurso` NÃO É COSMÉTICA, E É O QUE IMPEDE ESTA PONTE DE MENTIR NOS DOIS SENTIDOS. O relatório só
8
+ * contém o que JÁ resolveu, logo uma voz por relatar é ambígua: pode estar a caminho ou nunca ter sido
9
+ * pedida. O `voice-plan` recusa-se a confundir as duas por escrito — «ausente não se disfarça de a buscar»,
10
+ * porque isso esconderia um buscador que nunca arrancou atrás de uma frase tranquilizadora — e a única coisa
11
+ * que desfaz a ambiguidade é quem chamou saber se a descarga ainda corre.
12
+ *
13
+ * 📌 E o par contrário importa igualmente: com a descarga TERMINADA, uma voz por relatar é `ausente` e nunca
14
+ * `a-buscar`, senão a interface prometeria para sempre uma coisa que já não vem.
15
+ */
16
+ export declare function estadosDasVozes(relatorio: readonly RelatorioPesado[], opcoes?: {
17
+ readonly emCurso?: boolean;
18
+ }, catalogo?: readonly VozNeural[]): EstadosDasVozes;
19
+ /**
20
+ * AS IDS DAS COISAS PESADAS QUE SÃO VOZ — para quem queira descê-las primeiro, sem adivinhar o formato da id.
21
+ *
22
+ * ⚠️ Existe porque a alternativa é o consumidor escrever `p.id.startsWith('voz:')` no ponto de uso, e aí o
23
+ * formato da id passa a ser contrato público sem nunca ter sido decidido como tal.
24
+ */
25
+ export declare const idsDasVozes: (catalogo?: readonly VozNeural[]) => string[];
26
+ /** Reexportado para quem faz a ponte não ter de importar de dois sítios para responder a uma pergunta. */
27
+ export { vozEmUso, estadoDe } from './voice-plan.js';
28
+ export type { VozEmUso, EstadosDasVozes } from './voice-plan.js';
@@ -0,0 +1,61 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // platform/vozes-prontas.ts — A PONTE ENTRE O QUE DESCEU E O QUE A CRIANÇA OUVE (ADR-0110, gates 3 e 4).
3
+ //
4
+ // ========================= POR QUE ESTE MÓDULO EXISTE =========================
5
+ // 📏 MEDIDO EM 2026-09-09: `vozEmUso` — a função que o ADR-0110 chama «o gate 3 em forma de código» — tinha
6
+ // ZERO leitores em produção, e NADA no repositório produzia um `EstadosDasVozes`. O buscador que nasceu hoje
7
+ // relata `RelatorioPesado[]`. São DUAS linguagens para o mesmo facto, e nenhuma falava com a outra.
8
+ //
9
+ // ⚠️ ISSO NÃO É UM ENCAIXE QUE FALTAVA, É A FORMA DE DEFEITO QUE ESTE REPOSITÓRIO PERSEGUE: um modelo aferido
10
+ // e correcto, sem ninguém a lê-lo, ao lado de um mecanismo que produz a mesma verdade noutro vocabulário. O
11
+ // `render/viz-axes` esteve exactamente assim — «é ÓRFÃO» — e o que isso custou foi a composição dos dois eixos
12
+ // visuais a existir no papel e não na tela.
13
+ //
14
+ // ========================= 🎯 A REGRA QUE ESTE MÓDULO CARREGA, E É UMA SÓ =========================
15
+ // **UMA VOZ SÓ ESTÁ PRONTA QUANDO OS DOIS FICHEIROS DESCERAM.** O piper recusa-se a falar sem o `.onnx.json`,
16
+ // então uma voz com o modelo e sem a configuração é uma voz que NÃO FALA — e marcá-la `pronta` faria a engine
17
+ // anunciar «voz neural» e ficar muda. É pior do que a voz em falta, porque a primeira parece resolvida, e é
18
+ // literalmente a família do `reflectTTS` que o ADR-0110 nomeia ao pedir o gate 3.
19
+ import { VOZES_NEURAIS } from './voice-plan.js';
20
+ /** As duas ids que o catálogo dá a uma voz. Derivadas, não tabeladas — a tabela vive no catálogo. */
21
+ const idsDe = (v) => [`voz:${v.voice}`, `voz:${v.voice}:cfg`];
22
+ /**
23
+ * O ESTADO DE CADA VOZ A PARTIR DO QUE O BUSCADOR RELATOU.
24
+ *
25
+ * ⚠️ `emCurso` NÃO É COSMÉTICA, E É O QUE IMPEDE ESTA PONTE DE MENTIR NOS DOIS SENTIDOS. O relatório só
26
+ * contém o que JÁ resolveu, logo uma voz por relatar é ambígua: pode estar a caminho ou nunca ter sido
27
+ * pedida. O `voice-plan` recusa-se a confundir as duas por escrito — «ausente não se disfarça de a buscar»,
28
+ * porque isso esconderia um buscador que nunca arrancou atrás de uma frase tranquilizadora — e a única coisa
29
+ * que desfaz a ambiguidade é quem chamou saber se a descarga ainda corre.
30
+ *
31
+ * 📌 E o par contrário importa igualmente: com a descarga TERMINADA, uma voz por relatar é `ausente` e nunca
32
+ * `a-buscar`, senão a interface prometeria para sempre uma coisa que já não vem.
33
+ */
34
+ export function estadosDasVozes(relatorio, opcoes = {}, catalogo = VOZES_NEURAIS) {
35
+ const porId = new Map(relatorio.map((r) => [r.id, r]));
36
+ const saida = {};
37
+ for (const v of catalogo) {
38
+ const partes = idsDe(v).map((id) => porId.get(id));
39
+ if (partes.some((p) => p && (p.estado === 'falhou' || p.estado === 'sem-fonte'))) {
40
+ // 📌 UMA METADE FALHADA CHEGA PARA A VOZ INTEIRA FALHAR, e é o mesmo argumento da regra do cabeçalho
41
+ // visto do outro lado: sem os dois ficheiros não há fala nenhuma, logo não há meia voz para oferecer.
42
+ saida[v.voice] = 'falhou';
43
+ continue;
44
+ }
45
+ if (partes.every((p) => p && (p.estado === 'baixado' || p.estado === 'ja-tinha'))) {
46
+ saida[v.voice] = 'pronta';
47
+ continue;
48
+ }
49
+ saida[v.voice] = opcoes.emCurso ? 'a-buscar' : 'ausente';
50
+ }
51
+ return Object.freeze(saida);
52
+ }
53
+ /**
54
+ * AS IDS DAS COISAS PESADAS QUE SÃO VOZ — para quem queira descê-las primeiro, sem adivinhar o formato da id.
55
+ *
56
+ * ⚠️ Existe porque a alternativa é o consumidor escrever `p.id.startsWith('voz:')` no ponto de uso, e aí o
57
+ * formato da id passa a ser contrato público sem nunca ter sido decidido como tal.
58
+ */
59
+ export const idsDasVozes = (catalogo = VOZES_NEURAIS) => catalogo.flatMap((v) => [...idsDe(v)]);
60
+ /** Reexportado para quem faz a ponte não ter de importar de dois sítios para responder a uma pergunta. */
61
+ export { vozEmUso, estadoDe } from './voice-plan.js';
@@ -49,6 +49,30 @@ export type FontGroup = {
49
49
  export declare const FONT_GROUPS: FontGroup[];
50
50
  /** O papel de uma face; ausente no catálogo quer dizer `geral`. */
51
51
  export declare function papelDaFonte(it: FontItem): FontRole;
52
+ /**
53
+ * AS FAMÍLIAS QUE UMA FACE ACEITA, do `fam` que pode ser uma PILHA.
54
+ *
55
+ * 📌 A Ronde declara três (`'Ronde Script, OPTIFrench-Script, Merveille'`) porque qualquer uma delas serve —
56
+ * são três desenhos da mesma letra de mão, e um adulto instala a que encontrar. As outras faces declaram uma
57
+ * só, e para elas isto devolve uma lista de um.
58
+ */
59
+ export declare function familiasDaFace(it: FontItem): string[];
60
+ /**
61
+ * ESTA FACE PODE SER USADA AGORA? — o `off` deixa de ser uma sentença e passa a ser uma CONDIÇÃO.
62
+ *
63
+ * O ADR-0012 decidiu que a opção da ronde «fica DESABILITADA enquanto nenhuma fonte estiver presente», e o
64
+ * ADR-0108 §4 acrescentou o que ela diz. A palavra «enquanto» é o que esta função constrói: uma face `off`
65
+ * volta a ficar disponível no instante em que o adulto instala uma das que a mensagem nomeia.
66
+ *
67
+ * ⚠️ O DETECTOR É INJECTADO, e nunca `document.fonts` lido daqui: este módulo é o catálogo, corre em node nos
68
+ * gates, e ler um global do navegador aqui é o ACHADO 15 outra vez — o `srAlert` que rebentou o boot contra um
69
+ * documento injectado.
70
+ * 📌 E o PADRÃO É «não instalada», que é seguro por uma razão que não vale para todos os padrões deste
71
+ * repositório: sem detector a opção fica desabilitada COM a mensagem, e a mensagem diz ao adulto exactamente
72
+ * o que fazer. O silêncio não decide nada contra a criança — ele mantém o estado que já existia e que é
73
+ * accionável. É o oposto do `seguraTeclas`, onde os dois lados do padrão erravam.
74
+ */
75
+ export declare function faceDisponivel(it: FontItem, instalada?: (familia: string) => boolean): boolean;
52
76
  /** As faces que o MENU pode oferecer: só as gerais (emenda do ADR-0012). */
53
77
  export declare const OFERECIVEIS: FontItem[];
54
78
  export declare const FONT_BY_KEY: Record<string, FontItem>;
@@ -38,7 +38,42 @@ export const FONT_GROUPS = [
38
38
  // ⚠️ `comicneue` NÃO é caligráfica, e está neste grupo só por aparência: é uma face de propósito geral,
39
39
  // frequentemente recomendada para dislexia. Marcá-la como caligráfica tirá-la-ia do menu — removendo uma
40
40
  // opção legitimamente acessível pelo formato do grupo em vez de pelo papel.
41
- { k: 'comicneue', fam: 'Comic Neue', fb: 'cursive', d: 'font.desc.comicneue' }
41
+ { k: 'comicneue', fam: 'Comic Neue', fb: 'cursive', d: 'font.desc.comicneue' },
42
+ /*
43
+ * A RONDE FRANCESA — o item 4 da #87, decidido no ADR-0108 §4. Ela NUNCA é empacotada: as três faces são
44
+ * livres só para uso PESSOAL (ADR-0012), e distribuí-las seria distribuir o que não foi licenciado para
45
+ * distribuição. O que muda é que a opção passa a FALAR.
46
+ *
47
+ * ⚠️ E ISTO NÃO É A ENTRADA `.off` QUE ESTE CATÁLOGO JÁ REMOVEU. O cabeçalho acima tirou a `learningcurve`
48
+ * e a `kindergarten` com a razão certa — «uma linha que só serve para dizer "ainda não" é uma linha que a
49
+ * criança lê e não pode usar». A diferença é ACCIONABILIDADE, e é a razão que o ADR-0108 dá por extenso:
50
+ * aquelas diziam «ainda não», que ninguém pode resolver; esta diz QUAIS TRÊS FONTES INSTALAR, que um
51
+ * adulto resolve numa tarde. 📌 «Instale uma fonte ronde» seria o defeito de volta — um adulto não age
52
+ * sobre uma categoria —, e é por isso que a mensagem nomeia as três.
53
+ *
54
+ * ⚠️ `papel` AUSENTE, logo `geral`, e é deliberado apesar de a ronde ser caligráfica por natureza: as
55
+ * caligráficas são filtradas do menu (`papelDaFonte === 'geral'`), e uma linha filtrada não pode dizer
56
+ * nada a ninguém. Marcar o papel «certo» aqui apagaria a única coisa que este item existe para fazer.
57
+ */
58
+ /*
59
+ * AS OITO PLAYWRITE — o item 3 da #87, decidido no ADR-0108 §2 e entregue em 2026-09-09.
60
+ *
61
+ * ⚠️ `papel: caligrafica`, logo FORA DO MENU: elas são a mão que se aprende a escrever, para os botões
62
+ * DENTRO das atividades escolares, e não uma opção de interface. É a divisão do item 1 desta issue.
63
+ *
64
+ * 📌 `minPx: 20` é o mesmo piso que as outras três cursivas carregam. A lista de mínimos da #87 não
65
+ * nomeia a Playwrite — o número é o das irmãs, e não uma medição própria; corrigir-se com uma linha.
66
+ */
67
+ { k: 'pwbr', fam: 'Playwrite BR', fb: 'cursive', d: 'font.desc.pw.br', papel: 'caligrafica', minPx: 20 },
68
+ { k: 'pwustrad', fam: 'Playwrite US Trad', fb: 'cursive', d: 'font.desc.pw.ustrad', papel: 'caligrafica', minPx: 20 },
69
+ { k: 'pwusmod', fam: 'Playwrite US Modern', fb: 'cursive', d: 'font.desc.pw.usmod', papel: 'caligrafica', minPx: 20 },
70
+ { k: 'pwca', fam: 'Playwrite CA', fb: 'cursive', d: 'font.desc.pw.ca', papel: 'caligrafica', minPx: 20 },
71
+ { k: 'pwmx', fam: 'Playwrite MX', fb: 'cursive', d: 'font.desc.pw.mx', papel: 'caligrafica', minPx: 20 },
72
+ { k: 'pwar', fam: 'Playwrite AR', fb: 'cursive', d: 'font.desc.pw.ar', papel: 'caligrafica', minPx: 20 },
73
+ { k: 'pwcl', fam: 'Playwrite CL', fb: 'cursive', d: 'font.desc.pw.cl', papel: 'caligrafica', minPx: 20 },
74
+ { k: 'pwco', fam: 'Playwrite CO', fb: 'cursive', d: 'font.desc.pw.co', papel: 'caligrafica', minPx: 20 },
75
+ { k: 'ronde', fam: 'Ronde Script, OPTIFrench-Script, Merveille', fb: 'cursive',
76
+ d: 'font.desc.ronde', off: 'font.off.ronde' }
42
77
  ] },
43
78
  // ⚠️ A FACE DO JOGO, e ela tem grupo próprio porque não é nem sans, nem serifada, nem manuscrita — é uma
44
79
  // face de PIXEL, e pô-la em qualquer um dos três diria a coisa errada sobre ela na lista.
@@ -52,6 +87,36 @@ export const FONT_GROUPS = [
52
87
  ];
53
88
  /** O papel de uma face; ausente no catálogo quer dizer `geral`. */
54
89
  export function papelDaFonte(it) { return it.papel ?? 'geral'; }
90
+ /**
91
+ * AS FAMÍLIAS QUE UMA FACE ACEITA, do `fam` que pode ser uma PILHA.
92
+ *
93
+ * 📌 A Ronde declara três (`'Ronde Script, OPTIFrench-Script, Merveille'`) porque qualquer uma delas serve —
94
+ * são três desenhos da mesma letra de mão, e um adulto instala a que encontrar. As outras faces declaram uma
95
+ * só, e para elas isto devolve uma lista de um.
96
+ */
97
+ export function familiasDaFace(it) {
98
+ return it.fam.split(',').map((f) => f.trim().replace(/^['"]|['"]$/g, '')).filter(Boolean);
99
+ }
100
+ /**
101
+ * ESTA FACE PODE SER USADA AGORA? — o `off` deixa de ser uma sentença e passa a ser uma CONDIÇÃO.
102
+ *
103
+ * O ADR-0012 decidiu que a opção da ronde «fica DESABILITADA enquanto nenhuma fonte estiver presente», e o
104
+ * ADR-0108 §4 acrescentou o que ela diz. A palavra «enquanto» é o que esta função constrói: uma face `off`
105
+ * volta a ficar disponível no instante em que o adulto instala uma das que a mensagem nomeia.
106
+ *
107
+ * ⚠️ O DETECTOR É INJECTADO, e nunca `document.fonts` lido daqui: este módulo é o catálogo, corre em node nos
108
+ * gates, e ler um global do navegador aqui é o ACHADO 15 outra vez — o `srAlert` que rebentou o boot contra um
109
+ * documento injectado.
110
+ * 📌 E o PADRÃO É «não instalada», que é seguro por uma razão que não vale para todos os padrões deste
111
+ * repositório: sem detector a opção fica desabilitada COM a mensagem, e a mensagem diz ao adulto exactamente
112
+ * o que fazer. O silêncio não decide nada contra a criança — ele mantém o estado que já existia e que é
113
+ * accionável. É o oposto do `seguraTeclas`, onde os dois lados do padrão erravam.
114
+ */
115
+ export function faceDisponivel(it, instalada) {
116
+ if (!it.off)
117
+ return true;
118
+ return !!instalada && familiasDaFace(it).some((f) => instalada(f));
119
+ }
55
120
  /** As faces que o MENU pode oferecer: só as gerais (emenda do ADR-0012). */
56
121
  export const OFERECIVEIS = FONT_GROUPS.flatMap((g) => g.items).filter((it) => papelDaFonte(it) === 'geral');
57
122
  export const FONT_BY_KEY = {};
@@ -179,13 +179,32 @@ export declare function iconBtnMarkup(ic: PauseIcon): string;
179
179
  * ⚠️ Isto NÃO é o mesmo que `soon`. `soon` é «ainda não construímos»; isto é «este jogo não tem por onde», e
180
180
  * um botão que anuncia «em breve» diria a coisa errada.
181
181
  */
182
- export interface EscritoresVisuais {
182
+ export interface AccionaveisDoJogo {
183
183
  /** Há quem escreva o TEMA (o alto contraste)? Sem ele, o ícone `contrast` não é montado. */
184
184
  readonly tema: boolean;
185
185
  /** Há quem escreva a CORREÇÃO de cor? Sem ela, o ícone `cvd` não é montado. */
186
186
  readonly correcao: boolean;
187
+ /**
188
+ * ESTE JOGO SEGURA ALGUMA TECLA? — `GameDeclaration.seguraTeclas`, o campo do ADR-0115.
189
+ *
190
+ * 🔴 Sem ele o ícone `altmove` não é montado, e a AUSÊNCIA é a decisão. A alternância existe para quem não
191
+ * consegue manter uma tecla premida; num jogo onde nada se segura ela não tem o que travar, e um controle
192
+ * que não faz nada ensina a uma criança que o ajuste de que ela depende está partido.
193
+ *
194
+ * ⚠️ E ISTO É UMA AUSÊNCIA DIFERENTE DA DO ADR-0113 cláusula 3, que também vive neste ficheiro: lá o
195
+ * controle fica DESABILITADO com o motivo, porque o aparelho EXIGE a alternância e ela não se pode
196
+ * desligar. Aqui não há nada a travar, e um controle que explica por que não faz nada continua a ser um
197
+ * controle que não faz nada.
198
+ */
199
+ readonly seguraTeclas: boolean;
187
200
  }
188
- export declare function iconesQueAccionam(escritores: EscritoresVisuais): readonly PauseIcon[];
201
+ /**
202
+ * @deprecated O nome dizia «escritores VISUAIS» e a pergunta deixou de ser só visual quando o `seguraTeclas`
203
+ * entrou (ADR-0115). Use `AccionaveisDoJogo`. O alias fica para o consumidor não pagar duas quebras no mesmo
204
+ * major — uma pelo campo novo e outra pelo nome.
205
+ */
206
+ export type EscritoresVisuais = AccionaveisDoJogo;
207
+ export declare function iconesQueAccionam(escritores: AccionaveisDoJogo): readonly PauseIcon[];
189
208
  /**
190
209
  * OS TRÊS ITENS QUE A ENGINE ACCIONA SOZINHA, e que por isso nunca dependem do `getPauseActs` de um jogo.
191
210
  *
@@ -397,6 +416,21 @@ export interface PauseIconsCtx {
397
416
  /** Os escritores POR EIXO (#104): mexer no tema não apaga a correção, e vice-versa. */
398
417
  setTemaDoJogador?: (i: number, tema: Tema) => void;
399
418
  setCorrecaoDoJogador?: (i: number, correcao: Correcao) => void;
419
+ /**
420
+ * ESTE JOGO SEGURA ALGUMA TECLA? — o valor de `GameDeclaration.seguraTeclas` (ADR-0115). Sem ele o ícone
421
+ * `altmove` não é montado.
422
+ *
423
+ * ⚠️ OBRIGATÓRIO, e vai contra a direcção do ADR-0106, que passou a tornar campos deste ctx OPCIONAIS para
424
+ * a engine poder montar sozinha. A excepção tem razão medida: os campos que ganharam padrão têm um padrão
425
+ * SEGURO — o modo cego começa desligado, a lista de botões vem da engine. Aqui não há: `true` monta um
426
+ * controle que pode não fazer nada, e `false` esconde um de que uma criança depende. Os dois lados erram, e
427
+ * é essa a condição que o `holdsAtOnce` já usou para ser obrigatório também.
428
+ *
429
+ * 📌 Quem passa pelo `createGame` não escreve isto: a raiz lê a declaração, que já é obrigatória. O campo
430
+ * só é visível para um cartucho que chame `initPauseIcons` por fora — e é exactamente esse que não pode
431
+ * ficar em silêncio.
432
+ */
433
+ seguraTeclas: boolean;
400
434
  }
401
435
  export interface PauseIconsApi {
402
436
  /** Builds one `.screen-pause` (hidden), wired for click + hover/focus caption. Caller appends it. */
@@ -230,9 +230,13 @@ export function iconesQueAccionam(escritores) {
230
230
  // tiravam os DOIS escritores. O `&&` escondia um ícone que FUNCIONA quando só um escritor falta, e o `||`
231
231
  // mostrava um que NÃO funciona. Os dois erram, em direcções opostas, e a pergunta certa nunca foi «este
232
232
  // jogo tem escritores visuais» — é «este ÍCONE tem quem o accione».
233
+ // 📌 E o `altmove` entra pela MESMA porta, que é o achado: «este jogo segura teclas?» é a mesma pergunta
234
+ // que «este ícone tem quem o accione», feita a um campo do contrato em vez de a um escritor injectado.
235
+ // Um terceiro ramo, e não uma regra nova.
233
236
  return PAUSE_ICONS.filter((ic) => (ic.k === 'contrast' ? escritores.tema
234
237
  : ic.k === 'cvd' ? escritores.correcao
235
- : true));
238
+ : ic.k === 'altmove' ? escritores.seguraTeclas
239
+ : true));
236
240
  }
237
241
  /**
238
242
  * OS TRÊS ITENS QUE A ENGINE ACCIONA SOZINHA, e que por isso nunca dependem do `getPauseActs` de um jogo.
@@ -422,6 +426,10 @@ export function initPauseIcons(ctx) {
422
426
  const iconesDoJogo = iconesQueAccionam({
423
427
  tema: Boolean(ctx.setTemaDoJogador),
424
428
  correcao: Boolean(ctx.setCorrecaoDoJogador),
429
+ // 📌 Sem `Boolean(...)`: os dois de cima perguntam «existe escritor?» a um campo opcional; este é uma
430
+ // RESPOSTA que o jogo deu, e envolvê-la faria um `undefined` de um ctx mal montado virar `false` —
431
+ // esconder o controle em silêncio, que é metade do defeito que este campo existe para não cometer.
432
+ seguraTeclas: ctx.seguraTeclas,
425
433
  });
426
434
  /*
427
435
  * ⚠️ RESOLVIDOS UMA VEZ, no arranque, pela mesma razão do `settings-motion`: o `rm` é mutado in-place e