sagadeck 1.7.1 → 1.9.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 (105) hide show
  1. package/README.md +55 -4
  2. package/SKILL.md +10 -3
  3. package/bin/sagadeck.js +23 -0
  4. package/docs/ANDAMENTO.md +483 -1
  5. package/docs/PLANO-MELHORIAS.md +180 -0
  6. package/docs/REFERENCIA.md +184 -105
  7. package/package.json +34 -2
  8. package/src/ai/commands.js +2 -2
  9. package/src/ai/deck-ai.js +14 -5
  10. package/src/ai/ia-check.js +127 -0
  11. package/src/ai/ia-config.js +2 -2
  12. package/src/ai/llm.js +5 -1
  13. package/src/ai/map-analysis-run.js +119 -0
  14. package/src/ai/map-data.js +221 -0
  15. package/src/ai/quality.js +1 -1
  16. package/src/ai/video-generation.js +70 -25
  17. package/src/build.js +51 -15
  18. package/src/elements.js +39 -1
  19. package/src/export/browser.js +23 -1
  20. package/src/export/pptx.js +9 -3
  21. package/src/export/shots.js +10 -3
  22. package/src/fiscal/normalize.js +19 -0
  23. package/src/fs-retry.js +37 -2
  24. package/src/import/office.js +5 -4
  25. package/src/layouts.js +114 -99
  26. package/src/lessons.js +1 -1
  27. package/src/library.js +7 -7
  28. package/src/map-analysis.js +109 -0
  29. package/src/map-config.js +89 -0
  30. package/src/map-routing.js +82 -0
  31. package/src/map-services.js +60 -0
  32. package/src/map.js +274 -0
  33. package/src/mcp/server.js +17 -0
  34. package/src/motion.js +20 -6
  35. package/src/purpose.js +1 -1
  36. package/src/runtime/base.css +105 -329
  37. package/src/runtime/fit.js +3 -2
  38. package/src/runtime/fonts/amatic-sc.css +3 -0
  39. package/src/runtime/fonts/bebas-neue.css +2 -0
  40. package/src/runtime/fonts/cabin-sketch.css +2 -0
  41. package/src/runtime/fonts/fraunces.css +3 -0
  42. package/src/runtime/fonts/gelasio.css +3 -0
  43. package/src/runtime/fonts/ibm-plex-mono.css +4 -0
  44. package/src/runtime/fonts/index.json +10 -1
  45. package/src/runtime/fonts/itim.css +2 -0
  46. package/src/runtime/fonts/montserrat.css +2 -0
  47. package/src/runtime/fonts/space-grotesk.css +2 -0
  48. package/src/runtime/map-background.js +91 -0
  49. package/src/runtime/map.js +266 -0
  50. package/src/runtime/runtime.js +81 -100
  51. package/src/runtime/skins/caderno.css +33 -0
  52. package/src/runtime/skins/cartaz.css +27 -0
  53. package/src/runtime/skins/infografico.css +29 -0
  54. package/src/runtime/skins/lousa.css +22 -0
  55. package/src/runtime/skins/musgo.css +42 -0
  56. package/src/runtime/vendor/LEAFLET-LICENSE.txt +26 -0
  57. package/src/runtime/vendor/MAPLIBRE-LEAFLET-LICENSE.txt +15 -0
  58. package/src/runtime/vendor/MAPLIBRE-LICENSE.txt +116 -0
  59. package/src/runtime/vendor/geo-contornos.json +1 -0
  60. package/src/runtime/vendor/leaflet.css +661 -0
  61. package/src/runtime/vendor/leaflet.js +6 -0
  62. package/src/runtime/vendor/maplibre-gl.css +1 -0
  63. package/src/runtime/vendor/maplibre-gl.js +825 -0
  64. package/src/runtime/vendor/maplibre-leaflet.js +234 -0
  65. package/src/runtime/vendor/maplibre-worker.js +10 -0
  66. package/src/studio/ai-chat-route.js +20 -0
  67. package/src/studio/assets/imagens/tour-mapa-botao.jpg +0 -0
  68. package/src/studio/assets/imagens/tour-mapa-editor.jpg +0 -0
  69. package/src/studio/command-approval.js +2 -3
  70. package/src/studio/demo-decks.js +6 -2
  71. package/src/studio/export-jobs.js +86 -0
  72. package/src/studio/exporting.js +36 -18
  73. package/src/studio/feature-demos.js +144 -0
  74. package/src/studio/geo-routes.js +138 -0
  75. package/src/studio/ia-routes.js +9 -0
  76. package/src/studio/layout-samples.js +78 -16
  77. package/src/studio/library-routes.js +0 -9
  78. package/src/studio/project.js +2 -1
  79. package/src/studio/public/ai-settings.js +6 -3
  80. package/src/studio/public/app.js +46 -52
  81. package/src/studio/public/explorer.js +23 -8
  82. package/src/studio/public/export-status.js +154 -0
  83. package/src/studio/public/index.html +17 -10
  84. package/src/studio/public/library.css +12 -3
  85. package/src/studio/public/library.html +3 -0
  86. package/src/studio/public/library.js +39 -12
  87. package/src/studio/public/map-data.js +158 -0
  88. package/src/studio/public/map-editor.js +676 -0
  89. package/src/studio/public/map-fields.js +21 -0
  90. package/src/studio/public/map-settings.js +86 -0
  91. package/src/studio/public/slide-form.js +19 -16
  92. package/src/studio/public/style.css +58 -0
  93. package/src/studio/public/ui-icons.js +1 -1
  94. package/src/studio/public/viewers.js +2 -1
  95. package/src/studio/public/visual-editor.js +1 -1
  96. package/src/studio/server.js +34 -9
  97. package/src/studio/snapshot.js +8 -4
  98. package/src/studio/template-collections.js +1 -1
  99. package/src/studio/tour.js +158 -0
  100. package/src/themes.js +154 -0
  101. package/templates/cenario-e-ciencia.yaml +1 -21
  102. package/templates/ensaio-api.yaml +3 -3
  103. package/templates/cenario/Texto no cen/303/241rio.yaml" +0 -171
  104. package/templates/cenario/imagens/cidade.svg +0 -30
  105. package/templates/cenario/imagens/pessoa.svg +0 -15
package/src/fs-retry.js CHANGED
@@ -2,18 +2,53 @@
2
2
  // enquanto sincroniza (e também o antivírus ou o indexador), o arquivo fica preso por um instante e o rename falha
3
3
  // com EPERM/EBUSY/EACCES. Em vez de perder o salvamento da pessoa, tenta de novo com espera curta e crescente
4
4
  // (~1 s no total). Qualquer outro erro (destino inexistente, disco cheio…) sobe na hora.
5
+ //
6
+ // Copiar e remover pasta NÃO usam fs.cpSync/fs.rmSync recursivos: o código nativo deles (std::filesystem) no
7
+ // Windows quebra o processo inteiro (0xC0000409, sem exceção para pegar) ou "faz nada" quando o caminho tem
8
+ // acento ou caractere fora do ASCII (nodejs/node#54476, #61878) — "Relatório (cópia)" da biblioteca é o caso da
9
+ // vida real. A cópia/remoção caminha na mão por readdir/copyFile/unlink/rmdir (libuv), que lida com acento, e
10
+ // cada arquivo ainda tolera o travamento de um instante (EPERM/EBUSY/EACCES), como o rename.
5
11
  import fs from "node:fs";
12
+ import path from "node:path";
6
13
 
7
14
  const LOCKED = new Set(["EPERM", "EBUSY", "EACCES"]);
8
15
  export const RENAME_DELAYS = [20, 40, 80, 150, 250, 450];
9
16
 
10
17
  const pause = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
11
18
 
