@the-inclusionist/engine 7.0.1 → 8.0.0-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/README.md +5 -3
  2. package/app/css/style.css +22 -5
  3. package/app/public/vendor/fonts/fondamento-400-ext.woff2 +0 -0
  4. package/app/public/vendor/fonts/fondamento-400.woff2 +0 -0
  5. package/app/public/vendor/fonts/opendyslexic-400.woff2 +0 -0
  6. package/app/public/vendor/fonts/playwrite-ar.woff2 +0 -0
  7. package/app/public/vendor/fonts/playwrite-br.woff2 +0 -0
  8. package/app/public/vendor/fonts/playwrite-ca.woff2 +0 -0
  9. package/app/public/vendor/fonts/playwrite-cl.woff2 +0 -0
  10. package/app/public/vendor/fonts/playwrite-co.woff2 +0 -0
  11. package/app/public/vendor/fonts/playwrite-mx.woff2 +0 -0
  12. package/app/public/vendor/fonts/playwrite-us-modern.woff2 +0 -0
  13. package/app/public/vendor/fonts/playwrite-us-trad.woff2 +0 -0
  14. package/app/public/vendor/fonts/pressstart-400.woff2 +0 -0
  15. package/app/public/vendor/fonts.css +64 -2
  16. package/dist-pkg/boot/create-game.d.ts +107 -4
  17. package/dist-pkg/boot/create-game.js +337 -18
  18. package/dist-pkg/core/constants.d.ts +0 -28
  19. package/dist-pkg/core/constants.js +34 -17
  20. package/dist-pkg/core/contract.d.ts +99 -0
  21. package/dist-pkg/core/contract.js +78 -0
  22. package/dist-pkg/core/entity.d.ts +47 -4
  23. package/dist-pkg/core/layers.d.ts +18 -0
  24. package/dist-pkg/core/layers.js +18 -0
  25. package/dist-pkg/core/rng.js +14 -4
  26. package/dist-pkg/core/route.d.ts +42 -0
  27. package/dist-pkg/core/route.js +158 -0
  28. package/dist-pkg/core/state.d.ts +10 -1
  29. package/dist-pkg/core/state.js +13 -0
  30. package/dist-pkg/educational/adaptive-engine.d.ts +65 -0
  31. package/dist-pkg/educational/adaptive-engine.js +117 -0
  32. package/dist-pkg/educational/segment-bar.d.ts +96 -0
  33. package/dist-pkg/educational/segment-bar.js +89 -0
  34. package/dist-pkg/i18n/en.js +44 -0
  35. package/dist-pkg/i18n/es.js +44 -0
  36. package/dist-pkg/i18n/pt.js +60 -0
  37. package/dist-pkg/input/default-bindings.d.ts +40 -0
  38. package/dist-pkg/input/default-bindings.js +151 -9
  39. package/dist-pkg/input/gamepad.d.ts +33 -12
  40. package/dist-pkg/input/gamepad.js +103 -16
  41. package/dist-pkg/input/keyboard-runtime.d.ts +6 -8
  42. package/dist-pkg/input/keyboard-runtime.js +24 -5
  43. package/dist-pkg/input/keyboard.d.ts +42 -0
  44. package/dist-pkg/input/keyboard.js +87 -17
  45. package/dist-pkg/input/keydown.d.ts +40 -2
  46. package/dist-pkg/input/keydown.js +29 -6
  47. package/dist-pkg/input/latch-edge.d.ts +28 -0
  48. package/dist-pkg/input/latch-edge.js +45 -0
  49. package/dist-pkg/input/latch-scope.d.ts +70 -0
  50. package/dist-pkg/input/latch-scope.js +110 -0
  51. package/dist-pkg/input/latch-store.d.ts +45 -0
  52. package/dist-pkg/input/latch-store.js +74 -0
  53. package/dist-pkg/input/latch-sync.d.ts +47 -0
  54. package/dist-pkg/input/latch-sync.js +43 -0
  55. package/dist-pkg/input/origem-sintetica.d.ts +44 -0
  56. package/dist-pkg/input/origem-sintetica.js +60 -0
  57. package/dist-pkg/input/pad-defaults.d.ts +22 -0
  58. package/dist-pkg/input/pad-defaults.js +34 -0
  59. package/dist-pkg/input/pointer.d.ts +61 -0
  60. package/dist-pkg/input/pointer.js +71 -0
  61. package/dist-pkg/input/state.d.ts +86 -1
  62. package/dist-pkg/input/state.js +128 -1
  63. package/dist-pkg/input/touch-bindings.d.ts +21 -2
  64. package/dist-pkg/input/touch-bindings.js +15 -3
  65. package/dist-pkg/input/touch.js +9 -1
  66. package/dist-pkg/input/transporte-em-uso.d.ts +101 -0
  67. package/dist-pkg/input/transporte-em-uso.js +130 -0
  68. package/dist-pkg/input/transports.d.ts +88 -2
  69. package/dist-pkg/input/transports.js +60 -5
  70. package/dist-pkg/input/vocabulary-migration.d.ts +18 -7
  71. package/dist-pkg/platform/audio-earcons.d.ts +29 -2
  72. package/dist-pkg/platform/audio-earcons.js +10 -0
  73. package/dist-pkg/platform/audio-nav.d.ts +1 -1
  74. package/dist-pkg/platform/audio-sonar.d.ts +149 -9
  75. package/dist-pkg/platform/audio-sonar.js +232 -21
  76. package/dist-pkg/platform/guide-intensity.d.ts +40 -0
  77. package/dist-pkg/platform/guide-intensity.js +73 -0
  78. package/dist-pkg/platform/pesados-catalogo.d.ts +14 -0
  79. package/dist-pkg/platform/pesados-catalogo.js +177 -0
  80. package/dist-pkg/platform/pesados.d.ts +31 -0
  81. package/dist-pkg/platform/pesados.js +75 -0
  82. package/dist-pkg/platform/storage.d.ts +30 -0
  83. package/dist-pkg/platform/storage.js +35 -0
  84. package/dist-pkg/platform/tts.js +16 -1
  85. package/dist-pkg/platform/voice-plan.d.ts +109 -0
  86. package/dist-pkg/platform/voice-plan.js +156 -0
  87. package/dist-pkg/platform/vozes-prontas.d.ts +28 -0
  88. package/dist-pkg/platform/vozes-prontas.js +61 -0
  89. package/dist-pkg/render/draw.d.ts +1 -1
  90. package/dist-pkg/render/draw.js +15 -5
  91. package/dist-pkg/render/viz-axes.d.ts +17 -0
  92. package/dist-pkg/render/viz-axes.js +19 -0
  93. package/dist-pkg/render/viz-setters.d.ts +34 -2
  94. package/dist-pkg/render/viz-setters.js +189 -33
  95. package/dist-pkg/render/wheelchair-sprites.d.ts +11 -3
  96. package/dist-pkg/render/wheelchair-sprites.js +10 -4
  97. package/dist-pkg/ui/dom.js +20 -2
  98. package/dist-pkg/ui/fonts.d.ts +71 -1
  99. package/dist-pkg/ui/fonts.js +111 -8
  100. package/dist-pkg/ui/latch-refusal.d.ts +32 -0
  101. package/dist-pkg/ui/latch-refusal.js +60 -0
  102. package/dist-pkg/ui/layout.d.ts +32 -0
  103. package/dist-pkg/ui/layout.js +64 -1
  104. package/dist-pkg/ui/motion-scene.d.ts +41 -0
  105. package/dist-pkg/ui/motion-scene.js +76 -0
  106. package/dist-pkg/ui/panel-shell.d.ts +53 -0
  107. package/dist-pkg/ui/panel-shell.js +103 -0
  108. package/dist-pkg/ui/pause-icons.d.ts +168 -20
  109. package/dist-pkg/ui/pause-icons.js +315 -45
  110. package/dist-pkg/ui/reach-notice.js +8 -0
  111. package/dist-pkg/ui/settings-audio.d.ts +14 -1
  112. package/dist-pkg/ui/settings-audio.js +43 -3
  113. package/dist-pkg/ui/settings-controls.d.ts +66 -6
  114. package/dist-pkg/ui/settings-controls.js +179 -17
  115. package/dist-pkg/ui/settings-empathy.d.ts +15 -0
  116. package/dist-pkg/ui/settings-empathy.js +2 -0
  117. package/dist-pkg/ui/settings-motion.d.ts +25 -19
  118. package/dist-pkg/ui/settings-motion.js +32 -19
  119. package/dist-pkg/ui/settings-motor.d.ts +101 -3
  120. package/dist-pkg/ui/settings-motor.js +136 -9
  121. package/dist-pkg/ui/settings-typo.d.ts +49 -5
  122. package/dist-pkg/ui/settings-typo.js +74 -23
  123. package/dist-pkg/ui/settings-visual.d.ts +21 -1
  124. package/dist-pkg/ui/settings-visual.js +48 -4
  125. package/dist-pkg/ui/shell.d.ts +13 -3
  126. package/dist-pkg/ui/shell.js +3 -4
  127. package/dist-pkg/ui/simulation-refusal.d.ts +32 -0
  128. package/dist-pkg/ui/simulation-refusal.js +57 -0
  129. package/dist-pkg/ui/visual-axes-panel.d.ts +48 -0
  130. package/dist-pkg/ui/visual-axes-panel.js +95 -0
  131. package/dist-pkg/ui/webcam.js +6 -1
  132. package/docs/CREDITS.md +22 -1
  133. package/docs/LICENSES.md +170 -132
  134. package/package.json +25 -4
  135. package/app/public/vendor/fonts/greatvibes-400.woff2 +0 -0
  136. package/app/public/vendor/fonts/ufcook-700.woff2 +0 -0
