sagadeck 1.2.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 (233) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +305 -0
  3. package/SKILL.md +148 -0
  4. package/bin/sagadeck.js +511 -0
  5. package/docs/ANDAMENTO.md +1255 -0
  6. package/docs/EXPERIENCIAS.md +39 -0
  7. package/docs/PLANO-EXPERIENCIAS.md +39 -0
  8. package/docs/PLANO-TESTE-AULAS-TECNICAS.md +44 -0
  9. package/docs/PLANO-TESTE-DESINFORMACAO.md +38 -0
  10. package/docs/REFERENCIA.md +1131 -0
  11. package/package.json +59 -0
  12. package/src/adaptive-layouts.js +18 -0
  13. package/src/ai/art-direction.js +8 -0
  14. package/src/ai/commands.js +130 -0
  15. package/src/ai/context.js +245 -0
  16. package/src/ai/deck-ai.js +1135 -0
  17. package/src/ai/directions.js +74 -0
  18. package/src/ai/document-materials.js +49 -0
  19. package/src/ai/document-visuals.js +215 -0
  20. package/src/ai/evaluation.js +31 -0
  21. package/src/ai/ground.js +55 -0
  22. package/src/ai/llm.js +292 -0
  23. package/src/ai/quality.js +51 -0
  24. package/src/ai/relay.js +45 -0
  25. package/src/ai/staged-generation.js +24 -0
  26. package/src/ai/transform.js +858 -0
  27. package/src/ai/usage.js +17 -0
  28. package/src/ai/variety.js +80 -0
  29. package/src/ai/video-frames.js +24 -0
  30. package/src/ai/video-generation.js +126 -0
  31. package/src/ai/web-operation.js +31 -0
  32. package/src/algo-catalog.js +153 -0
  33. package/src/algo-trace.js +139 -0
  34. package/src/api-client.js +514 -0
  35. package/src/api-collections.js +14 -0
  36. package/src/api-demo.js +222 -0
  37. package/src/aspect.js +40 -0
  38. package/src/build.js +323 -0
  39. package/src/carousel.js +56 -0
  40. package/src/chrome.js +65 -0
  41. package/src/code-highlight.js +68 -0
  42. package/src/code-lab-browser.js +22 -0
  43. package/src/code-lab.js +3 -0
  44. package/src/code-language.js +52 -0
  45. package/src/csv.js +74 -0
  46. package/src/decision-lab.js +20 -0
  47. package/src/deck-file.js +119 -0
  48. package/src/diagram/napkin-examples.js +13 -0
  49. package/src/diagram/napkin.js +539 -0
  50. package/src/docx.js +128 -0
  51. package/src/dynamics/git-sim.js +200 -0
  52. package/src/dynamics/layouts.js +112 -0
  53. package/src/elements.js +554 -0
  54. package/src/experiences.js +124 -0
  55. package/src/exploration.js +31 -0
  56. package/src/export/browser.js +53 -0
  57. package/src/export/estudo.js +67 -0
  58. package/src/export/pptx.js +475 -0
  59. package/src/export/roteiro.js +40 -0
  60. package/src/export/shots.js +182 -0
  61. package/src/figures/charts.js +383 -0
  62. package/src/figures/diagrams.js +117 -0
  63. package/src/figures/icons.js +166 -0
  64. package/src/figures/material-pictos.js +20 -0
  65. package/src/figures/pictos.js +255 -0
  66. package/src/figures/points.js +106 -0
  67. package/src/figures/qr.js +29 -0
  68. package/src/figures/ufmap.js +48 -0
  69. package/src/fiscal/autofix.js +377 -0
  70. package/src/fiscal/normalize.js +115 -0
  71. package/src/graph-lab.js +24 -0
  72. package/src/hologram.js +30 -0
  73. package/src/identity.js +86 -0
  74. package/src/import/crop.js +59 -0
  75. package/src/import/index.js +132 -0
  76. package/src/import/legacy-word.js +42 -0
  77. package/src/import/merge.js +45 -0
  78. package/src/import/office.js +123 -0
  79. package/src/import/omml.js +69 -0
  80. package/src/import/pdf-render.js +56 -0
  81. package/src/import/pptx.js +701 -0
  82. package/src/import/xml.js +42 -0
  83. package/src/infographic.js +401 -0
  84. package/src/layouts.js +723 -0
  85. package/src/lessons.js +171 -0
  86. package/src/library.js +399 -0
  87. package/src/markup.js +116 -0
  88. package/src/master.js +214 -0
  89. package/src/mcp/server.js +371 -0
  90. package/src/meeting.js +42 -0
  91. package/src/motion.js +56 -0
  92. package/src/package.js +116 -0
  93. package/src/playground.js +19 -0
  94. package/src/portal-scene.js +20 -0
  95. package/src/preferences.js +61 -0
  96. package/src/protect.js +51 -0
  97. package/src/purpose.js +24 -0
  98. package/src/pytrace.js +1092 -0
  99. package/src/research/duckduckgo.js +73 -0
  100. package/src/research/research.js +202 -0
  101. package/src/research/svg-image.js +17 -0
  102. package/src/research/visuals.js +52 -0
  103. package/src/runtime/api-collection-ui.js +5 -0
  104. package/src/runtime/api-collections.js +28 -0
  105. package/src/runtime/api-core.js +530 -0
  106. package/src/runtime/api-ui.js +966 -0
  107. package/src/runtime/base.css +1774 -0
  108. package/src/runtime/calc.js +139 -0
  109. package/src/runtime/code-lab.js +371 -0
  110. package/src/runtime/decision-lab.js +49 -0
  111. package/src/runtime/diagram.js +341 -0
  112. package/src/runtime/fit.js +268 -0
  113. package/src/runtime/fonts/caveat.css +2 -0
  114. package/src/runtime/fonts/comic-neue.css +3 -0
  115. package/src/runtime/fonts/fredoka.css +2 -0
  116. package/src/runtime/fonts/index.json +10 -0
  117. package/src/runtime/fonts/inter.css +2 -0
  118. package/src/runtime/fonts/patrick-hand.css +2 -0
  119. package/src/runtime/fonts/plus-jakarta-sans.css +2 -0
  120. package/src/runtime/fonts/press-start-2p.css +2 -0
  121. package/src/runtime/fonts/vt323.css +2 -0
  122. package/src/runtime/formula.js +148 -0
  123. package/src/runtime/graph-lab.js +72 -0
  124. package/src/runtime/meeting.css +7 -0
  125. package/src/runtime/meeting.js +10 -0
  126. package/src/runtime/motion.js +15 -0
  127. package/src/runtime/playground.js +11 -0
  128. package/src/runtime/points.js +19 -0
  129. package/src/runtime/portal-scene.css +6 -0
  130. package/src/runtime/portal-scene.js +22 -0
  131. package/src/runtime/runtime.js +827 -0
  132. package/src/runtime/science.js +89 -0
  133. package/src/runtime/skins/aluminio.css +31 -0
  134. package/src/runtime/skins/arcade.css +25 -0
  135. package/src/runtime/skins/aurora.css +17 -0
  136. package/src/runtime/skins/bauhaus.css +19 -0
  137. package/src/runtime/skins/editorial.css +19 -0
  138. package/src/runtime/skins/jornal.css +17 -0
  139. package/src/runtime/skins/manual-noite.css +14 -0
  140. package/src/runtime/skins/manual.css +14 -0
  141. package/src/runtime/skins/noite.css +21 -0
  142. package/src/runtime/skins/oceano.css +17 -0
  143. package/src/runtime/skins/pop.css +23 -0
  144. package/src/runtime/skins/prata.css +18 -0
  145. package/src/runtime/skins/prisma.css +28 -0
  146. package/src/runtime/skins/rabisco.css +18 -0
  147. package/src/runtime/skins/relevo.css +33 -0
  148. package/src/runtime/skins/sinal.css +16 -0
  149. package/src/runtime/skins/terminal.css +23 -0
  150. package/src/runtime/vendor/KATEX-LICENSE.txt +21 -0
  151. package/src/runtime/vendor/MERMAID-LICENSE.txt +21 -0
  152. package/src/runtime/vendor/PLOTLY-LICENSE.txt +21 -0
  153. package/src/runtime/vendor/katex.css +1 -0
  154. package/src/runtime/vendor/mermaid.min.js +3636 -0
  155. package/src/runtime/vendor/plotly.min.js +3620 -0
  156. package/src/runtime/video-player.js +35 -0
  157. package/src/science.js +162 -0
  158. package/src/studio/ai-response.js +36 -0
  159. package/src/studio/api-routes.js +152 -0
  160. package/src/studio/assets/contexto/leia-me.md +12 -0
  161. package/src/studio/assets/contexto/vendas.csv +7 -0
  162. package/src/studio/assets/imagens/colecao-arquitetura.png +0 -0
  163. package/src/studio/assets/imagens/colecao-cor.png +0 -0
  164. package/src/studio/assets/imagens/colecao-equipe.png +0 -0
  165. package/src/studio/assets/imagens/lavanda-cover.jpg +0 -0
  166. package/src/studio/code-routes.js +32 -0
  167. package/src/studio/command-approval.js +48 -0
  168. package/src/studio/demo-decks.js +98 -0
  169. package/src/studio/direction-routes.js +29 -0
  170. package/src/studio/errors.js +65 -0
  171. package/src/studio/exploration-demo.js +17 -0
  172. package/src/studio/exporting.js +105 -0
  173. package/src/studio/generation-report.js +10 -0
  174. package/src/studio/instance.js +48 -0
  175. package/src/studio/layout-samples.js +152 -0
  176. package/src/studio/project.js +371 -0
  177. package/src/studio/public/api-collections.js +28 -0
  178. package/src/studio/public/app.js +5265 -0
  179. package/src/studio/public/art-preview.js +10 -0
  180. package/src/studio/public/calc-fields.js +11 -0
  181. package/src/studio/public/chat-progress.js +47 -0
  182. package/src/studio/public/developer-fields.js +22 -0
  183. package/src/studio/public/explorer.js +443 -0
  184. package/src/studio/public/history.js +58 -0
  185. package/src/studio/public/index.html +867 -0
  186. package/src/studio/public/inspector.js +270 -0
  187. package/src/studio/public/lab-fields.js +5 -0
  188. package/src/studio/public/library.css +275 -0
  189. package/src/studio/public/library.html +55 -0
  190. package/src/studio/public/library.js +471 -0
  191. package/src/studio/public/merge-decks.js +155 -0
  192. package/src/studio/public/review-ui.js +100 -0
  193. package/src/studio/public/screenshot-editor.js +140 -0
  194. package/src/studio/public/share-ui.js +72 -0
  195. package/src/studio/public/slide-clipboard.js +23 -0
  196. package/src/studio/public/slide-form.js +1263 -0
  197. package/src/studio/public/studio-awareness.js +154 -0
  198. package/src/studio/public/studio-next.css +303 -0
  199. package/src/studio/public/style.css +1262 -0
  200. package/src/studio/public/ui-icons.js +2 -0
  201. package/src/studio/public/viewers.js +372 -0
  202. package/src/studio/public/visual-editor.js +326 -0
  203. package/src/studio/public/workspace.js +18 -0
  204. package/src/studio/public-files.js +1 -0
  205. package/src/studio/request-helpers.js +41 -0
  206. package/src/studio/server.js +1583 -0
  207. package/src/studio/share-routes.js +146 -0
  208. package/src/studio/slide-copy-routes.js +26 -0
  209. package/src/studio/slide-copy.js +30 -0
  210. package/src/studio/snapshot.js +160 -0
  211. package/src/studio/style-routes.js +103 -0
  212. package/src/studio/template-collections.js +97 -0
  213. package/src/studio/versions.js +95 -0
  214. package/src/studio/video-download.js +29 -0
  215. package/src/studio/visual-keys.js +52 -0
  216. package/src/studio/workspaces.js +39 -0
  217. package/src/table.js +93 -0
  218. package/src/templates/scaffold.js +372 -0
  219. package/src/themes.js +604 -0
  220. package/src/trace-view.js +450 -0
  221. package/src/uid.js +19 -0
  222. package/src/video-player.js +40 -0
  223. package/src/visual-edits.js +99 -0
  224. package/src/ws.js +134 -0
  225. package/templates/cenario/Texto no cen/303/241rio.yaml" +171 -0
  226. package/templates/cenario/imagens/cidade.svg +30 -0
  227. package/templates/cenario/imagens/pessoa.svg +15 -0
  228. package/templates/cenario-e-ciencia.yaml +47 -0
  229. package/templates/ensaio-api.yaml +160 -0
  230. package/templates/exemplo-alegre.yaml +75 -0
  231. package/templates/exemplo-keynote.yaml +90 -0
  232. package/templates/exemplo-napkin.yaml +139 -0
  233. package/templates/exemplo.yaml +228 -0