12
- export function renameRetry(from, to, { delays = RENAME_DELAYS } = {}) {
19
+ // tenta de novo enquanto o erro for "travado por um instante"; o resto sobe na hora
20
+ function retry(delays, fn) {
13
21
  for (let i = 0; ; i++) {
14
- try { return fs.renameSync(from, to); } catch (e) {
22
+ try { return fn(); } catch (e) {
15
23
  if (!LOCKED.has(e?.code) || i >= delays.length) throw e;
16
24
  pause(delays[i]);
17
25
  }
18
26
  }
19
27
  }
28
+
29
+ export function renameRetry(from, to, { delays = RENAME_DELAYS } = {}) {
30
+ return retry(delays, () => fs.renameSync(from, to));
31
+ }
32
+
33
+ // arquivo ou pasta inteira (com imagens): duplicar uma apresentação é o caso — no Windows o antivírus segura o
34
+ // arquivo recém-criado por um instante e a cópia falhava com EPERM (era o flake do CI no "mover (pasta inteira…)").
35
+ export function copyRetry(from, to, { delays = RENAME_DELAYS } = {}) {
36
+ const st = fs.statSync(from);
37
+ if (!st.isDirectory()) return retry(delays, () => fs.copyFileSync(from, to));
38
+ fs.mkdirSync(to, { recursive: true });
39
+ for (const e of fs.readdirSync(from, { withFileTypes: true })) {
40
+ copyRetry(path.join(from, e.name), path.join(to, e.name), { delays });
41
+ }
42
+ }
43
+
44
+ // pasta inteira ou arquivo: a lixeira apaga de vez e o import limpa a área de trabalho; no Windows um arquivo
45
+ // travado derrubava a operação e o rmSync recursivo ainda errava com caminho acentuado (apagava nada, em silêncio).
46
+ export function removeRetry(target, { delays = RENAME_DELAYS } = {}) {
47
+ let st;
48
+ try { st = fs.lstatSync(target); } catch (e) { if (e?.code === "ENOENT") return; throw e; }
49
+ if (st.isDirectory()) {
50
+ for (const name of fs.readdirSync(target)) removeRetry(path.join(target, name), { delays });
51
+ return retry(delays, () => fs.rmdirSync(target));
52
+ }
53
+ return retry(delays, () => fs.unlinkSync(target));
54
+ }
@@ -8,6 +8,7 @@ import fs from "node:fs";
8
8
  import os from "node:os";
9
9
  import path from "node:path";
10
10
  import { spawnSync } from "node:child_process";
11
+ import { removeRetry } from "../fs-retry.js";
11
12
 
12
13
  const isWin = process.platform === "win32";
13
14
  const tmpDir = () => fs.mkdtempSync(path.join(os.tmpdir(), "sagadeck-import-"));
@@ -20,7 +21,7 @@ function powershell(script, args, timeout = 600000) {
20
21
  fs.writeFileSync(json, JSON.stringify(args), "utf8");
21
22
  const r = spawnSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", ps1, json], { encoding: "utf8", timeout, windowsHide: true });
22
23
  return { ok: r.status === 0, out: (r.stdout || "").trim(), err: (r.stderr || "").trim() };
23
- } finally { fs.rmSync(dir, { recursive: true, force: true }); }
24
+ } finally { removeRetry(dir); }
24
25
  }
25
26
 
26
27
  let officeCache = null;
@@ -75,7 +76,7 @@ foreach ($p in $a.pairs) {
75
76
  spawnSync(lo, ["--headless", "--convert-to", "png", "--outdir", out, p.src], { timeout: 120000 });
76
77
  const f = fs.readdirSync(out).find((x) => x.endsWith(".png"));
77
78
  if (f) fs.copyFileSync(path.join(out, f), p.dst);
78
- fs.rmSync(out, { recursive: true, force: true });
79
+ removeRetry(out);
79
80
  }
80
81
  return pairs.filter((p) => fs.existsSync(p.dst)).map((p) => p.dst);
81
82
  }
@@ -108,7 +109,7 @@ try {
108
109
  try {
109
110
  const r = powershell(script, { file: copy, out: outDir, width });
110
111
  if (!r.ok) return [];
111
- } finally { fs.rmSync(work, { recursive: true, force: true }); }
112
+ } finally { removeRetry(work); }
112
113
  return fs.readdirSync(outDir).filter((f) => /^slide-\d+\.png$/.test(f)).sort().map((f) => path.join(outDir, f));
113
114
  }
114
115
 
@@ -119,5 +120,5 @@ export function libreOfficeToPdf(file) {
119
120
  const out = tmpDir();
120
121
  spawnSync(lo, ["--headless", "--convert-to", "pdf", "--outdir", out, file], { timeout: 300000 });
121
122
  const f = fs.readdirSync(out).find((x) => x.endsWith(".pdf"));
122
- return f ? { pdf: path.join(out, f), cleanup: () => fs.rmSync(out, { recursive: true, force: true }) } : null;
123
+ return f ? { pdf: path.join(out, f), cleanup: () => removeRetry(out) } : null;
123
124
  }
package/src/layouts.js CHANGED
@@ -12,7 +12,7 @@ import { adaptiveHTML } from "./adaptive-layouts.js";
12
12
  import { iconSVG, listIcons } from "./figures/icons.js";
13
13
  let iconNames = null;
14
14
  const diagramIconNames = () => (iconNames ||= new Set(listIcons()));
15
- import { el, text, figureHTML, imageSize, attrs, SIZES, list, cards, stats, steps, poll, timer, counter, code } from "./elements.js";
15
+ import { el, text, figureHTML, imageSize, attrs, SIZES, list, cards, stats, steps, poll, timer, counter, code, popHTML } from "./elements.js";
16
16
  import { displayCodeLanguage, resolveCodeLanguage } from "./code-language.js";
17
17
  import { mathHTML, plotHTML } from "./science.js";
18
18
  import { infographicHTML, itemColors } from "./infographic.js";
@@ -22,6 +22,7 @@ import { slideSize } from "./aspect.js";
22
22
  import { recreatedFrame } from "./master.js";
23
23
  import { solutionHTML, calcHTML, algoHTML } from "./lessons.js";
24
24
  import "./runtime/api-core.js"; // globalThis.SagadeckApiCore (o mesmo núcleo que roda na apresentação)
25
+ import { mapModel, mapPreviewSVG, mapSummary } from "./map.js";
25
26
 
26
27
  const plainTitle = (s) => String(s.title || s.kicker || s.layout || "").replace(/[*=^~`]/g, "").slice(0, 40);
27
28
  const kicker = (s, d = 0) => (s.kicker ? `<div class="kicker t f-label e" style="--d:${d}">${md(s.kicker)}</div>` : "");
@@ -39,44 +40,11 @@ const codeEditor = (s, { source, highlight = s.highlight, footer = "CÓDIGO · L
39
40
  };
40
41
  // As cenas de aula deixam todo o conteúdo no HTML: funcionam offline e têm resumo estático.
41
42
  const lessonSteps = (items, fallback) => Array.isArray(items) && items.length ? items.map((p) => typeof p === "string" ? { title: p } : (p || {})) : [fallback];
42
- const kineticPhrases = (value) => {
43
- const words = String(value || "").match(/==[^=]+==|\*\*.*?\*\*|\*[^*]+\*|~~.*?~~|`[^`]+`|\[[^\]]+\]\([^)]+\)|\S+/g) || [];
44
- const connectors = new Set(["a", "as", "de", "do", "da", "dos", "das", "e", "em", "no", "na", "nos", "nas", "para", "por", "com", "o", "os"]);
45
- const phrases = [];
46
- while (words.length) {
47
- let take = Math.ceil(words.length / Math.ceil(words.length / 4));
48
- const last = words[take - 1]?.replace(/^[^A-Za-zÀ-ÿ]+|[^A-Za-zÀ-ÿ]+$/g, "").toLowerCase();
49
- if (take > 1 && connectors.has(last)) take--;
50
- phrases.push(words.splice(0, take).join(" "));
51
- }
52
- return phrases;
53
- };
54
43
  const lessonControls = (items) => `<nav class="lesson-controls" aria-label="Etapas da explicação"><button type="button" data-lesson-prev aria-label="Etapa anterior" disabled>${iconSVG("arrow-left", { size: 26, stroke: 2 })}</button><div class="lesson-dots">${items.map((p, i) => `<button type="button" data-lesson-go="${i}" aria-label="Etapa ${i + 1}: ${esc(p.title || "Explicação")}" aria-current="${i === 0 ? "step" : "false"}">${String(i + 1).padStart(2, "0")}</button>`).join("")}</div><button type="button" data-lesson-next aria-label="Próxima etapa" ${items.length === 1 ? "disabled" : ""}>${iconSVG("arrow-right", { size: 26, stroke: 2 })}</button></nav>`;
55
44
  const lessonPanels = (items, output = false) => `<div class="lesson-panels" aria-live="polite" aria-atomic="true">${items.map((p, i) => `<article class="lesson-panel${i === 0 ? " active" : ""}" data-lesson-panel="${i}" data-highlight="${esc(JSON.stringify([].concat(p.highlight || []).filter(Number.isFinite)))}"><div class="lesson-counter f-label">${String(i + 1).padStart(2, "0")} / ${String(items.length).padStart(2, "0")}</div>${text(p.title || `Etapa ${i + 1}`, "h3", { class: "lesson-title", size: 48 })}${p.text ? text(p.text, "body", { class: "lesson-text", size: 32 }) : ""}${output && p.output != null ? `<div class="lesson-output"><div class="lesson-output-label f-label">Saída esperada · simulação</div><pre class="f-mono">${esc(p.output)}</pre></div>` : ""}</article>`).join("")}</div>`;