@@ -6,17 +6,25 @@
6
6
  // production). Overlay open/close plumbing (frontOverlay, focus management, the #typo hidden toggle, Escape
7
7
  // handling) is the SHARED helper used by every settings panel and stays in game.js. The font catalog itself
8
8
  // (FONT_GROUPS/FONT_BY_KEY) stays in ./fonts.js (Phase 2 extraction) — imported here, never duplicated.
9
- import { toggleLabel } from './dom.js';
9
+ // (`toggleLabel` saiu daqui em 2026-09-07: ele devolve «Ligado»/«Desligado», e este menu é uma ESCOLHA.)
10
10
  import { t } from '../core/i18n.js';
11
- import { FONT_GROUPS, FONT_BY_KEY, DEFAULT_FONT_KEY } from './fonts.js';
11
+ import { FONT_GROUPS, FONT_BY_KEY, DEFAULT_FONT_KEY, papelDaFonte, faceDisponivel } from './fonts.js';
12
12
  import { markChanged, markMenuChanged, CHANGED_CLASS } from './changed-mark.js';
13
13
  // ---------------------------------------------------------------------------------------------
14
14
  // Pure logic (no `document`, testable in node)
15
15
  // ---------------------------------------------------------------------------------------------
16
- /** A key is selectable when it exists in the catalog and is not marked `.off` (licence pending, etc.). */
17
- export function isSelectableFont(k) {
16
+ /**
17
+ * A key is selectable when it exists in the catalog, is not marked `.off` (licence pending, etc.) — and is
18
+ * `geral`.
19
+ *
20
+ * ⚠️ O TERCEIRO TERMO ENTROU EM 2026-09-07 (issue #87), e sem ele o resto da mudança seria decoração: o menu
21
+ * deixaria de OFERECER as caligráficas e elas continuariam SELECIONÁVEIS por qualquer outro caminho — o
22
+ * `resolveFontKey` de uma chave guardada, um `data-font` num markup de consumidor. «Não está na lista» e «não
23
+ * pode ser escolhida» têm de ser a mesma afirmação, ou a lista é só uma sugestão.
24
+ */
25
+ export function isSelectableFont(k, instalada) {
18
26
  const it = FONT_BY_KEY[k];
19
- return !!it && !it.off;
27
+ return !!it && faceDisponivel(it, instalada) && papelDaFonte(it) === 'geral';
20
28
  }
21
29
  // A semantica da chave (validacao + migracao da chave antiga) mora em ui/fonts.ts, que e o dono do
22
30
  // catalogo; re-exportada aqui para quem ja consome este modulo. Uma implementacao, nao duas.
@@ -38,35 +46,76 @@ export function fontCssTarget(k, it) {
38
46
  const suffix = it.fb === 'serif' ? ',Georgia,serif' : it.fb === 'cursive' ? ',cursive' : '';
39
47
  return { fonte: 'custom', customFamily: `'${it.fam}'${suffix}` };
40
48
  }
49
+ /**
50
+ * UMA linha da lista, a partir de uma face do catálogo. Extraída em 2026-09-07 (issue #87) porque o gate do
51
+ * mecanismo `.off` precisava de a exercitar com uma face de MENTIRA: as duas únicas entradas desligadas
52
+ * saíram do roster, e um caso que dependa da composição do catálogo reprova sempre que o roster muda.
53
+ *
54
+ * O mecanismo fica, e é preciso: o item 4 da #87 usa-o para a **Ronde**, que só pode ser oferecida se uma de
55
+ * três faces estiver instalada, porque duas delas são gratuitas apenas para uso pessoal e não podem ser
56
+ * empacotadas.
57
+ */
58
+ export function linhaDaFonte(it, fontKey, instalada) {
59
+ // ⚠️ A MESMA pergunta que o `isSelectableFont` faz, pela MESMA função. Duas respostas dariam uma linha
60
+ // clicável que o clique recusa — ou, pior, uma linha cinzenta que o `resolveFontKey` aceita por outro
61
+ // caminho. «Não está disponível» e «não pode ser escolhida» têm de ser a mesma afirmação.
62
+ const disabled = !faceDisponivel(it, instalada);
63
+ // `d` e `off` também guardam CHAVE. O travessão que junta os dois é pontuação, não frase — as duas
64
+ // metades são independentes e cada uma traduz por si.
65
+ const desc = it.d ? t(it.d) : '', motivo = it.off ? t(it.off) : '';
66
+ const note = desc ? desc + (disabled ? ' — ' + motivo : '') : disabled ? motivo : '';
67
+ return { key: it.k, fam: it.fam, selected: fontKey === it.k, disabled, note };
68
+ }
41
69
  /** Pure view-model for the typography list: which row is selected/disabled and its note, per catalog group. */
42
- export function typoGroups(fontKey) {
70
+ export function typoGroups(fontKey, instalada) {
71
+ // ⚠️ SÓ AS GERAIS ENTRAM NA LISTA (emenda do ADR-0012, issue #87). As caligráficas existem para a criança
72
+ // APRENDER a ler letra cursiva — isso é matéria, e vive DENTRO das atividades, em botões próprios. Oferecê-
73
+ // las aqui é dar-lhe a matéria como obstáculo em todo lugar onde ela só quer navegar o menu.
74
+ //
75
+ // Um grupo que fique sem nenhuma face geral desaparece da lista, em vez de aparecer como título vazio.
43
76
  return FONT_GROUPS.map((g) => ({
44
77
  g: t(g.g), // `g` guarda CHAVE i18n desde o item 14 (ver ui/fonts)
45
- rows: g.items.map((it) => {
46
- const disabled = !!it.off;
47
- // `d` e `off` também guardam CHAVE. O travessão que junta os dois é pontuação, não frase — as duas
48
- // metades são independentes e cada uma traduz por si.
49
- const desc = it.d ? t(it.d) : '', motivo = it.off ? t(it.off) : '';
50
- const note = desc ? desc + (disabled ? ' — ' + motivo : '') : disabled ? motivo : '';
51
- return { key: it.k, fam: it.fam, selected: fontKey === it.k, disabled, note };
52
- }),
53
- }));
78
+ rows: g.items.filter((it) => papelDaFonte(it) === 'geral').map((it) => linhaDaFonte(it, fontKey, instalada)),
79
+ })).filter((grupo) => grupo.rows.length > 0);
54
80
  }