@@ -0,0 +1,222 @@
1
+ // Uma API de mentira para ensaiar e testar o slide "api" sem nenhum serviço de verdade
2
+ // (`sagadeck ensaio-api` e a suíte de testes). Genérica de propósito: não imita nenhum serviço real.
3
+ // POST /identity client_id + client_secret -> JWT em { data: { token } }
4
+ // POST /v1/upload multipart -> { path_id } POST /v1/ocr { path_id } ou { content: base64 } -> { text }
5
+ // POST /v1/embeddings { input } -> vetor POST /v1/index (falha com mode "rows" + chunk_size > 0)
6
+ // POST /v1/tts { text } -> áudio WAV POST /v1/stt multipart -> { text }
7
+ // POST /token client_id + client_secret -> { access_token, expires_in }
8
+ // POST /v1/sync responde na hora: { choices: [{ message: { content: "eco: <pergunta>" } }] }
9
+ // POST /v1/start inicia um workflow: { executionId }
10
+ // GET /v1/status/:id STARTED -> RUNNING -> ... -> FINISHED (ou ERROR se workflow = "falha"),
11
+ // no fim com { responses: [ { step, output } ] }
12
+ // POST /v1/stream SSE: data: {"choices":[{"delta":{"content":"..."}}]} ... data: [DONE]
13
+ // GET /v1/headers devolve os cabeçalhos recebidos (para conferir o token)
14
+ // GET /v1/vaza devolve o token no corpo (para conferir a máscara)
15
+ // Tudo menos /token exige "Authorization: Bearer <token válido>".
16
+ import http from "node:http";
17
+ import YAML from "yaml";
18
+ import { acceptWs } from "./ws.js";
19
+
20
+ export async function startMockApi({ clientId = "id-teste", clientSecret = "segredo-teste-123", statuses = ["STARTED", "RUNNING", "RUNNING", "FINISHED"], ttlSeconds = 1800, format = "json" } = {}) {
21
+ const state = { tokens: new Set(), tokensIssued: 0, polls: {}, lastHeaders: null, requests: [] };
22
+ const words = ["Olá", ", ", "isto ", "chegou ", "aos ", "poucos."];
23
+ const json = (res, code, obj) => { res.writeHead(code, { "Content-Type": "application/json" }); res.end(JSON.stringify(obj)); };
24
+ const readBody = (req) => new Promise((r) => { let b = ""; req.on("data", (c) => (b += c)); req.on("end", () => r(b)); });
25
+
26
+ const server = http.createServer(async (req, res) => {
27
+ const raw = await readBody(req);
28
+ const url = new URL(req.url, "http://x");
29
+ state.requests.push({ method: req.method, path: url.pathname, headers: req.headers, body: raw });
30
+ if (url.pathname === "/token" && req.method === "POST") {
31
+ const b = format === "form" || /urlencoded/.test(req.headers["content-type"] || "") ? Object.fromEntries(new URLSearchParams(raw)) : JSON.parse(raw || "{}");
32
+ if (b.client_id !== clientId || b.client_secret !== clientSecret) return json(res, 401, { error: "credenciais inválidas" });
33
+ const t = `tok-${++state.tokensIssued}-${Math.random().toString(36).slice(2, 10)}`;
34
+ state.tokens.add(t);
35
+ return json(res, 200, { access_token: t, expires_in: ttlSeconds, token_type: "Bearer" });
36
+ }
37
+ // "Identity": gera um JWT de verdade (cabeçalho.conteúdo.assinatura), válido nesta API
38
+ if (url.pathname === "/identity" && req.method === "POST") {
39
+ const b = JSON.parse(raw || "{}");
40
+ if (b.client_id !== clientId || b.client_secret !== clientSecret) return json(res, 401, { error: "credenciais inválidas" });
41
+ const b64 = (o) => Buffer.from(JSON.stringify(o)).toString("base64url");
42
+ const t = `${b64({ alg: "HS256", typ: "JWT" })}.${b64({ sub: clientId, scope: "llm ocr", exp: Math.floor(Date.now() / 1000) + 1800 })}.assinatura${++state.tokensIssued}XyZw`;
43
+ state.tokens.add(t);
44
+ return json(res, 200, { data: { token: t }, expires_in: 1800 });
45
+ }
46
+ // eco: devolve o que recebeu; é o que o slide de API genérico (Inserir, Slide de API) chama no ambiente ENSAIO
47
+ if (url.pathname === "/v1/echo") {
48
+ let body = raw;
49
+ try { body = JSON.parse(raw || "null"); } catch {}
50
+ return json(res, 200, { metodo: req.method, recebido: body, consulta: Object.fromEntries(url.searchParams) });
51
+ }
52
+ const auth = (req.headers.authorization || "").replace(/^Bearer /, "");
53
+ if (!state.tokens.has(auth)) return json(res, 401, { error: "token inválido ou vencido" });
54
+ state.lastHeaders = req.headers;
55
+
56
+ if (url.pathname === "/v1/sync") {
57
+ const b = JSON.parse(raw || "{}");
58
+ const q = b.messages?.at?.(-1)?.content ?? b.q ?? "";
59
+ return json(res, 200, { id: "r1", choices: [{ message: { role: "assistant", content: `eco: ${q}` } }], usage: { total_tokens: 7 } });
60
+ }
61
+ if (url.pathname === "/v1/start" && req.method === "POST") {
62
+ const b = JSON.parse(raw || "{}");
63
+ const id = `exe-${Object.keys(state.polls).length + 1}`;
64
+ state.polls[id] = { n: 0, fail: b.workflow === "falha" };
65
+ return json(res, 202, { executionId: id, message: "aceito" });
66
+ }
67
+ const m = url.pathname.match(/^\/v1\/status\/(.+)$/);
68
+ if (m) {
69
+ const p = state.polls[m[1]];
70
+ if (!p) return json(res, 404, { error: "execução não existe" });
71
+ const seq = p.fail ? statuses.slice(0, -1).concat("ERROR") : statuses;
72
+ const status = seq[Math.min(p.n++, seq.length - 1)];
73
+ const done = status === seq.at(-1);
74
+ return json(res, 200, { status, ...(done && !p.fail ? { responses: [{ step: "ocr", output: "texto extraído do PDF" }, { step: "llm", output: "resumo em 3 linhas" }] } : {}), ...(done && p.fail ? { error: "o OCR não leu o arquivo" } : {}) });
75
+ }
76
+ if (url.pathname === "/v1/stream") {
77
+ res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache" });
78
+ for (const w of words) {
79
+ res.write(`data: ${JSON.stringify({ choices: [{ delta: { content: w } }] })}\n\n`);
80
+ await new Promise((r) => setTimeout(r, 40));
81
+ }
82
+ res.end("data: [DONE]\n\n");
83
+ return;
84
+ }
85
+ // "FileManager": recebe multipart e devolve o path_id; "OCR": por path_id ou por base64
86
+ if (url.pathname === "/v1/upload" && req.method === "POST") {
87
+ const m2 = raw.match(/filename="([^"]+)"\r\nContent-Type: ([^\r]+)\r\n\r\n([\s\S]*?)\r\n--/);
88
+ if (!/^multipart\/form-data; boundary=/.test(req.headers["content-type"] || "") || !m2) return json(res, 400, { error: "esperava multipart com um arquivo" });
89
+ const id = `store/${m2[1]}`;
90
+ state.files = { ...(state.files || {}), [id]: m2[3] };
91
+ return json(res, 200, { path_id: id, name: m2[1], type: m2[2], size: Buffer.byteLength(m2[3]) });
92
+ }
93
+ if (url.pathname === "/v1/ocr" && req.method === "POST") {
94
+ const b = JSON.parse(raw || "{}");
95
+ const content = b.path_id ? state.files?.[b.path_id] : b.content ? Buffer.from(b.content, "base64").toString("utf8") : null;
96
+ if (content == null) return json(res, 400, { error: "mande path_id ou content (base64)" });
97
+ return json(res, 200, { text: `lido: ${content}`, via: b.path_id ? "path_id" : "base64" });
98
+ }
99
+ // "Embeddings": vetor de 64 números a partir das palavras (frases com palavras em comum ficam parecidas)
100
+ if (url.pathname === "/v1/embeddings" && req.method === "POST") {
101
+ const b = JSON.parse(raw || "{}");
102
+ const v = new Array(64).fill(0);
103
+ for (const w of String(b.input || "").toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "").match(/[a-z]+/g) || []) {
104
+ let h = 7; for (const ch of w) h = (h * 31 + ch.charCodeAt(0)) % 9973;
105
+ v[h % 64] += 1; v[(h * 7) % 64] -= 0.3;
106
+ }
107
+ return json(res, 200, { object: "list", data: [{ embedding: v, index: 0 }], model: b.model || "emb-teste" });
108
+ }
109
+ // "TTS": devolve um WAV de verdade (um tom de 0,4 s); "STT": recebe o áudio (multipart) e "transcreve"
110
+ if (url.pathname === "/v1/tts" && req.method === "POST") {
111
+ const rate = 8000, n = Math.floor(rate * 0.4), data = Buffer.alloc(n * 2);
112
+ for (let i = 0; i < n; i++) data.writeInt16LE(Math.round(Math.sin((2 * Math.PI * 440 * i) / rate) * 8000), i * 2);
113
+ const h = Buffer.alloc(44);
114
+ h.write("RIFF", 0); h.writeUInt32LE(36 + data.length, 4); h.write("WAVE", 8); h.write("fmt ", 12); h.writeUInt32LE(16, 16);
115
+ h.writeUInt16LE(1, 20); h.writeUInt16LE(1, 22); h.writeUInt32LE(rate, 24); h.writeUInt32LE(rate * 2, 28); h.writeUInt16LE(2, 32); h.writeUInt16LE(16, 34);
116
+ h.write("data", 36); h.writeUInt32LE(data.length, 40);
117
+ res.writeHead(200, { "Content-Type": "audio/wav" });
118
+ return res.end(Buffer.concat([h, data]));
119
+ }
120
+ if (url.pathname === "/v1/stt" && req.method === "POST") {
121
+ const m3 = raw.match(/filename="([^"]+)"\r\nContent-Type: ([^\r]+)/);
122
+ if (!m3) return json(res, 400, { error: "esperava o áudio num multipart" });
123
+ return json(res, 200, { text: `transcrição de ${m3[1]} (${m3[2]})` });
124
+ }
125
+ // "Indexador": pdf em pedaços ou planilha linha a linha; a combinação errada falha (como na vida real)
126
+ if (url.pathname === "/v1/index" && req.method === "POST") {
127
+ const b = JSON.parse(raw || "{}");
128
+ if (!b.path_id) return json(res, 400, { error: "path_id é obrigatório" });
129
+ if (b.mode === "rows" && Number(b.chunk_size) > 0) return json(res, 422, { error: "indexação falhou: chunk_size não se aplica a mode=rows" });
130
+ if (b.mode === "rows" && !(b.sheet && b.sheet.header_row)) return json(res, 422, { error: "indexação falhou: mode=rows exige sheet.header_row" });
131
+ const content = state.files?.[b.path_id] || "";
132
+ const n = b.mode === "rows" ? Math.max(1, content.split("\n").filter(Boolean).length - 1) : Math.max(1, Math.ceil(content.length / (Number(b.chunk_size) || 800)));
133
+ return json(res, 200, { status: "indexed", mode: b.mode || "pdf", documents: n, index: b.index || "workshop" });
134
+ }
135
+ if (url.pathname === "/v1/headers") return json(res, 200, { recebidos: req.headers });
136
+ if (url.pathname === "/v1/vaza") return json(res, 200, { debug: `seu token é ${auth}` });
137
+ json(res, 404, { error: "não existe" });
138
+ });
139
+ // "Conversa em tempo real" (WebSocket em /v1/realtime), no formato mais comum dessas APIs:
140
+ // session.update → session.updated; input_audio_buffer.append/commit → transcrição do que foi "falado";
141
+ // conversation.item.create (texto) + response.create → texto em pedaços + áudio em pedaços + response.done.
142
+ const sockets = new Set();
143
+ server.on("upgrade", (req, socket) => {
144
+ sockets.add(socket);
145
+ socket.on("close", () => sockets.delete(socket));
146
+ const u = new URL(req.url, "http://x");
147
+ const tok = (req.headers.authorization || "").replace(/^Bearer /, "") || u.searchParams.get("token") || "";
148
+ if (u.pathname !== "/v1/realtime") { socket.end("HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n"); return; }
149
+ if (!state.tokens.has(tok)) { socket.end("HTTP/1.1 401 Unauthorized\r\nContent-Length: 25\r\n\r\ntoken inválido ou vencido"); return; }
150
+ const ws = acceptWs(req, socket);
151
+ state.realtime = { sessions: (state.realtime?.sessions || 0) + 1, received: [] };
152
+ let audioBytes = 0, lastText = null;
153
+ const send = (o) => ws.send(JSON.stringify(o));
154
+ const tone = (ms, rate = 24000) => { const n = Math.floor((rate * ms) / 1000), b = Buffer.alloc(n * 2); for (let i = 0; i < n; i++) b.writeInt16LE(Math.round(Math.sin((2 * Math.PI * 330 * i) / rate) * 6000), i * 2); return b.toString("base64"); };
155
+ ws.on("message", async (m) => {
156
+ let ev;
157
+ try { ev = JSON.parse(m); } catch { return send({ type: "error", error: { message: "mande JSON" } }); }
158
+ state.realtime.received.push(ev.type);
159
+ if (ev.type === "session.update") return send({ type: "session.updated", session: ev.session || {} });
160
+ if (ev.type === "input_audio_buffer.append") { audioBytes += Buffer.from(String(ev.audio || ""), "base64").length; return; }
161
+ if (ev.type === "input_audio_buffer.commit") {
162
+ const secs = (audioBytes / 2 / 24000).toFixed(1).replace(".", ",");
163
+ send({ type: "input_audio_buffer.committed" });
164
+ send({ type: "conversation.item.input_audio_transcription.completed", transcript: `(você falou por ${secs} s)` });
165
+ lastText = `ouvi ${secs} segundos de áudio`;
166
+ audioBytes = 0;
167
+ return;
168
+ }
169
+ if (ev.type === "conversation.item.create") { lastText = ev.item?.content?.[0]?.text || ""; return; }
170
+ if (ev.type === "response.create") {
171
+ const words = `Oi! Você disse: ${lastText || "nada"}.`.split(/(?<= )/);
172
+ for (const w of words) { send({ type: "response.audio_transcript.delta", delta: w }); await new Promise((r) => setTimeout(r, 25)); }
173
+ for (let i = 0; i < 4; i++) send({ type: "response.audio.delta", delta: tone(120) });
174
+ return send({ type: "response.done" });
175
+ }
176
+ send({ type: "error", error: { message: `tipo desconhecido: ${ev.type}` } });
177
+ });
178
+ });
179
+ await new Promise((r) => server.listen(0, "127.0.0.1", r));
180
+ const closeServer = server.close.bind(server);
181
+ server.close = (cb) => { sockets.forEach((s) => s.destroy()); return closeServer(cb); };
182
+ const url = `http://127.0.0.1:${server.address().port}`;
183
+ return {
184
+ url, state, clientId, clientSecret,
185
+ // expira todos os tokens (simula os 30 minutos passando)
186
+ expireTokens: () => state.tokens.clear(),
187
+ close: () => new Promise((r) => server.close(r)),
188
+ };
189
+ }
190
+
191
+ // O ambiente do ensaio: tudo o que o deck de exemplo (templates/ensaio-api.yaml) usa, apontando para a API de mentira.
192
+ // O Studio local o oferece como o ambiente embutido "ensaio"; o `sagadeck ensaio-api` grava como dev e hom.
193
+ export function demoEnv(mock) {
194
+ return {
195
+ vars: { base: `${mock.url}/v1`, ws: `${mock.url.replace("http", "ws")}/v1`, identity: `${mock.url}/identity`, client_id: mock.clientId, wf: "resumo" },
196
+ token: { url: `${mock.url}/token`, client_id: mock.clientId, client_secret: mock.clientSecret, ttl_minutes: 30 },
197
+ secrets: { client_secret: mock.clientSecret },
198
+ };
199
+ }
200
+
201
+ // arquivos que o deck de exemplo espera ao lado dele (file: contrato.txt)
202
+ export const DEMO_FILES = { "contrato.txt": "Cláusula 1: prazo de 30 dias.\nCláusula 2: multa de 2%.\n" };
203
+
204
+ // ambientes.yaml do ensaio: dois ambientes (dev e hom) na API de mentira, com o segredo do Identity em secrets:
205
+ export function demoEnvFile(mock) {
206
+ const env = demoEnv(mock);
207
+ return `# Ambientes do ensaio (API de mentira em ${mock.url}). Os seus ficam em ~/.sagadeck/ambientes.yaml.\n`
208
+ + YAML.stringify({ current: "hom", environments: { dev: env, hom: env } }, { aliasDuplicateObjects: false });
209
+ }
210
+
211
+ // ambientes.yaml apontando para a API de mentira
212
+ export function envFileFor(mock, extra = "") {
213
+ return `current: hom
214
+ environments:
215
+ dev:
216
+ vars: { base: "${mock.url}/v1" }
217
+ token: { url: "${mock.url}/token", client_id: "${mock.clientId}", client_secret: "${mock.clientSecret}", ttl_minutes: 30 }
218
+ hom:
219
+ vars: { base: "${mock.url}/v1", ws: "${mock.url.replace("http", "ws")}/v1", wf: "resumo" }
220
+ token: { url: "${mock.url}/token", client_id: "${mock.clientId}", client_secret: "${mock.clientSecret}" }
221
+ ${extra}`;
222
+ }
package/src/aspect.js ADDED
@@ -0,0 +1,40 @@
1
+ // Proporção do slide (deck.aspect): 16:9 (padrão), 4:3, 16:10, 1:1, 9:16 (retrato) ou qualquer L:A.
2
+ // A largura lógica é sempre 1920 px; a altura acompanha a proporção (4:3 → 1920 × 1440). Os layouts crescem na vertical
3
+ // sozinhos; o que tem posição livre (layout canvas, ajustes do Studio) é reescalado por convertAspect.
4
+ export const SLIDE_W = 1920;
5
+ export const ASPECTS = { "16:9": [16, 9], "4:3": [4, 3], "16:10": [16, 10], "1:1": [1, 1], "9:16": [9, 16], "3:2": [3, 2], "21:9": [21, 9], "a4": [297, 210] };
6
+
7
+ // "4:3" | "4/3" | "4x3" | 1.333 | { w, h } | { width, height } → [w, h] (ou null se não entender)
8
+ export function parseAspect(a) {
9
+ if (a == null || a === "") return [16, 9];
10
+ if (typeof a === "object") { const w = Number(a.w ?? a.width), h = Number(a.h ?? a.height); return w > 0 && h > 0 ? [w, h] : null; }
11
+ if (typeof a === "number") return a > 0 ? [a, 1] : null;
12
+ const s = String(a).trim().toLowerCase().replace(",", ".");
13
+ if (ASPECTS[s]) return ASPECTS[s];
14
+ const m = s.match(/^(\d+(?:\.\d+)?)\s*[:/x×]\s*(\d+(?:\.\d+)?)$/);
15
+ if (m && +m[1] > 0 && +m[2] > 0) return [+m[1], +m[2]];
16
+ if (/^\d+(\.\d+)?$/.test(s) && +s > 0) return [+s, 1];
17
+ return null;
18
+ }
19
+
20
+ // tamanho lógico do slide do deck: { w: 1920, h, ratio: "4 / 3", label: "4:3" }
21
+ export function slideSize(spec = {}) {
22
+ const [w, h] = parseAspect(spec.aspect) || [16, 9];
23
+ const H = Math.round(Math.min(3600, Math.max(540, (SLIDE_W * h) / w))); // entre 32:9 e retrato alto
24
+ return { w: SLIDE_W, h: H, ratio: `${SLIDE_W} / ${H}`, label: `${w}:${h}` };
25
+ }
26
+
27
+ // Trocar a proporção sem refazer à mão: posições livres (layout canvas em px de 1920 × altura) e deslocamentos
28
+ // verticais do Studio (visualEdits.dy) acompanham a nova altura. Devolve um deck novo (o original não muda).
29
+ export function convertAspect(spec, aspect) {
30
+ const from = slideSize(spec).h, to = slideSize({ aspect }).h, k = to / from;
31
+ const out = JSON.parse(JSON.stringify(spec));
32
+ out.aspect = aspect;
33
+ if (k === 1) return out;
34
+ const scaleY = (o) => { if (!o || typeof o !== "object") return; for (const key of ["y", "h", "height"]) if (typeof o[key] === "number") o[key] = Math.round(o[key] * k); };
35
+ for (const s of out.slides || []) {
36
+ if (s.layout === "canvas") [].concat(s.elements || s.items || []).forEach(scaleY);
37
+ for (const e of Object.values(s.visualEdits || {})) if (typeof e?.dy === "number") e.dy = Math.round(e.dy * k);
38
+ }
39
+ return out;
40
+ }
package/src/build.js ADDED
@@ -0,0 +1,323 @@
1
+ // YAML -> HTML (arquivo único, abre com duplo clique, funciona offline)
2
+ import {meetingSpec} from './meeting.js';
3
+ import { slideSize } from "./aspect.js";
4
+ import { wordLimit } from "./purpose.js";
5
+ import { barHTML } from "./chrome.js";
6
+ import { masterApplies, masterElements, masterCSS, isCoverLayout, titleBand } from "./master.js";
7
+ import fs from "node:fs";
8
+ import path from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+ import YAML from "yaml";
11
+ import { resolveTheme, themeCSS, scopedThemeCSS } from "./themes.js";
12
+ import { identityFor, identitiesFile } from "./identity.js";
13
+ import { LAYOUTS, SCENES } from "./layouts.js";
14
+ import { applyVisualEdits } from "./visual-edits.js";
15
+ import { el, imageSrc } from "./elements.js";
16
+ import { iconSVG } from "./figures/icons.js";
17
+ import { esc, notesHTML, plain, md, INLINE_MATH } from "./markup.js";
18
+ import { normalizeSpec } from "./fiscal/normalize.js";
19
+ import { readRecordings } from "./api-client.js";
20
+ import { explorationStates } from './exploration.js';
21
+ import { calcModel } from './lessons.js';
22
+
23
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
24
+ const read = (p) => fs.readFileSync(path.join(HERE, p), "utf8");
25
+
26
+ export function loadSpec(file) {
27
+ const src = fs.readFileSync(file, "utf8");
28
+ let raw;
29
+ try { raw = YAML.parse(src); } catch (e) { throw new Error(`YAML inválido em ${file}:\n${e.message}`); }
30
+ const spec = normalizeSpec(raw);
31
+ if (!spec || !Array.isArray(spec.slides)) throw new Error(`${file}: o YAML precisa de uma lista "slides:"`);
32
+ spec._file = path.resolve(file);
33
+ spec._dir = path.dirname(spec._file);
34
+ return spec;
35
+ }
36
+
37
+ const drawIcon = (name, size = 20) => iconSVG(name, { size, stroke: 2 }).replace(/\s+/g, " ");
38
+ const slug = (s) => String(s || "deck").normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40) || "deck";
39
+
40
+ export function inferLayout(s) {
41
+ if (s.layout) return s.layout;
42
+ if (s.elements) return "canvas";
43
+ if (s.stats || s.kpis) return "stats";
44
+ if (s.steps || s.process || s.flow) return "steps";
45
+ if (s.quote) return "quote";
46
+ if (s.value != null) return "number";
47
+ if (s.question && s.options) return "question";
48
+ if (s.events) return "timeline";
49
+ if (s.chart) return "chart";
50
+ if (s.cells) return "matrix";
51
+ if (s.text || s.lines) return "statement";
52
+ if (s.items && s.cards !== false && s.items.some?.((i) => typeof i === "object" && (i.icon || i.title))) return "cards";
53
+ if (s.items) return "list";
54
+ if (s.figure && (s.title || s.body || s.bullets)) return "split";
55
+ return "blocks";
56
+ }
57
+
58
+ const DEFAULT_TONE = { section: "accent", cover: "light", end: "dark" };
59
+ const NO_FOOTER = new Set(["cover", "section", "end", "image", "canvas", "full", "headline", "kinetic", "scenography"]);
60
+
61
+ export function wordCount(s) {
62
+ const txt = [];
63
+ const walk = (v, k) => {
64
+ if (k === "notes" || k === "consulta" || k === "source" || k === "id" || k === "layout" || k === "tone" || k === "auto") return;
65
+ // o que veio de um PowerPoint importado: desenho, fontes, cores, estilos e a origem não são palavras
66
+ if (k === "drawing" || k === "font" || k === "color" || k === "sym" || k === "style" || k === "image" || k === "link" || k === "original" || k === "tableData" || k === "fill" || k === "border" || k === "borders" || k === "review") return;
67
+ if (typeof v === "string") { if (!/^(\.|https?:|#?[0-9a-f]{6}$)/i.test(v)) txt.push(plain(v)); }
68
+ else if (Array.isArray(v)) v.forEach((x) => walk(x));
69
+ else if (v && typeof v === "object" && !v.svg && !v.chart && !v.html) for (const [kk, vv] of Object.entries(v)) walk(vv, kk);
70
+ };
71
+ walk(s);
72
+ return txt.join(" ").split(/\s+/).filter((w) => w.length > 1).length;
73
+ }
74
+
75
+ // Tema e paleta de cada slide: os do deck, ou os do próprio slide (theme:/palette: no slide). Tema e paleta são
76
+ // independentes, como no PowerPoint: o tema cuida de fonte, arranjo e ornamentos (a pele em runtime/skins/<tema>.css
77
+ // rearruma capa, seção e encerramento); a paleta, só das cores.
78
+ // identidade do deck (fontes da empresa, do arquivo local; ver src/identity.js)
79
+ const identityOf = (spec) => (spec.identity ? identityFor(spec.identity) : null);
80
+ const slideTheme = (s, deckTheme, spec) => (s.theme || s.palette ? resolveTheme(s.theme || spec.theme, s.palette ?? spec.palette, identityOf(spec)) : deckTheme);
81
+ const skinCSS = (name) => { try { return read(`runtime/skins/${name}.css`); } catch { return ""; } };
82
+ export function deckThemeCSS(spec, deckTheme) {
83
+ const looks = new Map(), names = new Set([deckTheme.name]);
84
+ for (const raw of spec.slides || []) {
85
+ const t = slideTheme({ ...(spec.defaults || {}), ...raw }, deckTheme, spec);
86
+ if (t.key !== deckTheme.key) looks.set(t.key, t);
87
+ }
88
+ let css = themeCSS(deckTheme);
89
+ for (const t of looks.values()) { css += "\n" + scopedThemeCSS(t, { faces: !names.has(t.name) }); names.add(t.name); }
90
+ return css + "\n" + [...names].map(skinCSS).join("\n") + "\n" + masterCSS(spec);
91
+ }
92
+
93
+ // Fontes dos temas (OFL) embutidas: só as famílias que o CSS da apresentação cita (runtime/fonts, scripts/vendor-fonts.mjs)
94
+ let FONT_INDEX = null;
95
+ export function fontsCSSFor(css) {
96
+ FONT_INDEX ||= JSON.parse(read("runtime/fonts/index.json"));
97
+ return Object.entries(FONT_INDEX).filter(([family]) => css.includes(`'${family}'`) || css.includes(`"${family}"`))
98
+ .map(([, file]) => read(`runtime/fonts/${file}`)).join("\n");
99
+ }
100
+
101
+ // Destinos de navegação de um slide: goto (em qualquer item), back, next e os [texto](#id) do texto
102
+ function navTargets(slide) {
103
+ const out = [];
104
+ const walk = (v, k) => {
105
+ if (typeof v === "string") {
106
+ if (k === "goto" || k === "back" || (k === "next" && v)) out.push(v);
107
+ for (const m of v.matchAll(/\]\(#([\w-]+)\)/g)) out.push(m[1]);
108
+ } else if (typeof v === "number" && (k === "goto" || k === "back" || k === "next")) out.push(String(v));
109
+ else if (Array.isArray(v)) v.forEach((x) => walk(x, k));
110
+ else if (v && typeof v === "object") for (const [kk, vv] of Object.entries(v)) if (kk !== "notes") walk(vv, kk);
111
+ };
112
+ walk(slide, "");
113
+ return out;
114
+ }
115
+ export function navWarnings(slides = []) {
116
+ const ids = new Map(), warns = [];
117
+ slides.forEach((s, i) => { if (s && s.id != null && s.id !== "") { const id = String(s.id); if (ids.has(id)) warns.push(`slide ${i + 1}: id "${id}" repetido (o slide ${ids.get(id) + 1} já usa)`); else ids.set(id, i); } });
118
+ slides.forEach((s, i) => {
119
+ for (const t of navTargets(s)) {
120
+ const n = /^\d+$/.test(t) ? +t : 0;
121
+ if (!ids.has(t) && !(n >= 1 && n <= slides.length)) warns.push(`slide ${i + 1}: o link para "${t}" não leva a nenhum slide (dê id: ${t} ao slide de destino)`);
122
+ }
123
+ });
124
+ return warns;
125
+ }
126
+
127
+ export function buildHTML(rawSpec, opts = {}) {
128
+ const spec = normalizeSpec(meetingSpec(rawSpec,opts.audience));
129
+ const theme = resolveTheme(spec.theme, spec.palette, identityOf(spec));
130
+ const warnings = [];
131
+ if (spec.identity && !theme.identity) warnings.push(`identidade "${spec.identity}" não está em ${identitiesFile()} nesta máquina: a apresentação saiu com as fontes do tema`);
132
+ const ctx = { baseDir: spec._dir || process.cwd(), theme, spec, warnings };
133
+ const id = spec.id || slug(spec.title);
134
+ const slidesMeta = [];
135
+ let html = "";
136
+
137
+ spec.slides.forEach((raw, i) => {
138
+ const s = { ...(spec.defaults || {}), ...raw };
139
+ const layout = inferLayout(s);
140
+ const fn = LAYOUTS[layout];
141
+ if (!fn) throw new Error(`Slide ${i + 1}: layout "${layout}" não existe. Use: ${Object.keys(LAYOUTS).join(", ")}`);
142
+ const tone = s.tone || (layout === "scenography" && SCENES[s.scene || "stage"]?.tone) || DEFAULT_TONE[layout] || spec.tone || "light";
143
+ let inner;
144
+ const th = slideTheme(s, theme, spec), sctx = th === theme ? ctx : { ...ctx, theme: th };
145
+ try { inner = fn(s, sctx); } catch (e) { throw new Error(`Slide ${i + 1} (${layout}${s.title ? `: ${plain(s.title).slice(0, 40)}` : ""}): ${e.message}`); }
146
+ html += slideShell({ s, i, spec, theme: th, ctx: sctx, layout, tone, inner }) + "\n";
147
+
148
+ const words = wordCount({ ...raw, notes: undefined });
149
+ // limite pelo propósito do material (purpose: consulta/aula aceitam texto corrido) e pelo layout (one-page, status)
150
+ const limit = wordLimit(s, spec, layout);
151
+ if (words > limit) warnings.push(`slide ${i + 1}: ${words} palavras (limite ${limit}) — divida em dois ou use cliques`);
152
+ const t = plain(s.title || s.text || s.question || s.quote || (s.lines && (s.lines[0].text || s.lines[0])) || s.kicker || layout);
153
+ slidesMeta.push({ title: t.slice(0, 90), notes: notesHTML(s.notes), notesRaw: s.notes || "", consulta: s.consulta ? notesHTML(s.consulta) : "", ...(s.layout === 'calc' ? { exploration: explorationStates(s), explorationModel: calcModel(s), illustrative: !!s.illustrative, prediction: s.prediction || '', outputLabels: Object.fromEntries((s.outputs || []).map(o => [o.name, [o.label || o.name, o.unit || ''].join(' ').trim()])) } : {}), time: s.time || 0, layout, words });
154
+ });
155
+
156
+ for(const [i,s]of spec.slides.entries())if(['graphlab','codelab','playground','portal'].includes(s.layout))slidesMeta[i].interactiveModel={theme:spec.theme||'manual',slide:s};
157
+ // navegação por caminhos: todo goto/back/next e [texto](#id) tem de levar a um slide (id ou número)
158
+ warnings.push(...navWarnings(spec.slides));
159
+
160
+ // CSS e widgets próprios ficam ao lado do YAML. Se faltar um (ex.: deck aberto pelo navegador no
161
+ // Studio, sem a pasta original), a apresentação sai sem ele e com aviso — não quebra inteira.
162
+ const readCompanion = (f, kind) => {
163
+ const file = path.resolve(ctx.baseDir, f);
164
+ try { return fs.readFileSync(file, "utf8"); }
165
+ catch { warnings.push(`${kind} "${f}" não encontrado em ${ctx.baseDir} — a apresentação saiu sem ele`); return ""; }
166
+ };
167
+ let customCSS = "";
168
+ for (const c of [].concat(spec.css || [])) customCSS += readCompanion(c, "CSS") + "\n";
169
+ let widgets = "";
170
+ for (const w of [].concat(spec.widgets || [])) widgets += `\n/* ${w} */\n` + readCompanion(w, "widget") + "\n";
171
+
172
+ // Slide "api": o núcleo e a interface entram só se o deck tiver um; as respostas gravadas vão junto,
173
+ // para o HTML exportado (sem Studio) ainda mostrar o resultado.
174
+ const hasApi = slidesMeta.some((m) => m.layout === "api");
175
+ const apiScripts = hasApi ? `<script type="application/json" id="sagadeck-api-rec">${JSON.stringify(readRecordings(spec._file)).replace(/</g, "\\u003c")}</script>
176
+ <script>${read("runtime/api-core.js")}</script>` : "";
177
+ const size = slideSize(spec);
178
+ const data = { size: { w: size.w, h: size.h }, id, title: spec.title || "", author: spec.author || "", motion: ["none", "subtle", "expressive"].includes(spec.motion) ? spec.motion : "subtle", duration: spec.duration || null, slides: slidesMeta.map(({ notesRaw, consulta, ...m }) => m) };
179
+ const planned = slidesMeta.reduce((a, s) => a + s.time, 0);
180
+
181
+ const doc = `<!doctype html>
182
+ <html lang="${spec.lang || "pt-BR"}" style="--sh:${size.h}px;--aspect:${size.ratio}"${size.h > size.w ? ' data-orient="portrait"' : ""}><head><meta charset="utf-8">
183
+ <meta name="viewport" content="width=device-width,initial-scale=1">
184
+ <meta name="generator" content="sagadeck">
185
+ <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 32 32%22%3E%3Crect width=%2232%22 height=%2232%22 rx=%227%22 fill=%22%23c43e1c%22/%3E%3Cpath d=%22M12 9l12 7-12 7z%22 fill=%22white%22/%3E%3C/svg%3E">
186
+ <title>${esc(plain(spec.title || "Apresentação"))}</title>
187
+ <style>${fontsCSSFor(read("runtime/base.css") + deckThemeCSS(spec, theme) + customCSS)}
188
+ ${read("runtime/base.css")}
189
+ ${read("runtime/portal-scene.css")}
190
+ ${read("runtime/meeting.css")}
191
+ ${spec.slides.some(s => ["science", "solution", "calc"].includes(s.layout) || (s.layout === "canvas" && JSON.stringify(s.elements || []).includes('"latex"')) || new RegExp(INLINE_MATH.source).test(JSON.stringify(s))) ? read("runtime/vendor/katex.css") : ""}
192
+ ${deckThemeCSS(spec, theme)}
193
+ ${customCSS}</style></head>
194
+ <body class="theme-${theme.name}">
195
+ <div id="viewport"><div id="stage">
196
+ ${html}
197
+ <canvas id="draw-canvas" width="1920" height="${size.h}"></canvas>
198
+ </div></div>
199
+ <div id="draw-toolbar" class="draw-toolbar" style="display:none">
200
+ <button id="draw-btn-pen" class="draw-btn active" title="Caneta (D)">${drawIcon("pencil")}</button>
201
+ <button id="draw-btn-highlighter" class="draw-btn" title="Marca-texto (M)">${drawIcon("highlighter")}</button>
202
+ <div class="draw-separator"></div>
203
+ <button class="draw-color active" data-color="#ef4444" style="background:#ef4444" title="Vermelho"></button>
204
+ <button class="draw-color" data-color="#f59e0b" style="background:#f59e0b" title="Amarelo"></button>
205
+ <button class="draw-color" data-color="#38bdf8" style="background:#38bdf8" title="Azul"></button>
206
+ <button class="draw-color" data-color="#22c55e" style="background:#22c55e" title="Verde"></button>
207
+ <button class="draw-color" data-color="#ffffff" style="background:#ffffff" title="Branco"></button>
208
+ <div class="draw-separator"></div>
209
+ <button id="draw-btn-undo" class="draw-btn" title="Desfazer (Ctrl+Z ou Z)">${drawIcon("undo-2")}</button>
210
+ <button id="draw-btn-clear" class="draw-btn" title="Limpar anotações (C)">${drawIcon("trash-2")}</button>
211
+ <button id="draw-btn-close" class="draw-btn" title="Fechar modo desenho (Esc ou D)">${drawIcon("x")}</button>
212
+ </div>
213
+ <button id="draw-fab" class="draw-fab" title="Ativar Caneta de Anotações (D)">${drawIcon("pencil", 24)}</button>
214
+ <div id="hud"><div class="bar"></div></div><div id="laser"></div><div id="blank"></div><div id="overview"></div><div id="toast"></div>
215
+ <div id="help"><b>Atalhos</b><br><kbd>→</kbd><kbd>espaço</kbd> avança · <kbd>←</kbd> volta<br><kbd>D</kbd> caneta ao vivo · <kbd>M</kbd> marca-texto · <kbd>C</kbd> limpa tela<br><kbd>P</kbd> janela do apresentador (notas + cronômetro)<br><kbd>F</kbd> tela cheia · <kbd>G</kbd> visão geral<br><kbd>B</kbd> tela preta · <kbd>W</kbd> tela branca<br><kbd>L</kbd> apontador laser · <kbd>R</kbd> zera timer<br><kbd>5</kbd><kbd>Enter</kbd> vai ao slide 5 · <kbd>H</kbd> esta ajuda</div>
216
+ <script type="application/json" id="sagadeck-data">${JSON.stringify(data).replace(/</g, "\\u003c")}</script>
217
+ <script>window.Sagadeck={_q:[],widget:function(n,d){this._q.push([n,d])}};</script>
218
+ <script>${widgets.replace(/<\/script/gi, "<\\/script")}</script>
219
+ <script>${read("runtime/fit.js")}</script>
220
+ <script>${read("runtime/runtime.js")}</script>
221
+ <script>${read("runtime/motion.js")}</script>
222
+ <script>${read("runtime/video-player.js")}</script>
223
+ ${spec.slides.some(s=>s.layout==='poll'&&s.manual)?`<script>${read('runtime/meeting.js')}</script>`:''}
224
+ ${spec.slides.some(s => s.layout === "decisionlab") ? `<script>${read("runtime/decision-lab.js")}</script>` : ""}
225
+ ${spec.slides.some(s => s.layout === "graphlab") ? `<script>${read("runtime/graph-lab.js")}</script>` : ""}
226
+ ${spec.slides.some(s => s.layout === "codelab") ? `<script>${read("runtime/code-lab.js").replace(/<\/script/gi,'<\\/script')}</script>` : ""}
227
+ ${spec.slides.some((s) => s.layout === "diagram") ? `<script>${read("runtime/vendor/mermaid.min.js").replace(/<\/script/gi, "<\\/script")}</script><script>${read("runtime/diagram.js")}</script>` : ""}
228
+ ${spec.slides.some(s => s.layout === "science") ? `<script>${read("runtime/vendor/plotly.min.js").replace(/<\/script/gi,"<\\/script")}</script><script>${read("runtime/formula.js")}</script><script>${read("runtime/science.js")}</script>` : ""}
229
+ ${spec.slides.some(s => s.layout === "calc") ? `${spec.slides.some(s => s.layout === "science") ? "" : `<script>${read("runtime/formula.js")}</script>`}<script>${read("runtime/calc.js")}</script>` : ""}
230
+ ${spec.slides.some(s=>s.layout==="portal") ? `<script>${read("runtime/portal-scene.js")}</script>` : ""}
231
+ ${JSON.stringify(spec.slides || []).includes('"points"') ? `<script>${read("runtime/points.js")}</script>` : ""}
232
+ ${spec.slides.some(s=>s.layout==="playground") ? `<script>${read("runtime/playground.js")}</script>` : ""}
233
+ ${hasApi ? `${apiScripts}\n<script>${read("runtime/api-ui.js")}</script><script>${read("runtime/api-collection-ui.js")}</script>` : ""}
234
+ </body></html>`;
235
+ return { html: doc, warnings, meta: data, planned, theme, slidesMeta };
236
+ }
237
+
238
+ export function buildFile(file, outFile) {
239
+ const spec = loadSpec(file);
240
+ const r = buildHTML(spec);
241
+ fs.mkdirSync(path.dirname(outFile), { recursive: true });
242
+ fs.writeFileSync(outFile, r.html);
243
+ return { ...r, spec, outFile };
244
+ }
245
+
246
+ // Padrões do ajuste para caber; o Studio troca pelos das Preferências (setFitDefaults). O deck (fit:) vence os dois.
247
+ const FIT_DEFAULTS = { minCodePt: 10, minTextPt: 6, wrapCode: true };
248
+ export function setFitDefaults(d = {}) {
249
+ for (const k of Object.keys(FIT_DEFAULTS)) if (d[k] !== undefined && d[k] !== null && d[k] !== "") FIT_DEFAULTS[k] = k === "wrapCode" ? d[k] !== false : Number(d[k]) || FIT_DEFAULTS[k];
250
+ }
251
+ export const fitDefaults = () => ({ ...FIT_DEFAULTS });
252
+
253
+ // eco ambiente: camada viva discreta atrás do conteúdo (só no HTML; PPTX/PDF usam o quadro parado)
254
+ const AMBIENT = ["pontos", "grade"];
255
+ function ambientOf(s, i, ctx) {
256
+ if (s.ambient == null || s.ambient === "") return "";
257
+ if (!AMBIENT.includes(s.ambient)) { ctx.warnings?.push(`slide ${i + 1}: ambiente "${s.ambient}" desconhecido (vale: ${AMBIENT.join(", ")})`); return ""; }
258
+ return s.ambient;
259
+ }
260
+ // <section> de um slide: tom, textura, estilo do destaque, fundo, área, cabeçalho e rodapé.
261
+ // Única montagem para o Studio (renderSlide) e para a apresentação/exportação (buildHTML).
262
+ function slideShell({ s, i, spec, theme, ctx, layout, tone, inner: innerIn, current = false }) {
263
+ let inner = innerIn;
264
+ const ambient = ambientOf(s, i, ctx);
265
+ const deco = s.deco ?? theme.deco;
266
+ const style = (s.bg ? `--bg:#${String(s.bg).replace("#", "")};` : "") + (s.fg ? `--fg:#${String(s.fg).replace("#", "")};` : "");
267
+ const bars = s.footer !== false && (s.footer === true || !NO_FOOTER.has(layout));
268
+ const area = layout === "canvas" || layout === "full" || layout === "kinetic" || layout === "scenography" ? "free" : "safe";
269
+ const motionAccent = area === 'safe' && s.motionAccent && typeof s.motionAccent === 'object';
270
+ // estilo do ==destaque== (marca-texto | sublinhado | cor | negrito | nenhum), no deck ou por slide
271
+ const markStyle = s.markStyle || spec.markStyle;
272
+ const total = spec.slides?.length || i + 1;
273
+ // navegação por caminhos: id (destino de goto/back/next), next (aonde o avanço leva no fim do slide), back (botão Voltar)
274
+ const nav = (s.id != null && s.id !== "" ? ` id="s-${esc(s.id)}" data-id="${esc(s.id)}"` : "") + (s.next != null && s.next !== "" ? ` data-next="${esc(s.next)}"` : "");
275
+ // mínimos do ajuste para caber (em pt, como no PowerPoint; 1 pt = 2 px no slide de 1920): deck > Preferências > padrão
276
+ const fit = { ...FIT_DEFAULTS, ...(spec.fit || {}) };
277
+ const fitAttrs = ` data-min-code="${Math.round(Number(fit.minCodePt) * 2) || 20}" data-min-text="${Math.round(Number(fit.minTextPt) * 2) || 0}"${fit.wrapCode === false ? ' data-code-wrap="0"' : ""}`;
278
+ const withMaster = masterApplies(spec, s, layout);
279
+ // faixa do título do mestre: o cabeçalho do layout sai do fluxo e vai para cima do fio (src/master.js: titleBand)
280
+ const band = withMaster && !isCoverLayout(layout) && area === "safe" ? titleBand(spec) : null;
281
+ // (fora da área útil: o ajuste para caber de lá não pode achar que o título "vaza"; ele encolhe só pelo próprio tamanho,
282
+ // data-fit-self, e não pelo que vaza no resto do slide; os ajustes visuais valem nele)
283
+ let bandTitle = "";
284
+ if (band) {
285
+ const hd = inner.match(/<header class="hd">[\s\S]*?<\/header>/);
286
+ if (hd) { bandTitle = `<div class="master-title">${hd[0].replace(/<[a-z0-9]+ [^>]*class="[^"]*\bttl\b[^"]*"[^>]*/, (tag) => `${tag}${/\sdata-fit[\s=>]|\sdata-fit$/.test(tag) ? "" : " data-fit"} data-fit-self`)}</div>`; inner = inner.replace(hd[0], ""); }
287
+ }
288
+ let html = `<section class="slide${current ? " current" : ""}${withMaster ? ` has-master${isCoverLayout(layout) ? " master-cover" : ""}${band ? " master-band" : ""}` : ""} th-${theme.name} lk-${theme.key} tone-${tone} ${deco && deco !== "none" ? "deco-" + deco : ""} ${markStyle && markStyle !== "marca-texto" ? "ms-" + markStyle : ""} L-${layout}-slide${["compact", "dense"].includes(s.density) ? " density-" + s.density : ""}" data-idx="${i}"${s.uid ? ` data-uid="${esc(String(s.uid))}"` : ""} data-layout="${layout}" data-tr="${s.transition || "fade"}"${ambient ? ` data-ambient="${ambient}"` : ""}${nav}${fitAttrs}${!Array.isArray(s.steps) && Number.isFinite(Number(s.steps)) && Number(s.steps) > 0 ? ` data-steps="${Number(s.steps)}"` : ""}${style ? ` style="${style}"` : ""}>`;
289
+ if (ambient) html += `<div class="ambient" aria-hidden="true"></div>`;
290
+ if (s.background) html += `<div class="bgfig" style="${s.backgroundStyle || ""}">${el(s.background, ctx, 1920, slideSize(spec).h)}</div>`;
291
+ // mestre do deck (logos, faixas, número da página: src/master.js), atrás do conteúdo
292
+ if (withMaster) html += `<div class="master" aria-hidden="true">${masterElements(spec, layout, i).map((e) => el(e, ctx)).join("")}</div>`;
293
+ // ornamentos da pele do tema (fitas, molduras, faixas...): desenhados em CSS, atrás do conteúdo
294
+ if (area === "safe") html += `<div class="orn" aria-hidden="true"><i></i><i></i><i></i><i></i></div>`;
295
+ if (bars && s.header !== false) html += barHTML("header", spec, i, total);
296
+ if (bandTitle) html += applyVisualEdits(bandTitle, s.visualEdits, { src: (p) => imageSrc(p, ctx) || p });
297
+ html += `<div class="${area}${motionAccent ? ' with-motion-accent' : ''}">${applyVisualEdits(inner, s.visualEdits, { src: (p) => imageSrc(p, ctx) || p })}</div>`;
298
+ if (motionAccent) {const fg=s.fg||theme.tones[tone]?.fg||'ink';html += `<div class="motion-accent" aria-hidden="true">${el({motion:{background:'transparent',foreground:theme.colors[fg]||fg,...s.motionAccent}},ctx,320,90)}</div>`;}
299
+ if (bars) html += barHTML("footer", spec, i, total);
300
+ if (s.back != null && s.back !== "") {
301
+ const alvo = (spec.slides || []).find((x) => x && String(x.id) === String(s.back));
302
+ const nome = s.backLabel || (alvo ? plain(alvo.title || alvo.kicker || alvo.question || "") : "") || "o mapa";
303
+ html += `<a class="nav-back t f-label" href="#s-${esc(s.back)}" data-goto="${esc(s.back)}">${iconSVG("arrow-left", { size: 26, stroke: 2 })}<span>Voltar: ${esc(nome)}</span></a>`;
304
+ }
305
+ return html + `</section>`;
306
+ }
307
+
308
+ export function renderSlide(raw, i = 0, spec = {}) {
309
+ const deckTheme = resolveTheme(spec.theme, spec.palette, identityOf(spec));
310
+ const s = { ...(spec.defaults || {}), ...raw };
311
+ const theme = slideTheme(s, deckTheme, spec);
312
+ const ctx = { baseDir: spec._dir || process.cwd(), theme, spec, warnings: [] };
313
+ const layout = inferLayout(s);
314
+ const fn = LAYOUTS[layout];
315
+ if (!fn) throw new Error(`Slide ${i + 1}: layout "${layout}" não existe.`);
316
+ const tone = s.tone || (layout === "scenography" && SCENES[s.scene || "stage"]?.tone) || DEFAULT_TONE[layout] || spec.tone || "light";
317
+ const inner = fn(s, ctx);
318
+ const deco = s.deco ?? theme.deco;
319
+ const html = slideShell({ s, i, spec, theme, ctx, layout, tone, inner, current: true });
320
+ // o CSS de todos os temas do deck (e do próprio slide), igual para qualquer slide: as miniaturas não piscam
321
+ const all = deckThemeCSS({ ...spec, slides: [...(spec.slides || []), s] }, deckTheme);
322
+ return { html, layout, tone, deco, theme, inner, baseCSS: read("runtime/base.css") + read("runtime/portal-scene.css") + read('runtime/meeting.css'), themeCSS: all };
323
+ }