56
45
  const lessonSummary = (items) => `<div class="lesson-summary">${items.map((p, i) => `<article><div class="f-label lesson-summary-number">${String(i + 1).padStart(2, "0")}</div><div><strong class="f-heading">${md(p.title || `Etapa ${i + 1}`)}</strong>${p.text ? `<p class="f-body">${md(p.text)}</p>` : ""}${p.output != null ? `<pre class="f-mono">${esc(p.output)}</pre>` : ""}</div></article>`).join("")}</div>`;
57
46
  const percent = (value, fallback, min = 0, max = 100) => Number.isFinite(Number(value)) ? Math.max(min, Math.min(max, Number(value))) : fallback;
58
47
 
59
- // Texto no cenário: as composições. label = nome no formulário; size = tamanho padrão do título (titleSize vence);
60
- // props = cenografia extra (só texto e caixas; os desenhos são CSS em base.css, nada de símbolo unicode).
61
- const candles = () => Array.from({ length: 30 }, (_, i) => {
62
- const y = 120 + Math.round(Math.sin(i * 0.55) * 90 + i * 11), h = 40 + ((i * 37) % 90), up = (i * 7) % 3 !== 0;
63
- return `<i class="candle ${up ? 'up' : 'down'}" style="--x:${i * 64}px;--y:${y}px;--h:${h}px"></i>`;
64
- }).join('');
65
- export const SCENES = {
66
- stage: { label: 'Palco e profundidade', size: 230, tone: 'dark' },
67
- floor: { label: 'Chão em perspectiva', size: 230 },
68
- signs: { label: 'Placas na cidade', size: 230, tone: 'dark' },
69
- terminal: { label: 'Terminal hacker', size: 150, tone: 'dark', props: () => `<pre class="scene-code">${Array.from({ length: 34 }, (_, i) => `${(0x7f3a00 + i * 16).toString(16)} ${Array.from({ length: 12 }, (_, j) => ((i * 31 + j * 17) % 256).toString(16).padStart(2, '0')).join(' ')} ${['ssh saga@deck','sudo apresentar --ao-vivo','git push origin ideia','cat segredo.txt | grep plano','nmap -sV palco.local','tail -f plateia.log'][i % 6]}`).join('\n')}</pre><div class="scene-scan"></div>` },
70
- cafe: { label: 'Lousa de café', size: 170, tone: 'dark', props: () => `<div class="scene-neon">aberto</div><ul class="scene-menu"><li><span>espresso</span><span>7</span></li><li><span>coado do dia</span><span>9</span></li><li><span>pão de queijo</span><span>6</span></li><li><span>ideia nova</span><span>grátis</span></li></ul>` },
71
- travel: { label: 'Cartão de embarque', size: 140, props: () => `<div class="scene-pass"><div class="scene-pass-top">CARTÃO DE EMBARQUE</div><dl><div><dt>DE</dt><dd>GRU</dd></div><div><dt>PARA</dt><dd>LIS</dd></div><div><dt>VOO</dt><dd>SG 2026</dd></div><div><dt>PORTÃO</dt><dd>12</dd></div><div><dt>ASSENTO</dt><dd>7A</dd></div><div><dt>EMBARQUE</dt><dd>09:40</dd></div></dl><div class="scene-barcode"></div></div>` },
72
- ticker: { label: 'Pregão financeiro', size: 190, tone: 'dark', props: () => `<div class="scene-tape"><span>IBOV 128.430 <b class="up">+1,24%</b></span><span>USD/BRL 5,02 <b class="down">-0,31%</b></span><span>SAGA3 42,10 <b class="up">+8,90%</b></span><span>IDEIA11 99,00 <b class="up">+12,5%</b></span><span>BTC 312.004 <b class="down">-2,05%</b></span><span>SELIC 10,50%</span><span>IBOV 128.430 <b class="up">+1,24%</b></span></div><div class="scene-candles">${candles()}</div>` },
73
- marquee: { label: 'Letreiro de cinema', size: 150, tone: 'dark', props: () => `<div class="scene-curtain left"></div><div class="scene-curtain right"></div><div class="scene-ticket">SESSÃO 20H</div>` },
74
- blueprint: { label: 'Planta técnica', size: 170, tone: 'accent', props: () => `<div class="scene-dim h"><span>1920</span></div><div class="scene-dim v"><span>1080</span></div><table class="scene-stamp"><tr><td>PROJETO</td><td>SAGADECK</td></tr><tr><td>FOLHA</td><td>01 / 13</td></tr><tr><td>ESCALA</td><td>1:1</td></tr><tr><td>REVISÃO</td><td>A</td></tr></table>` },
75
- magazine: { label: 'Capa de revista', size: 210, props: () => `<div class="scene-masthead">SAGA</div><div class="scene-issue">EDIÇÃO 13 <i></i> SETEMBRO 2026 <i></i> R$ 24,90</div><ul class="scene-lines"><li><b>+10</b> ideias que mudam o jogo</li><li><b>Exclusivo</b> os bastidores do palco</li><li><b>Guia</b> como contar uma história</li></ul><div class="scene-barcode"></div>` },
76
- orbit: { label: 'Órbita', size: 170, tone: 'dark', props: () => `<div class="scene-stars"></div><div class="scene-stars far"></div><div class="scene-hud"><span>LAT -23.55</span><span>LON -46.63</span><span>ALT 408 KM</span><span>V 7,66 KM/S</span></div>` },
77
- synthwave: { label: 'Neon anos 80', size: 190, tone: 'dark', props: () => `<div class="scene-sun"></div><div class="scene-grid"></div><div class="scene-mountains"></div>` },
78
- gallery: { label: 'Parede de galeria', size: 170, props: () => `<div class="scene-spot"></div><div class="scene-plaque"><b>Sem título</b><span>2026. Texto sobre parede.</span><span>Coleção particular</span></div><div class="scene-baseboard"></div>` },
79
- };
80
48
 
81
49
  // No blocks, "rótulo e depois o gráfico" é o título do gráfico, e dois ou mais gráficos seguidos ficam lado a lado:
82
50
  // empilhados, cada um ganhava 200 e poucos px de altura e, mantendo a proporção, saía estreito no meio do slide.