81
+ /**
82
+ * ⚠️ A MARCA DE SELEÇÃO, e ela existe porque COR NÃO É ESTADO.
83
+ *
84
+ * O `.mode-btn.is-on` pinta o botão com `var(--accent)` — o fundo amarelo que a emenda do ADR-0012 pede
85
+ * por extenso. Mas quem não distingue a cor não vê estado nenhum, e é a mesma razão pela qual
86
+ * `ui/activities-menu.ts:656` já emite ☑/☐ ao lado do `aria-checked`: «o estado em DUAS formas, e nenhuma
87
+ * delas é cor».
88
+ */
89
+ const MARCA_ESCOLHIDA = '●';
90
+ const MARCA_ALTERNATIVA = '○';
55
91
  function rowHTML(row) {
56
92
  const noteHTML = row.note
57
93
  ? `<br><span class="opt-hint" style="margin:0;font-family:var(--font)">${row.note}</span>`
58
94
  : '';
59
95
  const ariaLabel = row.fam + (row.note ? ' — ' + row.note : '');
60
- const stateLabel = toggleLabel(row.selected);
61
96
  return (`<div class="ctrl-row"><span style="font-family:'${row.fam}'"><strong>${row.fam}</strong>${noteHTML}</span>` +
62
- `<button class="mode-btn switch${row.selected ? ' is-on' : ''}" data-font="${row.key}" type="button"` +
63
- `${row.disabled ? ' disabled' : ''} aria-pressed="${row.selected}" aria-label="${ariaLabel}">${stateLabel}</button></div>`);
97
+ `<button class="mode-btn${row.selected ? ' is-on' : ''}" data-font="${row.key}" type="button" role="radio"` +
98
+ `${row.disabled ? ' disabled' : ''} aria-checked="${row.selected}" aria-label="${ariaLabel}">` +
99
+ `${row.selected ? MARCA_ESCOLHIDA : MARCA_ALTERNATIVA}</button></div>`);
64
100
  }