@@ -168,39 +136,119 @@ export const LAYOUTS = {
168
136
  ${s.text ? `<div class="df-rule e" style="--d:2;"></div>${text(s.text, "lead", { class: "df-text e", style: "--d:3;", step: s.build ? 2 : undefined })}` : ""}</div>${fig}</div>${src(s)}${add(s, ctx)}`;
169
137
  },
170
138
 
171
- // Calendário (pedido: "um infográfico em forma de calendário dos próximos filmes"): um cartão por mês com evento, com
172
- // o mini calendário do mês (os dias marcados na cor do evento) e a lista embaixo; o que não tem data vai no cartão
173
- // "sem data". Datas: 2026-12-18, 18/12/2026, 2026-12 (só o mês) ou nada.
139
+ // Calendário (pedido: "um infográfico em forma de calendário dos próximos filmes"). Visões (`view`):
140
+ // - meses: um cartão por mês com o mini calendário (os dias marcados na cor do evento) e a lista embaixo;
141
+ // - mes: um mês grande, como calendário de parede, com os eventos escritos nos dias (muitos eventos num mês);
142
+ // - semana: uma coluna por dia, com o horário (programação de um evento, semana de lançamento);
143
+ // - lista: agenda compacta agrupada por mês, em colunas (muitos meses cheios).
144
+ // Sem `view`, escolhe sozinho: cabe nos cartões, meses; um mês só que não cabe, mes; vários meses cheios, lista
145
+ // (pedido de Narumi: muitos eventos no mesmo mês enchiam o cartão e passavam da altura do slide). `month` (2026-11)
146
+ // escolhe o mês da visão mes; `week` (uma data) a semana da visão semana. Datas: 2026-12-18, 18/12/2026, 2026-12
147
+ // (só o mês) ou nada; hora em `time` ("14:00") ou junto da data ("2026-12-18 14:00").
174
148
  calendar(s, ctx) {
175
149
  const MESES = ["jan", "fev", "mar", "abr", "mai", "jun", "jul", "ago", "set", "out", "nov", "dez"];
150
+ const MESES_LONGOS = ["janeiro", "fevereiro", "março", "abril", "maio", "junho", "julho", "agosto", "setembro", "outubro", "novembro", "dezembro"];
151
+ const DIAS = ["dom", "seg", "ter", "qua", "qui", "sex", "sáb"];
152
+ const warn = (m) => ctx?.warnings?.push(`calendário: ${m}`);
176
153
  const parse = (d) => {
177
- const t = String(d ?? "").trim();
178
- let m = t.match(/^(\d{4})-(\d{1,2})(?:-(\d{1,2}))?$/); if (m) return { y: +m[1], mo: +m[2] - 1, d: m[3] ? +m[3] : null };
179
- m = t.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/); if (m) return { y: +m[3], mo: +m[2] - 1, d: +m[1] };
180
- m = t.match(/^(\d{1,2})\/(\d{4})$/); if (m) return { y: +m[2], mo: +m[1] - 1, d: null };
154
+ const raw = String(d ?? "").trim();
155
+ const hm = raw.match(/[ T](\d{1,2})[:h](\d{2})$/);
156
+ const t = hm ? raw.slice(0, hm.index).trim() : raw;
157
+ const time = hm ? `${hm[1].padStart(2, "0")}:${hm[2]}` : null;
158
+ let m = t.match(/^(\d{4})-(\d{1,2})(?:-(\d{1,2}))?$/); if (m) return { y: +m[1], mo: +m[2] - 1, d: m[3] ? +m[3] : null, time };
159
+ m = t.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/); if (m) return { y: +m[3], mo: +m[2] - 1, d: +m[1], time };
160
+ m = t.match(/^(\d{1,2})\/(\d{4})$/); if (m) return { y: +m[2], mo: +m[1] - 1, d: null, time };
181
161
  return null;
182
162
  };
163
+ const timeOf = (e) => (e.time != null && String(e.time).trim() ? String(e.time).trim().replace("h", ":") : e.at?.time || "");
183
164
  const events = (Array.isArray(s.events) ? s.events : []).filter((e) => e && typeof e === "object").map((e, i) => ({ ...e, i, at: parse(e.date) }));
184
165
  const undated = [...events.filter((e) => !e.at || e.at.mo < 0 || e.at.mo > 11), ...(Array.isArray(s.undated) ? s.undated.map((e) => (typeof e === "string" ? { title: e } : e)) : [])];
166
+ const dated = events.filter((x) => x.at && x.at.mo >= 0 && x.at.mo <= 11).sort((a, b) => a.at.y - b.at.y || a.at.mo - b.at.mo || (a.at.d || 99) - (b.at.d || 99) || timeOf(a).localeCompare(timeOf(b)));
185
167
  const months = new Map();
186
- for (const e of events.filter((x) => x.at && x.at.mo >= 0 && x.at.mo <= 11).sort((a, b) => a.at.y - b.at.y || a.at.mo - b.at.mo || (a.at.d || 99) - (b.at.d || 99))) {
187
- const key = `${e.at.y}-${e.at.mo}`; if (!months.has(key)) months.set(key, { y: e.at.y, mo: e.at.mo, list: [] }); months.get(key).list.push(e);
188
- }
168
+ for (const e of dated) { const key = `${e.at.y}-${e.at.mo}`; if (!months.has(key)) months.set(key, { y: e.at.y, mo: e.at.mo, list: [] }); months.get(key).list.push(e); }
189
169
  const colors = itemColors(ctx?.theme || { colors: {} }, 6).map((h) => `#${h}`); // as do infográfico: do tema, diferentes entre si e com contraste
190
170
  const tagColor = new Map(); const colorOf = (e) => e.color ? (/^#?[0-9a-f]{6}$/i.test(e.color) ? `#${String(e.color).replace("#", "")}` : /^[a-z][\w-]*$/i.test(e.color) ? `var(--${e.color})` : "var(--hi)") : (e.tag ? (tagColor.has(e.tag) || tagColor.set(e.tag, colors[tagColor.size % colors.length]), tagColor.get(e.tag)) : colors[0]);
191
- const card = (m, k) => {
192
- const first = new Date(Date.UTC(m.y, m.mo, 1)).getUTCDay(), days = new Date(Date.UTC(m.y, m.mo + 1, 0)).getUTCDate();
193
- const byDay = new Map(m.list.filter((e) => e.at.d).map((e) => [e.at.d, e]));
194
- const cells = [...Array(first).fill(`<i></i>`), ...Array.from({ length: days }, (_, j) => { const e = byDay.get(j + 1); return e ? `<b style="--c:${colorOf(e)}">${j + 1}</b>` : `<i>${j + 1}</i>`; })].join("");
195
- const items = m.list.map((e) => `<li class="ca-ev" style="--c:${colorOf(e)}"><span class="t f-label ca-day">${e.at.d ? String(e.at.d).padStart(2, "0") : "—"}</span><div class="ca-what">${text(e.title || "", "h3", { class: "ca-title", size: 26 })}${e.text ? text(e.text, "small", { class: "ca-text muted", size: 18 }) : ""}${e.tag ? `<span class="t f-label ca-tag">${md(String(e.tag))}</span>` : ""}</div></li>`).join("");
196
- return `<section class="ca-month e" style="--d:${2 + k};"${s.build ? ` data-step="${k + 1}"` : ""}><header class="ca-head"><span class="t f-display ca-mo">${MESES[m.mo]}</span><span class="t f-label ca-y">${m.y}</span></header><div class="ca-grid" aria-hidden="true">${"DSTQQSS".split("").map((d) => `<u>${d}</u>`).join("")}${cells}</div><ul class="ca-list">${items}</ul></section>`;
197
- };
198
- const list = [...months.values()];
199
- const und = undated.length ? `<section class="ca-month ca-undated e" style="--d:${2 + list.length};"><header class="ca-head"><span class="t f-display ca-mo">${md(String(s.undatedTitle || "sem data"))}</span></header><ul class="ca-list">${undated.map((e) => `<li class="ca-ev" style="--c:var(--muted)"><span class="t f-label ca-day">?</span><div class="ca-what">${text(e.title || "", "h3", { class: "ca-title", size: 24 })}${e.text ? text(e.text, "small", { class: "ca-text muted", size: 17 }) : ""}</div></li>`).join("")}</ul></section>` : "";
200
- const n = list.length + (und ? 1 : 0), cols = s.cols || Math.min(n, n <= 4 ? n : n <= 6 ? 3 : 4);
201
- const cardsHTML = list.map(card).join(""); // antes da legenda: os cartões é que dão a cor de cada tipo
171
+ const step = (k) => (s.build ? ` data-step="${k + 1}"` : "");
172
+ let list = [...months.values()];
173
+ const und = (k) => undated.length ? `<section class="ca-month ca-undated e" style="--d:${2 + k};"><header class="ca-head"><span class="t f-display ca-mo">${md(String(s.undatedTitle || "sem data"))}</span></header><ul class="ca-list">${undated.map((e) => `<li class="ca-ev" style="--c:var(--muted)"><span class="t f-label ca-day">?</span><div class="ca-what">${text(e.title || "", "h3", { class: "ca-title", size: 24 })}${e.text ? text(e.text, "small", { class: "ca-text muted", size: 17 }) : ""}</div></li>`).join("")}</ul></section>` : "";
174
+
175
+ // cabe nos cartões? Medido no navegador: com título, a área dos cartões tem ~698 px (uma fileira); a lista começa
176
+ // ~245 px abaixo do topo do cartão num mês de 5 semanas (27 px a mais por semana); evento ocupa 27 px (54 com detalhe)
177
+ const nCards = list.length + (undated.length ? 1 : 0);
178
+ const cols = s.cols || Math.min(nCards, nCards <= 4 ? nCards : nCards <= 6 ? 3 : 4) || 1;
179
+ const rows = Math.ceil(nCards / cols) || 1;
180
+ const cardH = ((s.title || s.kicker ? 698 : 860) - (rows - 1) * 22) / rows;
181
+ const weeksOf = (m) => Math.ceil((new Date(Date.UTC(m.y, m.mo, 1)).getUTCDay() + new Date(Date.UTC(m.y, m.mo + 1, 0)).getUTCDate()) / 7);
182
+ const need = (m) => m.list.reduce((h, e) => h + (e.text ? 54 : 27), 0) + (m.list.length - 1) * 10;
183
+ const full = list.filter((m) => need(m) > cardH - 18 - 245 - (weeksOf(m) - 5) * 27 + 4);
184
+ let view = ["meses", "mes", "semana", "lista"].includes(s.view) ? s.view : null;
185
+ if (s.view && !view) warn(`visão "${s.view}" não existe (use meses, mes, semana ou lista)`);
186
+ if (!view) view = s.month ? "mes" : !full.length ? "meses" : list.length === 1 ? "mes" : "lista";
187
+ if (view === "meses" && full.length) warn(`${full.map((m) => `${MESES_LONGOS[m.mo]} tem ${m.list.length} eventos`).join(", ")} e não cabe no cartão; use view: mes (no Studio: Um slide por mês) ou view: lista`);
188
+
189
+ const chipOf = (e) => `<span class="cm-ev t f-body" style="--c:${colorOf(e)}">${timeOf(e) ? `<b>${esc(timeOf(e))}</b> ` : ""}${md(String(e.title || ""))}</span>`;
190
+ let body;
191
+ if (view === "mes") {
192
+ // o mês grande: `month` escolhe; senão os meses com evento (até dois lado a lado)
193
+ if (s.month) {
194
+ const want = parse(s.month);
195
+ if (!want) warn(`mês "${s.month}" não é uma data (use 2026-11)`);
196
+ else list = [months.get(`${want.y}-${want.mo}`) || { y: want.y, mo: want.mo, list: [] }];
197
+ }
198
+ if (list.length > 2) { warn(`${list.length} meses na visão de mês: mostrando os dois primeiros; use Um slide por mês (no Studio) ou view: lista`); list = list.slice(0, 2); }
199
+ const big = (m, k) => {
200
+ const first = new Date(Date.UTC(m.y, m.mo, 1)).getUTCDay(), days = new Date(Date.UTC(m.y, m.mo + 1, 0)).getUTCDate();
201
+ const weeks = Math.ceil((first + days) / 7), max = weeks > 5 ? 2 : 3;
202
+ const byDay = new Map(); for (const e of m.list.filter((x) => x.at.d)) (byDay.get(e.at.d) || byDay.set(e.at.d, []).get(e.at.d)).push(e);
203
+ const cells = Array.from({ length: weeks * 7 }, (_, j) => {
204
+ const d = j - first + 1;
205
+ if (d < 1 || d > days) return `<div class="cm-cell out"></div>`;
206
+ const evs = byDay.get(d) || [];
207
+ if (evs.length > max) warn(`dia ${d} de ${MESES_LONGOS[m.mo]} tem ${evs.length} eventos; ${evs.length - max} ficaram no "+ mais" (use view: semana para ver todos)`);
208
+ return `<div class="cm-cell${evs.length ? " has" : ""}"><span class="cm-d t f-label">${d}</span>${evs.slice(0, max).map(chipOf).join("")}${evs.length > max ? `<span class="cm-more t f-label">+ ${evs.length - max} mais</span>` : ""}</div>`;
209
+ }).join("");
210
+ const noDay = m.list.filter((x) => !x.at.d);
211
+ return `<section class="cm-month e" style="--d:${2 + k};--weeks:${weeks}"${step(k)}><header class="ca-head"><span class="t f-display ca-mo">${MESES_LONGOS[m.mo]}</span><span class="t f-label ca-y">${m.y}</span></header>
212
+ <div class="cm-grid">${DIAS.map((d) => `<u class="t f-label">${d}</u>`).join("")}${cells}</div>${noDay.length ? `<div class="cm-noday"><span class="t f-label">no mês</span>${noDay.map(chipOf).join("")}</div>` : ""}</section>`;
213
+ };
214
+ body = `<div class="cm-months" style="--cols:${list.length || 1}">${list.map(big).join("")}${undated.length ? `<div class="cm-side">${und(list.length)}</div>` : ""}</div>`;
215
+ } else if (view === "semana") {
216
+ // semanas (de domingo a sábado) com evento de dia marcado; `week` escolhe uma
217
+ const startOf = (y, mo, d) => { const t = Date.UTC(y, mo, d); return t - new Date(t).getUTCDay() * 864e5; };
218
+ const withDay = dated.filter((e) => e.at.d);
219
+ let starts = [...new Set(withDay.map((e) => startOf(e.at.y, e.at.mo, e.at.d)))].sort((a, b) => a - b);
220
+ if (s.week) { const w = parse(s.week); if (w?.d) starts = [startOf(w.y, w.mo, w.d)]; else warn(`semana "${s.week}" precisa de um dia (2026-11-12)`); }
221
+ if (starts.length > 2) { warn(`${starts.length} semanas com evento: mostrando as duas primeiras; use week: (uma data) num slide por semana, ou view: lista`); starts = starts.slice(0, 2); }
222
+ if (!starts.length) warn("nenhum evento com dia marcado para a visão de semana");
223
+ const busy = (t0, j) => { const day = new Date(t0 + j * 864e5); return withDay.some((e) => e.at.y === day.getUTCFullYear() && e.at.mo === day.getUTCMonth() && e.at.d === day.getUTCDate()); };
224
+ const days = starts.some((t0) => busy(t0, 0) || busy(t0, 6)) ? [0, 1, 2, 3, 4, 5, 6] : [1, 2, 3, 4, 5]; // fim de semana vazio sai
225
+ const week = (t0, k) => `<section class="cw-week e" style="--d:${2 + k};--days:${days.length}"${step(k)}>${days.map((j) => {
226
+ const day = new Date(t0 + j * 864e5), y = day.getUTCFullYear(), mo = day.getUTCMonth(), d = day.getUTCDate();
227
+ const evs = withDay.filter((e) => e.at.y === y && e.at.mo === mo && e.at.d === d);
228
+ return `<div class="cw-day${evs.length ? " has" : ""}"><header class="cw-h"><span class="t f-label">${DIAS[j]}</span><b class="t f-display">${d}</b><span class="t f-label cw-mo">${MESES[mo]}</span></header>
229
+ ${evs.map((e) => `<div class="cw-ev" style="--c:${colorOf(e)}">${timeOf(e) ? `<span class="t f-label cw-t">${esc(timeOf(e))}</span>` : ""}${text(e.title || "", "h3", { class: "cw-title", size: 22 })}${e.text ? text(e.text, "small", { class: "cw-text muted", size: 16 }) : ""}${e.tag ? `<span class="t f-label ca-tag">${md(String(e.tag))}</span>` : ""}</div>`).join("")}</div>`;
230
+ }).join("")}</section>`;
231
+ body = `<div class="cw-weeks" style="--rows:${starts.length || 1}">${starts.map(week).join("")}</div>${undated.length ? `<div class="cm-noday"><span class="t f-label">${md(String(s.undatedTitle || "sem data"))}</span>${undated.map((e) => chipOf({ ...e, color: "muted" })).join("")}</div>` : ""}`;
232
+ } else if (view === "lista") {
233
+ // agenda: os meses em sequência, em colunas; a letra diminui com a quantidade
234
+ const total = dated.length + undated.length;
235
+ const ncol = s.cols || (total > 24 ? 3 : total > 10 ? 2 : 1);
236
+ if (total > 48) warn(`${total} eventos numa lista só ficam pequenos demais; divida em dois slides (no Studio: Um slide por mês)`);
237
+ const fs = total > 36 ? 15 : total > 20 ? 17 : total > 10 ? 20 : 24;
238
+ const row = (e) => `<li class="cl-ev" style="--c:${colorOf(e)}"><span class="cl-d"><b class="t f-display">${e.at?.d ? String(e.at.d).padStart(2, "0") : "—"}</b>${e.at?.d ? `<small class="t f-label">${DIAS[new Date(Date.UTC(e.at.y, e.at.mo, e.at.d)).getUTCDay()]}</small>` : ""}</span><div class="cl-what"><span class="cl-title t f-body">${timeOf(e) ? `<b class="cl-t">${esc(timeOf(e))}</b> ` : ""}${md(String(e.title || ""))}</span>${e.text ? `<small class="cl-text t f-body muted">${md(String(e.text))}</small>` : ""}</div>${e.tag ? `<span class="t f-label ca-tag">${md(String(e.tag))}</span>` : ""}</li>`;
239
+ body = `<div class="cl-list" style="--ncol:${ncol};--cal-fs:${fs}px">${list.map((m, k) => `<section class="cl-month e" style="--d:${2 + k};"${step(k)}><header class="cl-h"><span class="t f-display">${MESES_LONGOS[m.mo]}</span><span class="t f-label ca-y">${m.y}</span></header><ul>${m.list.map(row).join("")}</ul></section>`).join("")}${undated.length ? `<section class="cl-month e" style="--d:${2 + list.length};"><header class="cl-h"><span class="t f-display">${md(String(s.undatedTitle || "sem data"))}</span></header><ul>${undated.map((e) => row({ ...e, color: "muted" })).join("")}</ul></section>` : ""}</div>`;
240
+ } else {
241
+ const card = (m, k) => {
242
+ const first = new Date(Date.UTC(m.y, m.mo, 1)).getUTCDay(), days = new Date(Date.UTC(m.y, m.mo + 1, 0)).getUTCDate();
243
+ const byDay = new Map(m.list.filter((e) => e.at.d).map((e) => [e.at.d, e]));
244
+ const cells = [...Array(first).fill(`<i></i>`), ...Array.from({ length: days }, (_, j) => { const e = byDay.get(j + 1); return e ? `<b style="--c:${colorOf(e)}">${j + 1}</b>` : `<i>${j + 1}</i>`; })].join("");
245
+ const items = m.list.map((e) => `<li class="ca-ev" style="--c:${colorOf(e)}"><span class="t f-label ca-day">${e.at.d ? String(e.at.d).padStart(2, "0") : "—"}</span><div class="ca-what">${text(e.title || "", "h3", { class: "ca-title", size: 26 })}${e.text ? text(e.text, "small", { class: "ca-text muted", size: 18 }) : ""}${e.tag ? `<span class="t f-label ca-tag">${md(String(e.tag))}</span>` : ""}</div></li>`).join("");
246
+ return `<section class="ca-month e" style="--d:${2 + k};"${step(k)}><header class="ca-head"><span class="t f-display ca-mo">${MESES[m.mo]}</span><span class="t f-label ca-y">${m.y}</span></header><div class="ca-grid" aria-hidden="true">${"DSTQQSS".split("").map((d) => `<u>${d}</u>`).join("")}${cells}</div><ul class="ca-list">${items}</ul></section>`;
247
+ };
248
+ body = `<div class="ca-months" style="--cols:${cols};">${list.map(card).join("")}${und(list.length)}</div>`;
249
+ }
202
250
  const legend = tagColor.size > 1 ? `<div class="ca-legend">${[...tagColor].map(([t, c]) => `<span class="t f-label" style="--c:${c}"><i></i>${md(String(t))}</span>`).join("")}</div>` : "";
203
- return `<div class="L-calendar">${head(s)}${legend}<div class="ca-months" style="--cols:${cols};">${cardsHTML}${und}</div></div>${src(s)}${add(s, ctx)}`;
251
+ return `<div class="L-calendar" data-view="${view}">${head(s)}${legend}${body}</div>${src(s)}${add(s, ctx)}`;
204
252
  },
205
253
 
206
254
  // Pôster (infográfico de uma página, como uma figura de revista científica): ilustração principal, painéis com letra
@@ -392,7 +440,7 @@ export const LAYOUTS = {
392
440
  const body = ev.map((e, i) => `<div${attrs({ step: e.step ?? build(s, i) }, `tl-ev ${hl.has(i) ? "hl" : ""}`)}>
393
441
  <div class="tl-dot"></div><div class="tl-when t f-display">${md(e.when)}</div>
394
442
  <div class="tl-title t f-heading">${md(e.title || "")}</div>${e.text ? `<div class="tl-text t f-body">${md(e.text)}</div>` : ""}
395
- ${e.tag ? `<div class="tl-tag t f-label">${md(e.tag)}</div>` : ""}</div>`).join("");
443
+ ${e.tag ? `<div class="tl-tag t f-label">${md(e.tag)}</div>` : ""}${e.more ? popHTML(e.more, "above") : ""}</div>`).join("");
396
444
  return `<div class="L-timeline">${head(s)}<div class="tl" style="--n:${ev.length}"><div class="tl-line"></div>${body}</div>${s.after ? text(s.after, "h3", { class: "tl-after", step: s.afterStep ?? (s.build ? ev.length + 1 : 1), face: "quote", size: 48 }) : ""}</div>${src(s)}${add(s, ctx)}`;
397
445
  },
398
446
 
@@ -488,42 +536,6 @@ export const LAYOUTS = {
488
536
  return `<div class="L-spotlight" data-lesson="spotlight" data-lesson-count="${spots.length}">${head(s)}<div class="lesson-row"><div class="spotlight-visual"><div class="spotlight-canvas"><div class="spotlight-zoom"><div class="spotlight-image">${el(visual, ctx, 1100, 660)}</div><div class="spotlight-regions">${regions}</div></div></div>${s.caption ? text(s.caption, "small", { class: "spotlight-caption", size: 24 }) : ""}</div><aside class="lesson-aside">${lessonPanels(spots)}${lessonSummary(spots)}${lessonControls(spots)}</aside></div></div>${src(s)}${add(s, ctx)}`;
489
537
  },
490
538
 
491
- kinetic(s, ctx) {
492
- const beats = Array.isArray(s.beats) && s.beats.length
493
- ? s.beats.map((beat) => typeof beat === "string" ? { text: beat } : beat || {})
494
- : [
495
- ...kineticPhrases(s.title || s.text),
496
- ...kineticPhrases(s.subtitle),
497
- ].map((text, i) => ({
498
- text, style: "clean", position: "left", size: "medium",
499
- ...(i === 0 && s.kicker ? { tag: s.kicker } : {}),
500
- }));
501
- if (!beats.length) beats.push({ text: "Uma ideia em movimento", style: "clean", position: "left", color: "white", size: "large" });
502
- const frames = beats.map((beat, i) => {
503
- const style = ["clean", "poster", "editorial", "outline", "marker"].includes(beat.style) ? beat.style : "clean";
504
- const position = ["left", "center", "right", "top", "bottom"].includes(beat.position) ? beat.position : "left";
505
- const color = ["white", "gold", "pink", "cyan"].includes(beat.color) ? beat.color : "white";
506
- const size = ["small", "medium", "large"].includes(beat.size) ? beat.size : "medium";
507
- return `<article class="kinetic-frame${i === 0 ? " active" : ""}" data-lesson-panel="${i}" data-position="${position}" data-style="${style}" data-color="${color}" data-size="${size}" aria-hidden="${i !== 0}">
508
- <div class="kinetic-word" data-fit>${md(beat.text || "")}</div>
509
- ${beat.tag ? `<div class="kinetic-tag">${md(beat.tag)}</div>` : ""}
510
- </article>`;
511
- }).join("");
512
- const scene = s.figure ? el(s.figure, ctx, 1920, slideSize(ctx.spec).h) : "";
513
- const interval = Math.max(450, Math.min(5000, Number(s.interval) || 1000));
514
- const autoplay = s.autoplay !== false && beats.length > 1;
515
- const summary = `<div class="kinetic-summary" aria-label="Frases da sequência">${beats.map((beat) => `<span>${md(beat.text || "")}</span>`).join("")}</div>`;
516
- return `<div class="L-kinetic" data-lesson="kinetic" data-lesson-count="${beats.length}" data-kinetic-interval="${interval}" data-kinetic-autoplay="${autoplay}">
517
- <div class="kinetic-scene" aria-hidden="true">${scene}</div><div class="kinetic-shade" aria-hidden="true"></div>
518
- <div class="kinetic-grain" aria-hidden="true"></div>
519
- <div class="kinetic-sequence" aria-live="polite" aria-atomic="true">${frames}</div>
520
- <div class="kinetic-controls"><button type="button" class="kinetic-toggle" data-kinetic-toggle aria-pressed="${autoplay}" aria-label="${autoplay ? "Pausar" : "Reproduzir"} sequência"${beats.length < 2 ? " disabled" : ""}>${autoplay ? "Pausar" : "Reproduzir"}</button>
521
- <nav class="kinetic-dots" aria-label="Frases da sequência">${beats.map((beat, i) => `<button type="button" data-lesson-go="${i}" aria-label="Frase ${i + 1}: ${esc(beat.text || "")}" aria-current="${i === 0 ? "step" : "false"}">${String(i + 1).padStart(2, "0")}</button>`).join("")}</nav>
522
- <button type="button" class="kinetic-next" data-lesson-next aria-label="Próxima frase">${iconSVG("arrow-right", { size: 26, stroke: 2 })}</button>
523
- </div>${summary}
524
- </div>${add(s, ctx)}`;
525
- },
526
-
527
539
  blocks(s, ctx) {
528
540
  return `<div class="L-blocks">${head(s, s.titleAs || "h2")}<div class="bl-body">${el(chartsSideBySide(s.content || []), ctx)}</div>${s.caption ? text(s.caption, "small", { class: "bl-caption muted" }) : ""}</div>${src(s)}${add(s, ctx)}`;
529
541
  },
@@ -733,13 +745,16 @@ export const LAYOUTS = {
733
745
  calc(s, ctx) { return `${calcHTML(s, ctx, head)}${src(s)}${add(s, ctx)}`; },
734
746
  algo(s, ctx) { return `${algoHTML(s, ctx, head)}${src(s)}${add(s, ctx)}`; },
735
747
 
748
+ // Mapa: camadas de planilha, GeoJSON, GPX, KML ou valores por estado/país (src/map.js), desenhadas ao vivo pelo
749
+ // Leaflet só quando o slide aparece (src/runtime/map.js). A prévia em SVG fica por baixo (miniaturas, reserva).
750
+ // Cada camada com passo ganha um marcador invisível data-step: o clique que a revela é o mesmo dos outros slides.
751
+ map(s, ctx) {
752
+ const m = mapModel(s, ctx);
753
+ const steps = m.layers.map((l, i) => (l.step ? `<i class="map-step" data-step="${l.step}" data-layer="${i}" hidden></i>` : "")).join("");
754
+ const data = JSON.stringify(m).replace(/</g, "\\u003c");
755
+ return `<div class="L-map">${head(s)}<div class="map-box e" style="--d:2" data-basemap="${m.basemap}" aria-label="Mapa: ${esc(mapSummary(m))}">${mapPreviewSVG(m)}<div class="map-live"></div><script type="application/json" class="map-data">${data}</script>${steps}</div>${s.caption ? text(s.caption, "small", { class: "map-caption muted" }) : ""}</div>${src(s)}${add(s, ctx)}`;
756
+ },
736
757
  science(s, ctx) {
737
758
  return `<div class="L-science">${head(s)}<div class="science-body${s.plot === false ? ' equations-only' : !(s.equations || []).length ? ' plot-only' : ''}"><div class="science-equations">${mathHTML(s.equations || [])}</div>${s.plot === false ? '' : plotHTML(s.plot || {}, ctx)}</div>${s.caption ? text(s.caption,'small') : ''}</div>${add(s,ctx)}`;
738
759
  },
739
- scenography(s, ctx) {
740
- const scene = Object.hasOwn(SCENES, s.scene) ? s.scene : 'stage';
741
- const op = s.imageOpacity == null || s.imageOpacity === '' ? 0.85 : Math.max(0, Math.min(1, Number(s.imageOpacity) || 0));
742
- const props = SCENES[scene].props?.() || '';
743
- return `<div class="L-scenography raster scene-${scene}"><div class="scene-atmosphere"></div>${s.image ? `<div class="scene-image" style="opacity:${op}">${el({image:s.image},ctx,1920,slideSize(ctx.spec).h)}</div>` : ''}${props ? `<div class="scene-props" aria-hidden="true">${props}</div>` : ''}<div class="scene-architecture" aria-hidden="true"><i></i><i></i><i></i><i></i><i></i></div><div class="scene-type"><div class="scene-eyebrow t f-label">${md(s.kicker || 'IDEIAS QUE OCUPAM ESPAÇO')}</div>${text(s.title || 'ALÉM DO\nÓBVIO','hero',{class:'scene-title',fit:true,size:s.titleSize || SCENES[scene].size})}${s.subtitle ? text(s.subtitle,'lead',{class:'scene-subtitle'}) : ''}</div>${s.foreground ? `<div class="scene-foreground">${el({image:s.foreground,fit:'contain'},ctx,1920,1080)}</div>` : '<div class="scene-sculpture" aria-hidden="true"><i></i><b></b></div>'}<div class="scene-caption f-label">${esc(s.caption || 'SAGA / NOVAS PERSPECTIVAS')}</div></div>${add(s,ctx)}`;
744
- },
745
760
  };
package/src/lessons.js CHANGED
@@ -34,7 +34,7 @@ function givenTex(g) {
34
34
  if (g.latex) return g.latex;
35
35
  const value = typeof g.value === "string" && isProse(g.value) ? `\\text{${g.value.replace(/[{}\\$]/g, "")}}` : commaTex(g.value);
36
36
  // % (e # & _ $) são comandos no LaTeX: "60 %" saía como erro vermelho na conta da criança
37
- const unit = g.unit ? String(g.unit).replace(/[%#&_$]/g, (c) => `\\${c}`).replace(/°/g, "^{\\circ}").replace(/ /g, "\\,") : "";
37
+ const unit = g.unit ? String(g.unit).replace(/(?<!\\)[%#&_$]/g, (c) => `\\${c}`).replace(/°/g, "^{\\circ}").replace(/ /g, "\\,") : "";
38
38
  return `${g.symbol ?? ""} = ${value}${unit ? `\\ \\mathrm{${unit}}` : ""}`;
39
39
  }
40
40
  export function solutionHTML(s, ctx, head) {
package/src/library.js CHANGED
@@ -14,7 +14,7 @@
14
14
  //
15
15
  // O id de uma apresentação é o caminho do .yaml dela, relativo à biblioteca, com "/" (ex.: "Palestras/X/X.yaml").
16
16
  import fs from "node:fs";
17
- import { renameRetry } from "./fs-retry.js";
17
+ import { renameRetry, copyRetry, removeRetry } from "./fs-retry.js";
18
18
  import os from "node:os";
19
19
  import { execFileSync } from "node:child_process";
20
20
  import path from "node:path";
@@ -228,7 +228,7 @@ export function openLibrary(root, { trashDays } = {}) {
228
228
  const dir = topicDir(id);
229
229
  const left = fs.readdirSync(dir).filter((n) => !hidden(n));
230
230
  if (left.length) throw new Error("o tópico não está vazio: mova ou exclua as apresentações antes");
231
- fs.rmSync(dir, { recursive: true, force: true });
231
+ removeRetry(dir);
232
232
  }
233
233
 
234
234
  // ---- apresentações ----
@@ -291,7 +291,7 @@ export function openLibrary(root, { trashDays } = {}) {
291
291
  const title = `${info.title} (cópia)`;
292
292
  const ext = unit === file ? path.extname(file) : "";
293
293
  const to = uniquePath(path.dirname(unit), safeName(title), ext);
294
- fs.cpSync(unit, to, { recursive: true });
294
+ copyRetry(unit, to);
295
295
  let yaml = unit === file ? to : path.join(to, path.basename(file));
296
296
  if (unit !== file && path.basename(file).replace(/\.ya?ml$/i, "") === path.basename(unit)) {
297
297
  const renamed = path.join(to, path.basename(to) + path.extname(file));
@@ -404,11 +404,11 @@ export function openLibrary(root, { trashDays } = {}) {
404
404
  const ext = isFile ? path.extname(o.unit) : "";
405
405
  const to = uniquePath(dest, path.basename(o.unit, ext), ext);
406
406
  renameRetry(path.join(slot, o.unit), to);
407
- fs.rmSync(slot, { recursive: true, force: true });
407
+ removeRetry(slot);
408
408
  return idOf(isFile ? to : path.join(to, o.yaml));
409
409
  }
410
410
  function purgeDeck(slotId) {
411
- fs.rmSync(path.join(root, TRASH, path.basename(slotId)), { recursive: true, force: true });
411
+ removeRetry(path.join(root, TRASH, path.basename(slotId)));
412
412
  }
413
413
  // esvaziar a lixeira: tudo de uma vez (a pessoa confirma antes, no Studio)
414
414
  function emptyTrash() {
@@ -484,7 +484,7 @@ export function openLibrary(root, { trashDays } = {}) {
484
484
  if (!src.startsWith(path.resolve(fromDir)) || !fs.existsSync(src)) continue;
485
485
  const dst = path.join(dir, ...rel.split("/"));
486
486
  fs.mkdirSync(path.dirname(dst), { recursive: true });
487
- fs.copyFileSync(src, dst);
487
+ copyRetry(src, dst);
488
488
  }
489
489
  fs.writeFileSync(path.join(dir, "estilo.yaml"), YAML.stringify(style, { indent: 2, lineWidth: 0 }));
490
490
  return { id, name: style.name };
@@ -505,7 +505,7 @@ export function openLibrary(root, { trashDays } = {}) {
505
505
  const to = `imagens/estilo/${slug(id)}/${path.basename(rel)}`;
506
506
  const dst = path.join(deckDir, ...to.split("/"));
507
507
  fs.mkdirSync(path.dirname(dst), { recursive: true });
508
- fs.copyFileSync(src, dst);
508
+ copyRetry(src, dst);
509
509
  map.set(rel, to);
510
510
  }
511
511
  const master = structuredClone(style.master);
@@ -0,0 +1,109 @@
1
+ // Análises do mapa (entrega 5): contas que o CÓDIGO faz e a IA só narra. Sem rede (as que precisam de serviço,
2
+ // como rota com tempo e área alcançável, ficam em src/map-routing.js). Coordenadas em [lat, lon]; GeoJSON em
3
+ // [lon, lat]. Distâncias na esfera (haversine), boas para cidade e região.
4
+ import { distance } from "./map-routing.js";
5
+
6
+ const R = 6371008.8, rad = Math.PI / 180;
7
+
8
+ // ponto mais próximo de cada ponto de A, entre os pontos de B: [{ i, j, metros }]
9
+ export function nearest(A, B) {
10
+ return A.map((a, i) => {
11
+ let best = -1, d = Infinity;
12
+ B.forEach((b, j) => { const x = distance(a, b); if (x < d) { d = x; best = j; } });
13
+ return { i, j: best, metros: Number.isFinite(d) ? d : null };
14
+ });
15
+ }
16
+
17
+ // quantos pontos ficam a até `metros` de um centro
18
+ export const withinRadius = (points, center, metros) => points.map((p, i) => [i, distance(p, center)]).filter(([, d]) => d <= metros).map(([i]) => i);
19
+
20
+ // ponto dentro de polígono (anéis GeoJSON [lon, lat]; o primeiro é o contorno, os outros são buracos)
21
+ function inRing([lat, lon], ring) {
22
+ let inside = false;
23
+ for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
24
+ const [xi, yi] = ring[i], [xj, yj] = ring[j];
25
+ if ((yi > lat) !== (yj > lat) && lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi) inside = !inside;
26
+ }
27
+ return inside;
28
+ }
29
+ export function inPolygon(p, geometry) {
30
+ const polys = geometry.type === "Polygon" ? [geometry.coordinates] : geometry.type === "MultiPolygon" ? geometry.coordinates : [];
31
+ return polys.some((rings) => inRing(p, rings[0]) && !rings.slice(1).some((h) => inRing(p, h)));
32
+ }
33
+
34
+ // distância de um ponto a um segmento (metros), numa projeção local equiretangular (boa até dezenas de km)
35
+ function toXY([lat, lon], lat0) { return [lon * rad * R * Math.cos(lat0 * rad), lat * rad * R]; }
36
+ export function pointSegment(p, a, b) {
37
+ const lat0 = p[0], [px, py] = toXY(p, lat0), [ax, ay] = toXY(a, lat0), [bx, by] = toXY(b, lat0);
38
+ const dx = bx - ax, dy = by - ay, L = dx * dx + dy * dy;
39
+ const t = L ? Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / L)) : 0;
40
+ return Math.hypot(px - (ax + t * dx), py - (ay + t * dy));
41
+ }
42
+ // distância de um ponto a uma linha GeoJSON (LineString ou MultiLineString)
43
+ export function pointLine(p, geometry) {
44
+ const lines = geometry.type === "LineString" ? [geometry.coordinates] : geometry.type === "MultiLineString" ? geometry.coordinates : [];
45
+ let d = Infinity;
46
+ for (const l of lines) for (let k = 1; k < l.length; k++) d = Math.min(d, pointSegment(p, [l[k - 1][1], l[k - 1][0]], [l[k][1], l[k][0]]));
47
+ return d;
48
+ }
49
+
50
+ // círculo como polígono (para raio e para a faixa): n vértices
51
+ export function circle([lat, lon], metros, n = 32) {
52
+ const ring = [];
53
+ for (let k = 0; k <= n; k++) {
54
+ const a = (2 * Math.PI * k) / n;
55
+ ring.push([lon + (metros * Math.sin(a)) / (R * Math.cos(lat * rad)) / rad, lat + (metros * Math.cos(a)) / R / rad]);
56
+ }
57
+ return { type: "Polygon", coordinates: [ring] };
58
+ }
59
+
60
+ // faixa de `metros` em volta de uma linha, para DESENHAR: retângulos dos trechos e círculos nos vértices (MultiPolygon).
61
+ // Os pedaços se sobrepõem: a feature leva `_faixa: true` e o mapa a desenha sem contorno (uma mancha só).
62
+ // Para CONTAR o que está dentro, use pointLine (exato).
63
+ export function lineBuffer(geometry, metros) {
64
+ const lines = geometry.type === "LineString" ? [geometry.coordinates] : geometry.type === "MultiLineString" ? geometry.coordinates : [];
65
+ const polys = [];
66
+ for (const l of lines) {
67
+ for (let k = 0; k < l.length; k++) {
68
+ polys.push(circle([l[k][1], l[k][0]], metros, 16).coordinates);
69
+ if (!k) continue;
70
+ const [lon1, lat1] = l[k - 1], [lon2, lat2] = l[k];
71
+ const lat0 = (lat1 + lat2) / 2, kx = 1 / (R * Math.cos(lat0 * rad)) / rad, ky = 1 / R / rad;
72
+ const dx = (lon2 - lon1) / kx, dy = (lat2 - lat1) / ky, len = Math.hypot(dx, dy) || 1;
73
+ const ox = (-dy / len) * metros * kx, oy = (dx / len) * metros * ky;
74
+ polys.push([[[lon1 + ox, lat1 + oy], [lon2 + ox, lat2 + oy], [lon2 - ox, lat2 - oy], [lon1 - ox, lat1 - oy], [lon1 + ox, lat1 + oy]]]);
75
+ }
76
+ }
77
+ // todos no mesmo sentido (anti-horário): onde os pedaços se sobrepõem, a área preenche uma vez só, sem buraco
78
+ const ccw = (ring) => { let a = 0; for (let i = 0; i < ring.length - 1; i++) a += ring[i][0] * ring[i + 1][1] - ring[i + 1][0] * ring[i][1]; return a >= 0 ? ring : [...ring].reverse(); };
79
+ return { type: "MultiPolygon", coordinates: polys.map(([ring]) => [ccw(ring)]) };
80
+ }
81
+
82
+ // "onde instalar o próximo": os `quantos` lugares mais longe de todos os existentes (e uns dos outros), dentro da área
83
+ // (polígono) ou do retângulo que cobre os existentes. Grade + escolha gulosa; devolve [{ lat, lon, metros }] com a
84
+ // distância até o existente (ou sugerido) mais próximo.
85
+ export function suggestSites(existing, { area = null, quantos = 3, grade = 40 } = {}) {
86
+ let s = 90, w = 180, n = -90, e = -180;
87
+ const add = ([lat, lon]) => { s = Math.min(s, lat); n = Math.max(n, lat); w = Math.min(w, lon); e = Math.max(e, lon); };
88
+ if (area) { const walk = (c) => (typeof c[0] === "number" ? add([c[1], c[0]]) : c.forEach(walk)); walk(area.coordinates); } else existing.forEach(add);
89
+ if (s > n) return [];
90
+ // pontos alinhados (todos na mesma latitude ou longitude): o retângulo ganha a metade do outro lado de folga
91
+ if (!area) { const padLat = n - s < 1e-9 ? Math.max((e - w) / 2, 0.005) : 0, padLon = e - w < 1e-9 ? Math.max((n - s) / 2, 0.005) : 0; s -= padLat; n += padLat; w -= padLon; e += padLon; }
92
+ if (!(n > s) || !(e > w)) return [];
93
+ const cand = [];
94
+ for (let i = 0; i < grade; i++) for (let j = 0; j < grade; j++) {
95
+ const p = [s + ((n - s) * (i + 0.5)) / grade, w + ((e - w) * (j + 0.5)) / grade];
96
+ if (!area || inPolygon(p, area)) cand.push(p);
97
+ }
98
+ const chosen = [], base = [...existing];
99
+ for (let k = 0; k < quantos && cand.length; k++) {
100
+ let best = null, bd = -1;
101
+ for (const c of cand) { const d = base.length ? Math.min(...base.map((b) => distance(c, b))) : Infinity; if (d > bd) { bd = d; best = c; } }
102
+ if (!best) break;
103
+ chosen.push({ lat: best[0], lon: best[1], metros: Number.isFinite(bd) ? bd : null });
104
+ base.push(best);
105
+ }
106
+ return chosen;
107
+ }
108
+
109
+ export const fmtMetros = (m) => (m == null ? "?" : m >= 1000 ? `${(m / 1000).toLocaleString("pt-BR", { maximumFractionDigits: 1 })} km` : `${Math.round(m)} m`);