65
- /** Full innerHTML for #typo-list, given the currently selected key. Pure string building — no DOM. */
66
- export function typoListHTML(fontKey) {
67
- return typoGroups(fontKey)
101
+ /**
102
+ * Full innerHTML for #typo-list, given the currently selected key. Pure string building — no DOM.
103
+ *
104
+ * ⚠️ É UM GRUPO DE RÁDIO SÓ, ATRAVESSANDO AS TRÊS SECÇÕES, e isso é a decisão e não um detalhe de
105
+ * marcação: a exclusividade é do MENU inteiro — uma fonte activa no total —, não de cada família. Três
106
+ * grupos diriam a quem escuta que dá para ter uma sans E uma serif ao mesmo tempo.
107
+ *
108
+ * ⚠️ E ISTO DEIXOU DE SER UM INTERRUPTOR EM 2026-09-07. Era `class="mode-btn switch"` + `aria-pressed` +
109
+ * `toggleLabel()`, ou seja dezassete interruptores independentes anunciando «Ligado»/«Desligado» para
110
+ * escolher UMA fonte. A emenda do ADR-0012 diz o contrário em tantas palavras: «THE MENU IS A CHOICE, NOT
111
+ * A TOGGLE […] One font is active; the others are alternatives, not switches.» O `switch` do
112
+ * `style.css:427` desenha uma chave de 52×28 px com bolinha — era o desenho de um estado que não existe.
113
+ */
114
+ export function typoListHTML(fontKey, instalada) {
115
+ const grupos = typoGroups(fontKey, instalada)
68
116
  .map((group) => `<h3 class="panel-sub">${group.g}</h3>` + group.rows.map(rowHTML).join(''))
69
117
  .join('');
118
+ return `<div role="radiogroup" aria-label="${t('font.grupo.rotulo')}">${grupos}</div>`;
70
119
  }
71
120
  // ---------------------------------------------------------------------------------------------
72
121
  // DOM-facing (thin) — requires `document`/injected ctx
@@ -75,7 +124,9 @@ export function initSettingsTypo(ctx) {
75
124
  let fontKey = resolveFontKey(ctx.store);
76
125
  function setFont(k, announce = false) {
77
126
  const it = FONT_BY_KEY[k];
78
- if (!it || it.off)
127
+ // A MESMA função das outras duas leituras: uma face que a lista mostra clicável tem de ser aceite aqui,
128
+ // e uma que ela mostra cinzenta tem de ser recusada. Três respostas à mesma pergunta divergem.
129
+ if (!it || !faceDisponivel(it, ctx.fonteInstalada))
79
130
  return;
80
131
  fontKey = k;
81
132
  persistFontKey(ctx.store, k);
@@ -95,7 +146,7 @@ export function initSettingsTypo(ctx) {
95
146
  const el = ctx.$('#typo-list');
96
147
  if (!el)
97
148
  return;
98
- el.innerHTML = typoListHTML(fontKey);
149
+ el.innerHTML = typoListHTML(fontKey, ctx.fonteInstalada);
99
150
  el.querySelectorAll('button[data-font]').forEach((b) => {
100
151
  b.addEventListener('click', () => {
101
152
  const k = b.dataset.font;
@@ -50,7 +50,14 @@ export interface SettingsVisualCtx {
50
50
  /** Desenha UMA lista de rádio de modos visuais (+ abas por jogador). O MESMO helper que o painel de
51
51
  * empatia usa — de propósito: as três correções mudaram de menu, e mudar junto a aparência delas faria a
52
52
  * criança ter de reaprender um controle que ela já conhecia. */
53
- renderVizGroup: (listSel: string, tabsSel: string, modes: readonly VizMode[]) => void;
53
+ /**
54
+ * Os DOIS eixos deste painel (#104). Substituiu o `renderVizGroup`, que fica com o painel de EMPATIA.
55
+ *
56
+ * ⚠️ A lista de sete que este painel oferecia era a forma honesta de contar uma exclusividade REAL, e o
57
+ * `VISUAL_MODES` explica-a em prosa logo acima. Ela deixou de existir: o estado tem dois eixos, e os
58
+ * escritores por eixo mexem num sem tocar no outro.
59
+ */
60
+ renderEixosVisuais: (listSel: string, tabsSel: string) => void;
54
61
  setLq: (t: number) => void;
55
62
  setOwnerColors: (on: boolean) => void;
56
63
  setCbSafe: (on: boolean) => void;
@@ -58,6 +65,19 @@ export interface SettingsVisualCtx {
58
65
  setOutlineBg: (level: number) => void;
59
66
  setRoleColor: (key: RoleKey, hex: string) => void;
60
67
  resetRoleColors: () => void;
68
+ /**
69
+ * Move a prosa das linhas para o rodapé (`ui/settings-panel` → `fillExplain`). Chamado a CADA render.
70
+ *
71
+ * ⚠️ NÃO É OPCIONAL POR ELEGÂNCIA: `fillExplain` roda uma vez quando o overlay é frontalizado e move o
72
+ * `.opt-hint` de dentro de cada linha para o rodapé. Este painel RECONSTRÓI as linhas, e as linhas novas
73
+ * voltam com a prosa lá dentro — então a explicação aparece duas vezes, no rodapé e sob o rótulo, a
74
+ * partir do primeiro clique. O `CLAUDE.md` §4 regista exatamente isto, e a issue #109 já o consertou
75
+ * uma vez noutros painéis.
76
+ *
77
+ * Opcional na assinatura porque um consumidor pode montar o painel sem a casca (um teste, o segundo
78
+ * consumidor): sem casca não há rodapé para duplicar.
79
+ */
80
+ fillExplain?: (card: HTMLElement | null) => void;
61
81
  }
62
82
  /**
63
83
  * `viz` quando ele é um modo DESTE menu (contraste ou correção), senão 'normal'.
@@ -12,6 +12,8 @@ import { lqName as lqLabel } from '../render/lq-filter.js';
12
12
  import { HC_ROLE_KEYS, HC_ROLE_DEF } from '../render/hc-role-data.js';
13
13
  import { VIZ_CORRECTIONS, VIZ_MODES } from '../render/viz-modes.js';
14
14
  import { DEFAULTS } from '../core/state.js';
15
+ // O padrao dos DOIS EIXOS (ADR-0076/#104). A marca pergunta ao modelo novo, nao ao espelho p.viz.
16
+ import { PADRAO as PADRAO_VISUAL } from '../render/viz-axes.js';
15
17
  import { markChanged, markMenuChanged } from './changed-mark.js';
16
18
  /** Contrast levels, in cycle order — mirrors game.js's HC_SEQ (also used there by the physical contrast-cycle button). */
17
19
  export const CONTRAST_LEVELS = ['normal', 'hc-direto', 'hc-direto-45', 'hc-direto-7'];
@@ -77,6 +79,21 @@ export function rgbToHex(rgb) {
77
79
  export function onOffLabel(on) {
78
80
  return toggleLabel(on);
79
81
  }
82
+ /**
83
+ * Lê `player[i].visual` defensivamente, sem importar o tipo do jogador — o mesmo molde do `playerViz` abaixo,
84
+ * e pela mesma razão: `getPlayers()` devolve `readonly unknown[]` porque o tipo real é do JOGO (ADR-0033), e
85
+ * cada consumidor estreita para a SUA fatia.
86
+ *
87
+ * ⚠️ Sem jogador ou sem o campo, devolve o PADRÃO — que é a resposta certa para «esta criança mexeu em
88
+ * alguma coisa?»: quem não existe não mexeu. Inventar `hc7` aqui marcaria um menu que ninguém tocou.
89
+ */
90
+ function playerVisual(list, i) {
91
+ const v = list[i]?.visual;
92
+ return {
93
+ tema: typeof v?.tema === 'string' ? v.tema : PADRAO_VISUAL.tema,
94
+ correcao: typeof v?.correcao === 'string' ? v.correcao : PADRAO_VISUAL.correcao,
95
+ };
96
+ }
80
97
  /** Reads player[i].viz defensively (no player at that index -> 'normal'), without a Player type import. */
81
98
  function playerViz(list, i) {
82
99
  const p = list[i];
@@ -144,7 +161,11 @@ export function initSettingsVisual(ctx) {
144
161
  ctx.setSelectedPlayer(selected);
145
162
  const contrastValue = resolveVisualMode(playerViz(ctx.getPlayers(), selected));
146
163
  const settings = ctx.getVisualSettings();
147
- ctx.renderVizGroup('#visual-modes', '#visual-players', VISUAL_MODE_LIST);
164
+ // ⚠️ `renderEixosVisuais` E NÃO `renderVizGroup` desde a #104: este painel passou a ter DOIS controles,
165
+ // e o `renderVizGroup` continua a servir o painel de EMPATIA, cuja lista de simulações é mesmo exclusiva.
166
+ // Trocar o corpo daquela função em vez de acrescentar esta teria posto os dois eixos na lista de
167
+ // simulações — foi o que quase aconteceu, e o que a separação impede.
168
+ ctx.renderEixosVisuais('#visual-modes', '#visual-players');
148
169
  el.innerHTML = renderVisualPanelHtml(contrastValue, settings);
149
170
  const lq = ctx.$('#opt-lq');
150
171
  const lqv = ctx.$('#opt-lq-val');
@@ -182,6 +203,8 @@ export function initSettingsVisual(ctx) {
182
203
  rr.addEventListener('click', () => { ctx.resetRoleColors(); render(); });
183
204
  reflectOutlines();
184
205
  refreshMarks();
206
+ // A prosa volta para o rodapé depois de as linhas serem reconstruídas (CLAUDE.md §4, #109).
207
+ ctx.fillExplain?.(ctx.$('#visual .overlay__card'));
185
208
  }
186
209
  /**
187
210
  * A marca de "saiu do padrão" (ADR-0029). Cada linha contra o SEU padrão, e o botão do menu por cima.
@@ -193,7 +216,27 @@ export function initSettingsVisual(ctx) {
193
216
  */
194
217
  function refreshMarks() {
195
218
  const s = ctx.getVisualSettings();
196
- const contraste = resolveVisualMode(playerViz(ctx.getPlayers(), ctx.getSelectedPlayer())) !== 'normal';
219
+ /**
220
+ * 🔴 A MARCA É POR EIXO DESDE 2026-09-08, e a granularidade que ela recupera foi perdida por mim na #104
221
+ * etapa 4d. Enquanto o `#visual-modes` era UM rádio de sete opções, marcar o contentor era marcar a
222
+ * escolha. A etapa 4d pôs DOIS eixos lá dentro, e uma marca no contentor deixou de dizer QUAL saiu do
223
+ * padrão — que é o terceiro canal do ADR-0029 a perder informação: «a criança cega percorre o menu e OUVE,
224
+ * em ordem, o que saiu do padrão». Ouvir «mudado» sem saber de quê manda-a procurar em oito linhas.
225
+ *
226
+ * ⚠️ E VEM DO MODELO NOVO. Isto era `resolveVisualMode(playerViz(...))`, que lê o `p.viz` que a etapa 1a
227
+ * marcou `@deprecated` — um leitor que ficou para trás na migração.
228
+ *
229
+ * 📌 E A REGRA DA SIMULAÇÃO DEIXA DE SER DERIVADA E PASSA A SER ESTRUTURAL, que é o ganho de fundo. Ela
230
+ * está escrita logo acima: uma simulação de empatia não marca ESTE menu, porque a marca dela pertence ao
231
+ * menu de empatia e é lá que ela leva a criança. Antes isso dependia de o `resolveVisualMode` responder
232
+ * `normal`; agora a simulação vive noutro campo do `VisualState`, e perguntar pelo tema e pela correcção
233
+ * nunca a alcança.
234
+ */
235
+ const visual = playerVisual(ctx.getPlayers(), ctx.getSelectedPlayer());
236
+ const tema = visual.tema !== PADRAO_VISUAL.tema;
237
+ const correcao = visual.correcao !== PADRAO_VISUAL.correcao;
238
+ const linhaDoEixo = (eixo) => ctx.$(`#visual-modes button[data-eixo="${eixo}"][aria-checked="true"]`)
239
+ ?.closest('.ctrl-row') ?? null;
197
240
  const lqOff = s.lq !== DEFAULTS.lq;
198
241
  const owner = s.ownerColors !== DEFAULTS.ownerColors;
199
242
  const cb = s.cbSafe !== DEFAULTS.cbSafe;
@@ -201,14 +244,15 @@ export function initSettingsVisual(ctx) {
201
244
  const bg = s.outlineBg !== DEFAULTS.hcOutlineBg;
202
245
  const papeis = ROLE_KEYS.some((k) => !sameRgb(s.roleColors[k], HC_ROLE_DEF[k]));
203
246
  const linha = (sel) => ctx.$(sel)?.closest('.ctrl-row') ?? null;
204
- markChanged(ctx.$('#visual-modes'), contraste);
247
+ markChanged(linhaDoEixo('tema'), tema);
248
+ markChanged(linhaDoEixo('correcao'), correcao);
205
249
  markChanged(linha('#opt-lq'), lqOff);
206
250
  markChanged(linha('#opt-ownercolors'), owner);
207
251
  markChanged(linha('#opt-cbsafe'), cb);
208
252
  markChanged(linha('#opt-outline-fg'), fg);
209
253
  markChanged(linha('#opt-outline-bg'), bg);
210
254
  markChanged(linha('#opt-role-reset'), papeis);
211
- markMenuChanged(ctx.$('[data-act="visual"]'), [contraste, lqOff, owner, cb, fg, bg, papeis]);
255
+ markMenuChanged(ctx.$('[data-act="visual"]'), [tema, correcao, lqOff, owner, cb, fg, bg, papeis]);
212
256
  }
213
257
  // ---- restaurar os padrões DESTE menu (ADR-0028) ----
214
258
  //
@@ -32,9 +32,19 @@ export type { FatosDaCena } from '../core/scenes.js';
32
32
  export interface PhaseView {
33
33
  /** `#title-overlay`.hidden — o splash só aparece no título. */
34
34
  titleOverlayHidden: boolean;
35
- /** `#pause-overlay`.hidden — SEMPRE true. A pausa global foi aposentada na Etapa 2 (agora é uma por tela);
36
- * o elemento continua no index.html e o original o escondia incondicionalmente. Verbatim. */
37
- pauseOverlayHidden: true;
35
+ /**
36
+ * ⚠️ `pauseOverlayHidden` SAIU EM 2026-09-08, e a ausência é a notícia.
37
+ *
38
+ * Ele era `true` em toda fase, e existia porque a Etapa 2 aposentou a pausa GLOBAL sem apagar o elemento: a
39
+ * casca continuava a procurá-lo e a escondê-lo a cada troca de fase, com dois gates a afirmar que ele ficava
40
+ * escondido. Código a segurar um cadáver.
41
+ *
42
+ * E o cadáver custava mais do que as linhas: aquelas quarenta linhas de `#pause-overlay` no `index.html` do
43
+ * cartucho eram o menu de pausa com aspecto mais OFICIAL do repositório — o que o próximo autor de cartucho
44
+ * copia junto com o ficheiro —, e já tinham derivado do que a engine gera (dois itens «Comunicação», e um
45
+ * `#opt-letra` que hoje sai do `dynLabel`). Um campo que diz «está escondido» aceita que ele exista; não o
46
+ * ter diz que ele não existe.
47
+ */
38
48
  /** `.screen-pause`.hidden de CADA tela — os menus por tela só aparecem na pausa. */
39
49
  screenPauseHidden: boolean;
40
50
  /** `setMasterMuted(...)` — GAG: fora de 'playing' TODO o som cala (loops de ambiente/chuva inclusive). */
@@ -99,7 +99,6 @@ import { PM_ITENS_VISIVEIS } from './pause-icons.js';
99
99
  export function phaseView(f) {
100
100
  return {
101
101
  titleOverlayHidden: !f.telaDeTitulo,
102
- pauseOverlayHidden: true,
103
102
  screenPauseHidden: !f.menuDePausa,
104
103
  masterMuted: !f.mundoRodando,
105
104
  hideTouchControls: !f.mundoRodando,
@@ -308,11 +307,11 @@ export function initShell(ctx) {
308
307
  /* ===================== a troca de fase ===================== */
309
308
  /** A metade IMPURA: pega a projeção pronta e a carimba no documento. */
310
309
  function applyPhaseView(v) {
311
- const t = ctx.$('#title-overlay'), pa = ctx.$('#pause-overlay');
310
+ // A pausa GLOBAL não é procurada: ela foi aposentada na Etapa 2, e a casca deixou de a segurar em
311
+ // 2026-09-08. Ver a nota no `PhaseView`, onde o campo estava.
312
+ const t = ctx.$('#title-overlay');
312
313
  if (t)
313
314
  t.hidden = v.titleOverlayHidden;
314
- if (pa)
315
- pa.hidden = v.pauseOverlayHidden; // pausa GLOBAL aposentada (Etapa 2): agora é uma por tela
316
315
  ctx.getPauseScreens().forEach((sp) => { sp.hidden = v.screenPauseHidden; });
317
316
  }
318
317
  /** O estado do `#touch-controls` ANTES de qualquer coisa desta troca de fase mexer nele. */
@@ -0,0 +1,32 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ import { type MotivoIndisponivel, type VisualState } from '../render/viz-axes.js';
3
+ /** Uma linha pronta a traduzir: a chave e o que ela precisa. `null` = não há nada a dizer. */
4
+ export interface Recusa {
5
+ readonly chave: string;
6
+ /** O que a criança tem de desfazer para a demonstração ser honesta. Entra na frase por `{eixo}`. */
7
+ readonly eixo: MotivoIndisponivel;
8
+ }
9
+ /**
10
+ * A chave i18n do motivo, por eixo fora do padrão.
11
+ *
12
+ * ⚠️ TRÊS CHAVES E NÃO UMA COM PARÂMETRO, e a diferença é de tradução e não de estilo: em português «o tema»
13
+ * e «a correção de cor» levam artigos diferentes, e «os dois» não é o plural de nenhum dos dois. Uma frase
14
+ * com `{eixo}` obrigaria cada idioma a montar concordância a partir de um substantivo solto — que é
15
+ * exactamente o defeito que o `sr.nav.clockOne` já registou para «às 1 horas».
16
+ */
17
+ export declare const CHAVE_DO_MOTIVO: Readonly<Record<MotivoIndisponivel, string>>;
18
+ /**
19
+ * Esta simulação pode ser oferecida? E se não, o que dizer.
20
+ *
21
+ * `null` = pode; a interface não mostra nada, porque um aviso que aparece sempre deixa de ser lido.
22
+ */
23
+ export declare function recusaDaSimulacao(v: VisualState): Recusa | null;
24
+ /**
25
+ * A simulação deve aparecer DESABILITADA em vez de sumir?
26
+ *
27
+ * ⚠️ SEMPRE, E É A METADE «never silently removed» DO ADR-0076. Existe como função com nome próprio, e não
28
+ * como um `!recusa` no ponto de uso, porque ela responde a uma pergunta diferente: `recusaDaSimulacao` diz
29
+ * *por que* não dá; esta diz *que a linha continua na tela*. Quem desenha precisa das duas, e juntá-las numa
30
+ * só faria a resposta «não há motivo» parecer «não desenhe a linha».
31
+ */
32
+ export declare function mostraMesmoIndisponivel(): boolean;
@@ -0,0 +1,57 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // ui/simulation-refusal — POR QUE A SIMULAÇÃO NÃO ESTÁ DISPONÍVEL, dito à criança (ADR-0076, issue #104).
3
+ //
4
+ // ========================= O QUE A RECUSA TEM DE FAZER, E O QUE ELA NÃO PODE FAZER =========================
5
+ // O ADR-0076 exige que uma simulação indisponível apareça **VISÍVEL e explicada** — «never silently removed,
6
+ // never accepted then ignored». As duas metades dessa frase descrevem dois defeitos diferentes:
7
+ //
8
+ // · REMOVER EM SILÊNCIO ensina que a coisa não existe. Um adulto que ontem mostrou a simulação a uma turma
9
+ // e hoje não a acha conclui que ela foi tirada, e não que ele próprio ligou o alto contraste.
10
+ // · ACEITAR E IGNORAR é pior: a demonstração PARECE estar a correr. Sobre uma tela já corrigida, ela não
11
+ // mostra nem a deficiência nem a correção; sobre um tema de alto contraste, mostra o que o TEMA faz e não
12
+ // o que a deuteranopia faz. ⚠️ Não é uma demonstração mais fraca — **ela ensina uma coisa falsa**.
13
+ //
14
+ // ⚠️ E O MOTIVO É UM FACTO SOBRE A DEMONSTRAÇÃO, NUNCA UMA REPREENSÃO A QUEM ESCOLHEU. Quem ligou o alto
15
+ // contraste ligou-o porque precisa; quem ligou a correção de cor vê melhor com ela. A frase diz o que a
16
+ // demonstração precisa para ser honesta, e diz como voltar — nunca «desligue isso».
17
+ //
18
+ // ========================= POR QUE ISTO É UM MÓDULO PURO =========================
19
+ // Mesma forma do `ui/reach-notice`: devolve DADO (chave i18n + parâmetros), não texto pronto. A frase é da
20
+ // interface e tem de passar por `t()`; devolver português daqui repetiria o defeito que o `PADWIZ_STEPS` já
21
+ // deixou de cometer. E, sendo puro, o desenho da recusa é conferível no project `node`, sem documento.
22
+ //
23
+ // Módulo-folha: importa só os tipos e o predicado do modelo de dois eixos.
24
+ import { simulacaoIndisponivel } from '../render/viz-axes.js';
25
+ /**
26
+ * A chave i18n do motivo, por eixo fora do padrão.
27
+ *
28
+ * ⚠️ TRÊS CHAVES E NÃO UMA COM PARÂMETRO, e a diferença é de tradução e não de estilo: em português «o tema»
29
+ * e «a correção de cor» levam artigos diferentes, e «os dois» não é o plural de nenhum dos dois. Uma frase
30
+ * com `{eixo}` obrigaria cada idioma a montar concordância a partir de um substantivo solto — que é
31
+ * exactamente o defeito que o `sr.nav.clockOne` já registou para «às 1 horas».
32
+ */
33
+ export const CHAVE_DO_MOTIVO = Object.freeze({
34
+ tema: 'sim.indisponivel.tema',
35
+ correcao: 'sim.indisponivel.correcao',
36
+ ambos: 'sim.indisponivel.ambos',
37
+ });
38
+ /**
39
+ * Esta simulação pode ser oferecida? E se não, o que dizer.
40
+ *
41
+ * `null` = pode; a interface não mostra nada, porque um aviso que aparece sempre deixa de ser lido.
42
+ */
43
+ export function recusaDaSimulacao(v) {
44
+ const motivo = simulacaoIndisponivel(v);
45
+ return motivo === null ? null : { chave: CHAVE_DO_MOTIVO[motivo], eixo: motivo };
46
+ }
47
+ /**
48
+ * A simulação deve aparecer DESABILITADA em vez de sumir?
49
+ *
50
+ * ⚠️ SEMPRE, E É A METADE «never silently removed» DO ADR-0076. Existe como função com nome próprio, e não
51
+ * como um `!recusa` no ponto de uso, porque ela responde a uma pergunta diferente: `recusaDaSimulacao` diz
52
+ * *por que* não dá; esta diz *que a linha continua na tela*. Quem desenha precisa das duas, e juntá-las numa
53
+ * só faria a resposta «não há motivo» parecer «não desenhe a linha».
54
+ */
55
+ export function mostraMesmoIndisponivel() {
56
+ return true;
57
+ }
@@ -0,0 +1,48 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ import { type Tema, type Correcao, type VisualState } from '../render/viz-axes.js';
3
+ /** O rótulo de cada tema. Chave i18n — quem exibe resolve, como todo o resto do menu. */
4
+ export declare const ROTULO_DO_TEMA: Readonly<Record<Tema, string>>;
5
+ /** O rótulo de cada correção de cor. */
6
+ export declare const ROTULO_DA_CORRECAO: Readonly<Record<Correcao, string>>;
7
+ /**
8
+ * Os rótulos CURTOS, para os ícones da barra rápida — «7:1», «deuteranopia».
9
+ *
10
+ * ⚠️ SÃO AS CHAVES QUE JÁ EXISTIAM (`contrast.*`, `cvd.*`), re-chaveadas por eixo. A barra rápida sempre
11
+ * falou curto porque anuncia UM ícone de cada vez, e o painel sempre falou por extenso porque a criança está
12
+ * a ler uma lista — a diferença é de contexto e sobrevive à divisão. Reaproveitar em vez de traduzir de novo
13
+ * é o que mantém a mesma palavra nos dois sítios.
14
+ */
15
+ export declare const CURTO_DO_TEMA: Readonly<Record<Tema, string>>;
16
+ export declare const CURTO_DA_CORRECAO: Readonly<Record<Correcao, string>>;
17
+ /** Os dois eixos, como o painel os identifica no DOM. */
18
+ export type EixoVisual = 'tema' | 'correcao';
19
+ /** Um tradutor, igual ao que o resto da interface recebe. */
20
+ export type Tradutor = (chave: string, params?: Record<string, string | number>) => string;
21
+ /**
22
+ * As linhas de UM eixo, no formato de rádio que o painel já usa.
23
+ *
24
+ * ⚠️ RÁDIO E NÃO SETE BOTÕES, e o motivo sobrevive à divisão: DENTRO de um eixo os valores continuam
25
+ * exclusivos — um tema de cada vez, uma correção de cada vez. O que deixou de ser exclusivo é a relação
26
+ * ENTRE os eixos, e é por isso que eles viram dois rádios em vez de um.
27
+ *
28
+ * ⚠️ E MANTÉM-SE A FORMA DE LINHA VISÍVEL, não um `<select>`. O `settings-visual` regista o erro que o Dev
29
+ * apanhou na primeira tentativa: dentro de uma caixa fechada, um controle cuja razão de existir é ser ACHADO
30
+ * por quem enxerga mal fica «quase o mesmo que não ter movido».
31
+ */
32
+ export declare function linhasDoEixo(eixo: EixoVisual, valores: readonly string[], rotulos: Readonly<Record<string, string>>, atual: string, t: Tradutor): string;
33
+ /**
34
+ * Os DOIS eixos, um a seguir ao outro, com um título cada.
35
+ *
36
+ * O título existe porque dois rádios seguidos sem nome são um rádio de oito para quem lê depressa — e essa
37
+ * leitura é exactamente o mal-entendido que a divisão existe para desfazer.
38
+ */
39
+ export declare function eixosHtml(v: VisualState, t: Tradutor): string;
40
+ /** O que um clique num botão do painel quer dizer. `null` quando o botão não é de eixo nenhum. */
41
+ export interface EscolhaDeEixo {
42
+ readonly eixo: EixoVisual;
43
+ readonly valor: string;
44
+ }
45
+ export declare function escolhaDoBotao(dataset: {
46
+ eixo?: string;
47
+ valor?: string;
48
+ }): EscolhaDeEixo | null;
@@ -0,0 +1,95 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // ui/visual-axes-panel — O MENU VISUAL COM DOIS CONTROLES (ADR-0076, issue #104), na metade pura.
3
+ //
4
+ // ========================= O COMENTÁRIO QUE AUTORIZA ESTA MUDANÇA =========================
5
+ // O `ui/settings-visual` justifica o controle ÚNICO com estas palavras, e elas continuam lá, verdadeiras
6
+ // sobre o dia em que foram escritas:
7
+ //
8
+ // «Os sete vivem num CONTROLE SÓ, e isso não é economia de espaço: `p.viz` guarda UM valor. Dois
9
+ // controles separados se sobrescreveriam em silêncio — a criança escolheria a correção, depois o
10
+ // contraste, e perderia a correção sem nada dizer que perdeu.»
11
+ //
12
+ // ⚠️ A RAZÃO QUE ELE DÁ DEIXOU DE EXISTIR. `p.viz` já não guarda um valor: guarda dois eixos mais a
13
+ // simulação, e os escritores por eixo (`setTemaDoJogador`/`setCorrecaoDoJogador`) mexem num sem tocar no
14
+ // outro. O controle único era a forma honesta de contar uma exclusividade REAL; mantê-lo agora seria contar
15
+ // uma exclusividade que já não existe — e continuar a negar os dois a quem precisa dos dois.
16
+ //
17
+ // ========================= OS NOMES DOS PADRÕES SÃO DECISÃO, NÃO ROTINA =========================
18
+ // ⚠️ «Tema padrão» e «Visão tricromática», e o ADR-0076 fecha a *definition of done* com a regra que os
19
+ // escolheu: **nenhum rótulo de padrão diagnostica quem lê**. `modo sem deficiência visual` foi oferecido e
20
+ // recusado — ele diz à criança o que ela NÃO é, no menu que ela abriu para conseguir jogar. Um padrão
21
+ // chama-se pelo que ele É.
22
+ //
23
+ // E `Modo padrão` (o `viz.normal` de hoje) deixa de servir aqui: era o neutro PARTILHADO, de quando os dois
24
+ // eixos eram um só. Com dois controles, um «padrão» sem dizer padrão de QUÊ é ambíguo em ambos.
25
+ //
26
+ // Módulo puro: devolve HTML e chaves i18n, não toca documento nenhum. Quem monta é o `render/viz-setters`.
27
+ import { TEMAS, CORRECOES } from '../render/viz-axes.js';
28
+ /** O rótulo de cada tema. Chave i18n — quem exibe resolve, como todo o resto do menu. */
29
+ export const ROTULO_DO_TEMA = Object.freeze({
30
+ padrao: 'eixo.tema.padrao',
31
+ hc3: 'viz.hc-direto',
32
+ hc45: 'viz.hc-direto-45',
33
+ hc7: 'viz.hc-direto-7',
34
+ });
35
+ /** O rótulo de cada correção de cor. */
36
+ export const ROTULO_DA_CORRECAO = Object.freeze({
37
+ tricro: 'eixo.correcao.tricro',
38
+ protan: 'viz.fix-protan',
39
+ deuter: 'viz.fix-deuter',
40
+ tritan: 'viz.fix-tritan',
41
+ });
42
+ /**
43
+ * Os rótulos CURTOS, para os ícones da barra rápida — «7:1», «deuteranopia».
44
+ *
45
+ * ⚠️ SÃO AS CHAVES QUE JÁ EXISTIAM (`contrast.*`, `cvd.*`), re-chaveadas por eixo. A barra rápida sempre
46
+ * falou curto porque anuncia UM ícone de cada vez, e o painel sempre falou por extenso porque a criança está
47
+ * a ler uma lista — a diferença é de contexto e sobrevive à divisão. Reaproveitar em vez de traduzir de novo
48
+ * é o que mantém a mesma palavra nos dois sítios.
49
+ */
50
+ export const CURTO_DO_TEMA = Object.freeze({
51
+ padrao: 'contrast.off', hc3: 'contrast.3', hc45: 'contrast.45', hc7: 'contrast.7',
52
+ });
53
+ export const CURTO_DA_CORRECAO = Object.freeze({
54
+ tricro: 'cvd.off', protan: 'cvd.protan', deuter: 'cvd.deuter', tritan: 'cvd.tritan',
55
+ });
56
+ /**
57
+ * As linhas de UM eixo, no formato de rádio que o painel já usa.
58
+ *
59
+ * ⚠️ RÁDIO E NÃO SETE BOTÕES, e o motivo sobrevive à divisão: DENTRO de um eixo os valores continuam
60
+ * exclusivos — um tema de cada vez, uma correção de cada vez. O que deixou de ser exclusivo é a relação
61
+ * ENTRE os eixos, e é por isso que eles viram dois rádios em vez de um.
62
+ *
63
+ * ⚠️ E MANTÉM-SE A FORMA DE LINHA VISÍVEL, não um `<select>`. O `settings-visual` regista o erro que o Dev
64
+ * apanhou na primeira tentativa: dentro de uma caixa fechada, um controle cuja razão de existir é ser ACHADO
65
+ * por quem enxerga mal fica «quase o mesmo que não ter movido».
66
+ */
67
+ export function linhasDoEixo(eixo, valores, rotulos, atual, t) {
68
+ return valores.map((valor) => {
69
+ const sel = valor === atual;
70
+ return `<div class="ctrl-row"><span><strong>${t(rotulos[valor])}</strong></span>`
71
+ + `<button class="mode-btn${sel ? ' is-on' : ''}" role="radio" aria-checked="${sel}"`
72
+ + ` data-eixo="${eixo}" data-valor="${valor}" type="button">${sel ? t('viz.escolhido') : t('viz.escolher')}</button></div>`;
73
+ }).join('');
74
+ }
75
+ /**
76
+ * Os DOIS eixos, um a seguir ao outro, com um título cada.
77
+ *
78
+ * O título existe porque dois rádios seguidos sem nome são um rádio de oito para quem lê depressa — e essa
79
+ * leitura é exactamente o mal-entendido que a divisão existe para desfazer.
80
+ */
81
+ export function eixosHtml(v, t) {
82
+ return `<h3 class="opt-sub">${t('eixo.tema.titulo')}</h3>`
83
+ + linhasDoEixo('tema', TEMAS, ROTULO_DO_TEMA, v.tema, t)
84
+ + `<h3 class="opt-sub">${t('eixo.correcao.titulo')}</h3>`
85
+ + linhasDoEixo('correcao', CORRECOES, ROTULO_DA_CORRECAO, v.correcao, t);
86
+ }
87
+ export function escolhaDoBotao(dataset) {
88
+ const { eixo, valor } = dataset;
89
+ if (eixo !== 'tema' && eixo !== 'correcao')
90
+ return null;
91
+ if (!valor)
92
+ return null;
93
+ const validos = eixo === 'tema' ? TEMAS : CORRECOES;
94
+ return validos.includes(valor) ? { eixo, valor } : null;
95
+ }
@@ -7,6 +7,7 @@ import { t } from '../core/i18n.js';
7
7
  import { $ } from './dom.js';
8
8
  import { srAlert } from '../core/a11y-sr.js';
9
9
  import { emFracao } from '../input/pointer-space.js'; // #105: um lugar so converte um ponto de tela
10
+ import { carimbarOrigem } from '../input/origem-sintetica.js'; // ADR-0109: a tecla sintetica declara quem a produziu
10
11
  const wg = () => window.webgazer;
11
12
  export let eyeMode = false; // ligado pelo botão; onGaze só age com ele ligado
12
13
  export function setEyeMode(on) { eyeMode = on; }
@@ -16,7 +17,11 @@ function eyeSet(k, on, code) {
16
17
  if (_eyeKeys[k] === on)
17
18
  return;
18
19
  _eyeKeys[k] = on;
19
- const ev = new KeyboardEvent(on ? 'keydown' : 'keyup', { code, bubbles: true });
20
+ // ⚠️ CARIMBADO `'olhos'` ANTES DE DESPACHAR (ADR-0109). Sem esta linha o evento chega ao `input/keydown`
21
+ // indistinguível de uma tecla premida e é carimbado `teclado` — e o efeito não é cosmético: a regra 3 diz
22
+ // que apertar uma tecla devolve o teclado SEM alternância, logo o olhar da criança desligaria, sozinho e em
23
+ // silêncio, a alternância de que ela depende para jogar. É o defeito que o §C desta issue existe para fechar.
24
+ const ev = carimbarOrigem(new KeyboardEvent(on ? 'keydown' : 'keyup', { code, bubbles: true }), 'olhos');
20
25
  window.dispatchEvent(ev);
21
26
  document.dispatchEvent(ev);
22
27
  }
package/docs/CREDITS.md CHANGED
@@ -61,4 +61,25 @@ A narração por voz roda **inteiramente no navegador/offline** graças ao traba
61
61
  `es_MX` (ADR-0065, que supersede o ADR-0022).
62
62
 
63
63
  > Os pesos das vozes são **baixados uma vez** (de um host público) e rodam **100% localmente** depois — nenhum áudio de
64
- > criança sai do dispositivo. Ver `docs/2-Architecture/adr/ADR-0065-three-neural-voices-owned-by-the-engine-and-cached-on-first-use.yaml`.
64
+ > criança sai do dispositivo. Ver `the-inclusionist-docs · docs/2-Architecture/adr/ADR-0065-three-neural-voices-owned-by-the-engine-and-cached-on-first-use.yaml`.
65
+
66
+ ## Arte importada — CC0, CC BY 3.0, CC BY 4.0 ou OGA-BY
67
+
68
+ A atribuição da arte que vem de fora é **por recurso**, e por isso não cabe nesta página em prosa: a autoria de um
69
+ acervo lê-se recurso a recurso, e a cadeia de derivações com ela.
70
+
71
+ **Ela mora em [`../art/ATTRIBUTION.csv`](../art/ATTRIBUTION.csv)** — caminho, autor, **URL da fonte**, licença e de
72
+ que recursos cada um derivou. É a mesma forma que o `LICENSES.md` §4 já usa para as 100 gerações do PixelLab: um
73
+ ficheiro tabular ao lado do apontador em prosa, porque um livro-razão que cresce com o catálogo não se mantém à mão.
74
+
75
+ ⚠️ **Atribuição aqui é condição de uso, não linha de crédito.** Três das quatro licenças exigem-na, e um recurso
76
+ sem autor conhecido **não entra** — «não consegui descobrir» não é licença. O gate
77
+ `tests/arte-licencas-aceites.node.test.js` reprova a entrada vazia, a entrada órfã, o recurso sem entrada, a fonte
78
+ sem URL e **qualquer licença fora das quatro** — com ND, NC e share-alike recusados por nome e com o motivo de
79
+ cada um, que o [`LICENSES.md`](LICENSES.md) §3 e o **ADR-0133** explicam.
80
+
81
+ 📌 **Não há quarentena, e já houve.** Até 2026-09-09 o Liberated Pixel Cup ia entrar sob CC BY-SA 3.0 atrás de uma
82
+ parede (ADR-0107). O Dev recusou o share-alike e a parede saiu com ele: sem arte cujo copyleft viaje, não há o que
83
+ segregar.
84
+
85
+ **Hoje o livro está vazio** — nenhum recurso entrou nunca.