sagadeck 1.9.0 → 1.10.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.
@@ -35,11 +35,45 @@
35
35
 
36
36
  // {{nome}} em qualquer texto do pedido (URL, cabeçalhos, corpo). Sem valor: fica como está.
37
37
  const VAR = /\{\{\s*([\w.-]+)\s*\}\}/g;
38
+ function renderBody(body, vars, encoding) {
39
+ if (typeof body !== "string" || !["json", "urlencoded"].includes(encoding)) return render(body, vars);
40
+ if (encoding === "urlencoded") return body.replace(VAR, (all, key) => vars && vars[key] != null ? encodeURIComponent(String(vars[key])) : all);
41
+ // Interpola no texto original, sem converter números ou reformatar o JSON. Dentro de uma
42
+ // string escapa só o valor; fora dela, insere um literal JSON. Marcador sem valor continua.
43
+ let result = "", quoted = false;
44
+ for (let at = 0; at < body.length; at++) {
45
+ if (body.startsWith("{{", at)) {
46
+ const marker = body.slice(at).match(/^\{\{\s*([\w.-]+)\s*\}\}/);
47
+ if (marker && vars && vars[marker[1]] != null) {
48
+ const value = vars[marker[1]];
49
+ result += quoted ? JSON.stringify(String(value)).slice(1, -1) : JSON.stringify(value);
50
+ at += marker[0].length - 1; continue;
51
+ }
52
+ }
53
+ const char = body[at]; result += char;
54
+ if (quoted && char === "\\" && at + 1 < body.length) { result += body[++at]; continue; }
55
+ if (char === '"') quoted = !quoted;
56
+ }
57
+ return result;
58
+ }
59
+ function renderURL(url, vars, encoding) {
60
+ if (encoding !== "query") return render(url, vars);
61
+ const text = String(url), query = text.indexOf("?"), fragment = text.indexOf("#");
62
+ return text.replace(VAR, (all, key, at) => {
63
+ if (!vars || vars[key] == null) return all;
64
+ const value = String(vars[key]);
65
+ return query >= 0 && at > query && (fragment < 0 || at < fragment) ? encodeURIComponent(value) : value;
66
+ });
67
+ }
38
68
  function render(value, vars) {
39
69
  if (typeof value === "string") return value.replace(VAR, (all, k) => (vars && vars[k] != null ? String(vars[k]) : all));
40
70
  if (Array.isArray(value)) return value.map((x) => render(x, vars));
41
71
  if (value && typeof value === "object" && value.__py) return value;
42
- if (value && typeof value === "object") { const o = {}; for (const [k, v] of Object.entries(value)) o[render(k, vars)] = render(v, vars); return o; }
72
+ if (value && typeof value === "object") {
73
+ const o = {};
74
+ for (const [k, v] of Object.entries(value)) o[render(k, vars)] = k === "body" && value.bodyEncoding ? renderBody(v, vars, value.bodyEncoding) : k === "url" && value.urlEncoding ? renderURL(v, vars, value.urlEncoding) : render(v, vars);
75
+ return o;
76
+ }
43
77
  return value;
44
78
  }
45
79
  function missing(value, vars) {
@@ -82,6 +116,8 @@
82
116
  url: String(req.url || ""),
83
117
  headers: req.headers && typeof req.headers === "object" ? req.headers : {},
84
118
  body: req.body,
119
+ ...(["json", "urlencoded"].includes(req.bodyEncoding) ? { bodyEncoding: req.bodyEncoding } : {}),
120
+ ...(req.urlEncoding === "query" ? { urlEncoding: "query" } : {}),
85
121
  form: req.form && typeof req.form === "object" ? req.form : null, // multipart: { campo: "@file" | texto }
86
122
  auth: req.auth !== false,
87
123
  },
@@ -202,12 +238,12 @@
202
238
  }
203
239
 
204
240
  function curlCmd(r, tokenVar, extra, fileName) {
205
- const parts = [`curl -s${extra || ""}${r.method !== "GET" || r.body != null || r.form ? ` -X ${r.method}` : ""} ${dq(r.url)}`];
241
+ const parts = [`curl -s${extra || ""}${r.method !== "GET" || r.body != null || r.form ? ` -X ${r.method}` : ""} ${r._urlShell || dq(r.url)}`];
206
242
  if (r.auth) parts.push(`-H "Authorization: Bearer $${tokenVar}"`);
207
- for (const [k, v] of Object.entries(r.headers || {})) parts.push(`-H ${dq(k + ": " + v)}`);
243
+ for (const [k, v] of Object.entries(r.headers || {})) parts.push(`-H ${r._headerShell?.[k] || dq(k + ": " + v)}`);
208
244
  if (r.form) for (const [k, v] of Object.entries(r.form)) parts.push(`-F ${dq(k + "=" + (v === "@file" ? "@" + (fileName || "arquivo") : v))}`);
209
245
  else if (isJsonBody(r.body)) { parts.push(`-H "Content-Type: application/json"`); parts.push(`-d ${shq(JSON.stringify(r.body, null, 2))}`); }
210
- else if (r.body != null) parts.push(`-d ${shq(r.body)}`);
246
+ else if (r.body != null) parts.push(r._stdinBody ? "--data-binary @-" : `-d ${r._bodyShell || shq(r.body)}`);
211
247
  return parts.join(" \\\n ");
212
248
  }
213
249
 
@@ -217,7 +253,70 @@
217
253
  const secretsIn = (v) => { const out = new Set(); JSON.stringify(v || "").replace(/\{\{\s*secret\.([\w-]+)\s*\}\}/g, (_, k) => out.add(k)); return [...out]; };
218
254
  const fileName = (a, vars) => (vars && vars["file.name"]) || (a.file ? String(a.file).split(/[\\/]/).pop() : "arquivo");
219
255
  const usesB64 = (a) => JSON.stringify(a.request.body || "").includes("{{file.base64}}");
256
+ // Para cURL importado, expressões são montadas por contexto. As partes literais continuam
257
+ // literais no shell, e JSON/form/query escapam o segredo só na hora de rodar o exemplo.
258
+ function secretParts(text, encoding) {
259
+ text = String(text); const parts = []; let last = 0, scan = 0, quoted = false;
260
+ const query = text.indexOf("?"), fragment = text.indexOf("#");
261
+ const pattern = /\{\{\s*secret\.([\w-]+)\s*\}\}/g; let match;
262
+ while ((match = pattern.exec(text))) {
263
+ if (encoding === "json") for (; scan < match.index; scan++) { if (quoted && text[scan] === "\\") scan++; else if (text[scan] === '"') quoted = !quoted; }
264
+ if (match.index > last) parts.push(text.slice(last, match.index));
265
+ const context = encoding === "json" ? (quoted ? "json-string" : "json-literal") : encoding === "urlencoded" || (encoding === "query" && query >= 0 && match.index > query && (fragment < 0 || match.index < fragment)) ? "urlencoded" : "raw";
266
+ parts.push({ name: secretEnv(match[1]), context }); last = pattern.lastIndex; scan = last;
267
+ }
268
+ if (last < text.length || !parts.length) parts.push(text.slice(last));
269
+ return parts;
270
+ }
271
+ function secretExpression(text, encoding, language) {
272
+ return secretParts(text, encoding).map(part => {
273
+ if (typeof part === "string") return JSON.stringify(part);
274
+ const value = language === "python" ? `os.environ[${JSON.stringify(part.name)}]` : `segredo(${JSON.stringify(part.name)})`;
275
+ if (part.context === "json-string") return language === "python" ? `json.dumps(${value}, ensure_ascii=False)[1:-1]` : `JSON.stringify(${value}).slice(1, -1)`;
276
+ if (part.context === "json-literal") return language === "python" ? `json.dumps(${value}, ensure_ascii=False)` : `JSON.stringify(${value})`;
277
+ if (part.context === "urlencoded") return language === "python" ? `quote(${value}, safe="-_.!~*'()")` : `encodeURIComponent(${value})`;
278
+ return value;
279
+ }).join(" + ");
280
+ }
281
+ function curlImported(a, vars) {
282
+ const L = Lines(false), name = fileName(a, vars), helpers = [], helperNames = new Map();
283
+ const cleanVars = Object.fromEntries(Object.entries(vars || {}).filter(([key]) => !key.startsWith("secret.")));
284
+ const shell = (text, encoding) => secretParts(text, encoding).map(part => {
285
+ if (typeof part === "string") return shq(part);
286
+ if (part.context === "raw") return `"$${part.name}"`;
287
+ const key = `${part.name}:${part.context}`;
288
+ let helper = helperNames.get(key);
289
+ if (!helper) {
290
+ helper = `SAGA_SECRET_${helpers.length + 1}`; helperNames.set(key, helper);
291
+ const expression = part.context === "urlencoded" ? "encodeURIComponent(valor)" : part.context === "json-string" ? "JSON.stringify(valor).slice(1,-1)" : "JSON.stringify(valor)";
292
+ const script = `const valor=process.env[${JSON.stringify(part.name)}];if(valor==null)throw new Error(${JSON.stringify("Defina " + part.name)});process.stdout.write(${expression});`;
293
+ helpers.push(`${helper}=$(node -e ${shq(script)})`);
294
+ }
295
+ return `"$${helper}"`;
296
+ }).join("");
297
+ const prepare = original => {
298
+ const r = render(original, { ...cleanVars, "file.name": name });
299
+ r._urlShell = shell(r.url, r.urlEncoding);
300
+ r._headerShell = Object.fromEntries(Object.entries(r.headers || {}).map(([key, value]) => [key, shell(`${key}: ${value}`, "raw")]));
301
+ if (r.body != null) {
302
+ if (typeof r.body === "object") r.body = JSON.stringify(r.body, null, 2);
303
+ r._bodyShell = shell(r.body, r.bodyEncoding);
304
+ r._stdinBody = true; // stdin preserva UTF-8 também com curl.exe no Windows
305
+ }
306
+ return r;
307
+ };
308
+ const req = prepare(a.request), check = a.polling ? prepare(render(a.polling.check, { ...cleanVars, id: "$ID" })) : null;
309
+ if (helpers.length) { L.add("# Bash + Node.js: segredos são escapados sem alterar o JSON ou a query."); helpers.forEach(line => L.add(line)); L.blank(); }
310
+ const command = (r, extra = "") => (r._stdinBody ? `printf '%s' ${r._bodyShell} | \\\n` : "") + curlCmd(r, a.tokenVar, extra, name);
311
+ if (a.mode === "polling") {
312
+ L.add("# 1. inicia", "start").add(command(req), "start");
313
+ L.add(`# guarde ${a.polling.id} da resposta em ID`).blank();
314
+ L.add("# 2. consulta o andamento", "poll").add(command(check), "poll");
315
+ } else L.add(command(req, a.mode === "stream" ? "N" : "") + (a.audio ? ` \\\n --output ${shq(a.audio)}` : ""), "start");
316
+ return L.out();
317
+ }
220
318
  function curl(a, vars) {
319
+ if (a.request.bodyEncoding || a.request.urlEncoding) return curlImported(a, vars);
221
320
  const L = Lines(false);
222
321
  const name = fileName(a, vars);
223
322
  const sec = Object.fromEntries(secretsIn(a.request).map((k) => ["secret." + k, MARK(k)]));
@@ -242,14 +341,14 @@
242
341
 
243
342
  function pyCall(name, r, extra, ind) {
244
343
  ind = ind || "";
245
- const args = [`${dq(r.url)}`];
344
+ const args = [r._urlExpr || dq(r.url)];
246
345
  if (r.auth || Object.keys(r.headers || {}).length) args.push("headers=HEADERS");
247
346
  if (r.form) {
248
347
  const files = Object.entries(r.form).filter(([, v]) => v === "@file"), data = Object.entries(r.form).filter(([, v]) => v !== "@file");
249
348
  if (files.length) args.push(`files={${files.map(([k]) => `${JSON.stringify(k)}: open(${dq(r._fileName || "arquivo")}, "rb")`).join(", ")}}`);
250
349
  if (data.length) args.push(`data={${data.map(([k, v]) => `${JSON.stringify(k)}: ${JSON.stringify(String(v))}`).join(", ")}}`);
251
350
  } else if (isJsonBody(r.body)) args.push(`json=${pyLiteral(r.body, ind + " ")}`);
252
- else if (r.body != null) args.push(`data=${JSON.stringify(String(r.body))}`);
351
+ else if (r.body != null) args.push(`data=${r.bodyEncoding ? `(${r._bodyExpr || JSON.stringify(String(r.body))}).encode("utf-8")` : r._bodyExpr || JSON.stringify(String(r.body))}`);
253
352
  args.push(...(extra || []));
254
353
  return `${name} = requests.${r.method.toLowerCase()}(\n` + args.map((x) => `${ind} ${x},`).join("\n") + `\n${ind})`;
255
354
  }
@@ -260,7 +359,7 @@
260
359
  Object.assign(h, r.headers || {});
261
360
  if (!Object.keys(h).length) return;
262
361
  const val = (v) => { const m = String(v).match(/^\{\{\s*secret\.([\w-]+)\s*\}\}$/); return m ? `os.environ[${JSON.stringify(String(m[1]).toUpperCase().replace(/[^A-Z0-9_]/g, "_"))}]` : JSON.stringify(String(v)); };
263
- const body = Object.entries(h).map(([k, v]) => ` ${JSON.stringify(k)}: ${v === "__TOKEN__" ? 'f"Bearer {TOKEN}"' : val(v)},`).join("\n");
362
+ const body = Object.entries(h).map(([k, v]) => ` ${JSON.stringify(k)}: ${v === "__TOKEN__" ? 'f"Bearer {TOKEN}"' : r.urlEncoding || r.bodyEncoding ? secretExpression(v, "raw", "python") : val(v)},`).join("\n");
264
363
  L.add(`HEADERS = {\n${body}\n}`, null, "cabeçalhos enviados em toda chamada");
265
364
  }
266
365
 
@@ -271,6 +370,12 @@
271
370
  const toPy = (v) => (v === "{{file.base64}}" ? { __py: "CONTEUDO" } : typeof v === "string" && /^\{\{\s*secret\.([\w-]+)\s*\}\}$/.test(v) ? { __py: `os.environ[${JSON.stringify(secretEnv(v.match(/secret\.([\w-]+)/)[1]))}]` } : Array.isArray(v) ? v.map(toPy) : v && typeof v === "object" ? Object.fromEntries(Object.entries(v).map(([k, x]) => [k, toPy(x)])) : v);
272
371
  const req = { ...render({ ...a.request, body: toPy(a.request.body) }, { ...vars, "file.name": name }), _fileName: name };
273
372
  const imports = ["import os"];
373
+ if (req.urlEncoding || req.bodyEncoding) {
374
+ req._urlExpr = secretExpression(req.url, req.urlEncoding, "python");
375
+ if (typeof req.body === "string") req._bodyExpr = secretExpression(req.body, req.bodyEncoding, "python");
376
+ if (req.bodyEncoding === "json") imports.push("import json");
377
+ if (req.urlEncoding || req.bodyEncoding === "urlencoded") imports.push("from urllib.parse import quote");
378
+ }
274
379
  if (b64) imports.push("import base64");
275
380
  if (a.mode === "polling") imports.push("import time");
276
381
  if (a.mode === "stream") imports.push("import json");
@@ -368,9 +473,9 @@
368
473
  opts.push(`body: JSON.stringify(${jsLiteral(r.body, " ")})`);
369
474
  } else {
370
475
  if (hasHeaders) opts.push("headers: HEADERS");
371
- if (r.body != null) opts.push(`body: ${JSON.stringify(String(r.body))}`);
476
+ if (r.body != null) opts.push(`body: ${r._bodyExpr || JSON.stringify(String(r.body))}`);
372
477
  }
373
- const target = url || dq(r.url);
478
+ const target = url || r._urlExpr || dq(r.url);
374
479
  L.add(opts.length ? `const ${name} = await fetch(${target}, {\n${opts.map((o) => " " + o + ",").join("\n")}\n});` : `const ${name} = await fetch(${target});`, tag, why);
375
480
  L.add(`if (!${name}.ok) throw new Error(\`HTTP \${${name}.status}: \${await ${name}.text()}\`);`, tag, "para aqui se deu erro (4xx/5xx)");
376
481
  }
@@ -380,15 +485,21 @@
380
485
  const b64 = usesB64(a);
381
486
  const toJs = (v) => (v === "{{file.base64}}" ? { __js: "CONTEUDO" } : jsSecret(v) || (Array.isArray(v) ? v.map(toJs) : v && typeof v === "object" ? Object.fromEntries(Object.entries(v).map(([k, x]) => [k, toJs(x)])) : v));
382
487
  const req = { ...render({ ...a.request, body: toJs(a.request.body) }, { ...vars, "file.name": name }), _fileName: name };
488
+ const imported = !!(req.urlEncoding || req.bodyEncoding);
489
+ if (imported) {
490
+ req._urlExpr = secretExpression(req.url, req.urlEncoding, "javascript");
491
+ if (typeof req.body === "string") req._bodyExpr = secretExpression(req.body, req.bodyEncoding, "javascript");
492
+ }
383
493
  const fsUse = [b64 || req.form ? "readFile" : "", a.audio ? "writeFile" : ""].filter(Boolean);
384
494
  L.add("// Node.js 18+ · salve como exemplo.mjs e rode: node exemplo.mjs");
385
495
  if (fsUse.length) L.add(`import { ${fsUse.join(", ")} } from "node:fs/promises";`);
386
496
  L.blank();
497
+ if (imported && secretsIn(a.request).length) L.add('const segredo = (nome) => { const valor = process.env[nome]; if (valor == null) throw new Error(`Defina ${nome}`); return valor; };').blank();
387
498
  const auth = req.auth || !!(a.polling && a.polling.check.auth);
388
499
  if (auth) L.add(`const TOKEN = process.env.${a.tokenVar};`, null, "o token de acesso vem de uma variável de ambiente, nunca escrito no código").blank();
389
500
  const h = [];
390
501
  if (auth) h.push(" Authorization: `Bearer ${TOKEN}`,");
391
- for (const [k, v] of Object.entries(req.headers || {})) h.push(` ${JSON.stringify(k)}: ${jsSecret(v) ? jsSecret(v).__js : JSON.stringify(String(v))},`);
502
+ for (const [k, v] of Object.entries(req.headers || {})) h.push(` ${JSON.stringify(k)}: ${imported ? secretExpression(v, "raw", "javascript") : jsSecret(v) ? jsSecret(v).__js : JSON.stringify(String(v))},`);
392
503
  if (h.length) L.add(`const HEADERS = {\n${h.join("\n")}\n};`, null, "cabeçalhos enviados em toda chamada").blank();
393
504
  if (b64) L.add(`const CONTEUDO = (await readFile(${dq(name)})).toString("base64");`, "start", "o arquivo, em base64, vai dentro do JSON").blank();
394
505
  const out = (expr, why) => {
@@ -525,6 +636,6 @@
525
636
 
526
637
  // o slide mexe com arquivo? (upload @file ou {{file.…}})
527
638
  const usesFile = (s) => { const a = s && s._normalized ? s : normalize(s); return !!a.file || a.mic || !!(a.request.form && Object.values(a.request.form).includes("@file")) || /\{\{\s*file\./.test(JSON.stringify(a.request)); };
528
- const api = { isType, cosine, usesFile, parsePath, get, set, render, missing, envKind, mask, normalize, key, code, pyLiteral, pyPath, LANGS };
639
+ const api = { isType, cosine, usesFile, parsePath, get, set, render, renderBody, renderURL, missing, envKind, mask, normalize, key, code, pyLiteral, pyPath, LANGS };
529
640
  g.SagadeckApiCore = api;
530
641
  })(typeof window !== "undefined" ? window : globalThis);
@@ -61,7 +61,8 @@
61
61
  const bodyTxt = bodyEl ? bodyEl.value : "";
62
62
  let body;
63
63
  if (bodyTxt.trim() !== "" && !cfg.request.form) { // upload: a aba Corpo só mostra os campos do formulário
64
- try { body = JSON.parse(bodyTxt); }
64
+ if (cfg.request.bodyEncoding && typeof cfg.request.body === "string") body = bodyTxt;
65
+ else try { body = JSON.parse(bodyTxt); }
65
66
  catch (e) {
66
67
  if (typeof cfg.request.body === "string") body = bodyTxt;
67
68
  else throw new Error("O corpo não é um JSON válido: " + e.message);
@@ -0,0 +1,39 @@
1
+ /* Aurora Dev: luzes nos cantos, linha orbital e painéis de leitura opacos. A rede ambiente do runtime respeita
2
+ movimento reduzido, motion: none, troca de slide e exportação sem depender de animação CSS contínua. */
3
+ .th-aurora-dev .orn i:nth-child(1){display:block;right:-220px;top:-300px;width:920px;height:820px;border-radius:50%;background:radial-gradient(ellipse,color-mix(in srgb,var(--hi) 18%,transparent),transparent 66%)}
4
+ .th-aurora-dev .orn i:nth-child(2){display:block;left:-240px;bottom:-340px;width:860px;height:740px;border-radius:50%;background:radial-gradient(ellipse,color-mix(in srgb,var(--em) 12%,transparent),transparent 66%)}
5
+ .th-aurora-dev .ambient{opacity:.4!important;mask-image:linear-gradient(90deg,transparent 15%,#000)}
6
+ .th-aurora-dev .kicker{align-self:flex-start;color:var(--em);border-top:3px double var(--em);padding:14px 0 0;font-size:20px;letter-spacing:.16em}
7
+ .th-aurora-dev .L-cover:not(:has(.cv-fig)) .cv-main,.th-aurora-dev .L-end:not(:has(.en-fig)) .en-main{max-width:1390px;margin:0 auto;width:100%;gap:34px}
8
+ .th-aurora-dev :is(.L-cover,.L-end) .ttl{padding-bottom:32px;background:linear-gradient(90deg,var(--hi),var(--em),transparent) no-repeat left bottom/460px 3px}
9
+ .th-aurora-dev .L-cover .sub{max-width:1200px}
10
+ .th-aurora-dev .L-section{flex-direction:column;justify-content:center;align-items:flex-start;gap:28px}
11
+ .th-aurora-dev .L-section .sc-num{font-family:'IBM Plex Mono',monospace;font-size:72px;line-height:1.2;padding:12px 30px;background:var(--surface);border:1px solid var(--hi);border-radius:100px;color:var(--hi)}
12
+ .th-aurora-dev .L-section .sc-text{flex:none;width:100%;gap:24px}
13
+ .th-aurora-dev .L-section:has(.sc-fig){display:grid;grid-template-columns:1fr 500px;align-content:center;column-gap:50px}
14
+ .th-aurora-dev .L-section .sc-fig{grid-column:2;grid-row:1/span 2;width:500px;height:580px}
15
+ .th-aurora-dev .L-section .sc-num{justify-self:start}
16
+ .th-aurora-dev .hd{padding-bottom:22px;border-bottom:2px solid color-mix(in srgb,var(--hi) 40%,var(--line));gap:18px}
17
+ .th-aurora-dev :is(.card:not(.card-hi),.stat-card,.step-card){background:var(--surface);border:1px solid var(--line);border-radius:24px;box-shadow:inset 0 2px 0 color-mix(in srgb,var(--hi) 22%,transparent),0 16px 45px #0002}
18
+ .th-aurora-dev .cd-ico{border-radius:16px;background:color-mix(in srgb,var(--hi) 13%,var(--surface));color:var(--em)}
19
+ .th-aurora-dev .li-n{color:var(--em);border-bottom:2px solid var(--hi);padding-bottom:8px;font-family:'IBM Plex Mono',monospace}
20
+ .th-aurora-dev .li-b{width:14px;height:14px;background:var(--hi);border:0;border-radius:50%;box-shadow:0 0 0 6px color-mix(in srgb,var(--hi) 15%,transparent)}
21
+ .th-aurora-dev .L-quote{padding:36px 50px;border:1px solid var(--line);background:var(--surface);border-radius:24px}
22
+ .th-aurora-dev .q-mark{color:var(--em)}
23
+ .th-aurora-dev .st-body{padding-left:32px;border-left:3px solid var(--em)}
24
+ .th-aurora-dev .nb-val,.th-aurora-dev .st-val{color:var(--hi);font-family:'Space Grotesk',sans-serif}
25
+ /* É a superfície que separa a sintaxe das luzes; não use transparência no código. */
26
+ .th-aurora-dev :is(.code,.codewalk-editor,.api-req,.api-res){background:#131A30;color:#F1F2FF;border:1px solid #475071;border-radius:20px;box-shadow:0 24px 70px #080B2080}
27
+ .th-aurora-dev .codewalk-bar{background:#202A47;color:#F1F2FF;border-bottom-color:#475071}
28
+ .th-aurora-dev .codewalk-editor .code{color:#F1F2FF;border:0;box-shadow:none}
29
+ .th-aurora-dev :is(.codewalk-language,.codewalk-footer,.cn,.tok-comment,.api-code .cl.cm .cc){color:#B5BBD9}
30
+ .th-aurora-dev .codewalk-footer{border-top-color:#475071}
31
+ .th-aurora-dev .code .cl.hl,.th-aurora-dev .api-code .cl.run{background:#C4ADFF1A;box-shadow:inset 4px 0 #C4ADFF;color:#FFF}
32
+ .th-aurora-dev .tok-keyword,.th-aurora-dev .tok-type{color:#C4ADFF}.th-aurora-dev .tok-string,.th-aurora-dev .tok-operator{color:#83E7EF}.th-aurora-dev .tok-number{color:#FFDCA2}.th-aurora-dev .tok-function{color:#A9C8FF}.th-aurora-dev .tok-constant{color:#FFADCA}.th-aurora-dev .tok-punctuation{color:#D9DCF0}
33
+ .th-aurora-dev :is(.api-run,.lesson-controls button,.nav-back){border-radius:14px;font-family:'Space Grotesk',sans-serif}
34
+ .th-aurora-dev :is(.api-run,.lesson-controls button,.api-tabs button,.nav-back):focus-visible{outline:4px solid var(--em);outline-offset:5px}
35
+ .th-aurora-dev .api-tabs button{color:#B5BBD9}.th-aurora-dev .api-tabs button[aria-selected=true]{color:#F1F2FF}
36
+ .th-aurora-dev :is(.api-hint,.api-meta,.api-auth span,.api-tl-i){color:#B5BBD9}
37
+ .th-aurora-dev .lesson-output{border-top:3px solid var(--hi);border-radius:16px}
38
+ html[data-motion="none"] .th-aurora-dev .ambient,.export .th-aurora-dev .ambient{animation:none}
39
+ @media(prefers-reduced-motion:reduce){.th-aurora-dev .ambient{animation:none}}
@@ -0,0 +1,33 @@
1
+ /* Terminal Pro: cabeçalho compacto, moldura de IDE, menta e azul nas partes do código; animação ambiente
2
+ pela rede do runtime, que só anda no slide atual e fica parada na exportação e com movimento reduzido. */
3
+ .th-terminal-pro .orn i:nth-child(1){display:block;inset:0;background:linear-gradient(90deg,color-mix(in srgb,var(--hi) 6%,transparent) 1px,transparent 1px),linear-gradient(color-mix(in srgb,var(--hi) 6%,transparent) 1px,transparent 1px);background-size:64px 64px;mask-image:linear-gradient(90deg,transparent 38%,#000)}
4
+ .th-terminal-pro .orn i:nth-child(2){display:block;right:48px;top:48px;bottom:48px;width:2px;background:linear-gradient(transparent,var(--hi) 35%,var(--em) 65%,transparent);opacity:.35}
5
+ .th-terminal-pro .orn i:nth-child(3){display:block;right:44px;top:52px;width:10px;height:10px;border:2px solid var(--hi);background:var(--bg)}
6
+ .th-terminal-pro .ambient{opacity:.35!important}
7
+ .th-terminal-pro .kicker{align-self:flex-start;padding:12px 18px;background:var(--surface);color:var(--hi);border:1px solid var(--line);border-radius:7px;font-size:20px;letter-spacing:.1em}
8
+ .th-terminal-pro .L-cover .cv-main,.th-terminal-pro .L-end .en-main{border-left:5px solid var(--hi);padding-left:42px;gap:32px}
9
+ .th-terminal-pro .L-cover .ttl{max-width:1330px}
10
+ .th-terminal-pro .L-cover .sub{max-width:1220px}
11
+ .th-terminal-pro .L-section{gap:48px}
12
+ .th-terminal-pro .sc-num{font-family:'IBM Plex Mono',monospace;font-size:156px;line-height:1.2;padding:20px 26px;border:1px solid var(--hi);border-radius:12px;color:var(--hi);background:var(--surface)}
13
+ .th-terminal-pro .hd{padding:0 0 22px 22px;border-left:4px solid var(--hi);border-bottom:1px solid var(--line);gap:16px}
14
+ .th-terminal-pro :is(.card:not(.card-hi),.stat-card,.step-card){border:1px solid var(--line);border-top:4px solid var(--hi);background:var(--surface);border-radius:12px;box-shadow:0 12px 36px #0002}
15
+ .th-terminal-pro .li-n{font-family:'IBM Plex Mono',monospace;background:var(--surface);padding:8px 12px;border:1px solid var(--line);border-radius:8px;color:var(--hi)}
16
+ .th-terminal-pro .li-b{border:0;border-radius:3px;background:var(--hi);width:14px;height:14px}
17
+ .th-terminal-pro .q-text,.th-terminal-pro .st-body{border-left:4px solid var(--hi);padding-left:32px}
18
+ .th-terminal-pro .nb-val,.th-terminal-pro .st-val{font-family:'IBM Plex Mono',monospace;color:var(--hi)}
19
+ /* Código sempre numa superfície escura estável, inclusive com uma paleta clara aplicada por cima. */
20
+ .th-terminal-pro :is(.code,.codewalk-editor,.api-req,.api-res){background:#0D1720;color:#EAF5F1;border:1px solid #344B58;border-radius:12px;box-shadow:0 22px 60px #0003}
21
+ .th-terminal-pro .codewalk-bar{background:#172530;border-bottom-color:#344B58;color:#EAF5F1}
22
+ .th-terminal-pro .codewalk-editor .code{color:#EAF5F1;border:0;box-shadow:none}
23
+ .th-terminal-pro :is(.codewalk-language,.codewalk-footer,.cn,.tok-comment,.api-code .cl.cm .cc){color:#A4B8B4}
24
+ .th-terminal-pro .codewalk-footer{border-top-color:#344B58}
25
+ .th-terminal-pro .code .cl.hl,.th-terminal-pro .api-code .cl.run{background:#74E8B41A;box-shadow:inset 4px 0 #74E8B4;color:#F4FFF9}
26
+ .th-terminal-pro .tok-keyword,.th-terminal-pro .tok-constant{color:#CEB4FF}.th-terminal-pro .tok-string{color:#74E8B4}.th-terminal-pro .tok-number,.th-terminal-pro .tok-type{color:#FFCB8A}.th-terminal-pro .tok-function{color:#83CCF8}.th-terminal-pro .tok-operator,.th-terminal-pro .tok-punctuation{color:#C1D6E2}
27
+ .th-terminal-pro :is(.api-run,.lesson-controls button,.nav-back){border-radius:8px;font-family:'Space Grotesk',sans-serif}
28
+ .th-terminal-pro :is(.api-run,.lesson-controls button,.api-tabs button,.nav-back):focus-visible{outline:4px solid var(--em);outline-offset:5px}
29
+ .th-terminal-pro .api-tabs button{color:#A4B8B4}.th-terminal-pro .api-tabs button[aria-selected=true]{color:#EAF5F1}
30
+ .th-terminal-pro :is(.api-hint,.api-meta,.api-auth span,.api-tl-i){color:#A4B8B4}
31
+ .th-terminal-pro .lesson-output{border-left:4px solid var(--hi);border-radius:8px}
32
+ html[data-motion="none"] .th-terminal-pro .ambient,.export .th-terminal-pro .ambient{animation:none}
33
+ @media(prefers-reduced-motion:reduce){.th-terminal-pro .ambient{animation:none}}
@@ -105,8 +105,23 @@ _${c.naoConfirmados.length} observação(ões) descartada(s): o trecho citado n
105
105
  res.on('close', onClose);
106
106
  // Capture o arquivo da tarefa: abrir outro deck não redireciona a escrita.
107
107
  const taskWorkspace = { ...owner, spec: structuredClone(owner.spec) };
108
+ const originFile = owner.file, originPreview = owner.preview;
109
+ let materialized = null;
108
110
  const prompt = body.message || "";
109
111
  const spec = body.spec || taskWorkspace.spec;
112
+ // Conversar com uma prévia não cria arquivo. Edição/imagem ou documento binário precisam da cópia pessoal.
113
+ const imageOptionsFor = (deck = spec) => {
114
+ const options = imageOptions(taskWorkspace, withBase(taskWorkspace, deck));
115
+ if (originPreview && !originFile && taskWorkspace.file && !materialized) {
116
+ materialized = { id: taskWorkspace.library.idOf(taskWorkspace.file), title: taskWorkspace.spec.title,
117
+ topic: originPreview.topic, file: taskWorkspace.file };
118
+ if (owner.file === originFile && owner.preview === originPreview) {
119
+ owner.file = taskWorkspace.file;
120
+ owner.preview = null;
121
+ }
122
+ }
123
+ return materialized ? { ...options, baseDir: path.dirname(taskWorkspace.file) } : options;
124
+ };
110
125
  const issues = body.issues || [];
111
126
  // a IA vê (e testa) os slides api no ambiente atual, que pode ser o embutido "ensaio"
112
127
  if (!apiBlocked(req) && (spec.slides || []).some((sl) => sl && sl.layout === "api")) await ensureEnsaio();
@@ -128,8 +143,11 @@ _${c.naoConfirmados.length} observação(ões) descartada(s): o trecho citado n
128
143
  const target = typeof body.targetSlide === "number" ? body.targetSlide : null;
129
144
  const visuals = await lookAt(withBase(taskWorkspace, spec), target, prompt, emit);
130
145
  // anexos: imagens vão como visão; documentos (id do /api/ai/context) vão como texto
131
- let materials = await prepareDocumentMaterials(takeMaterials(taskWorkspace, (Array.isArray(body.attachments) ? body.attachments : [])
132
- .filter((a) => a && typeof a === "object" && a.type === "doc").map((a) => a.id)), path.dirname(taskWorkspace.file), {signal,onProgress:emit});
146
+ let materials = takeMaterials(taskWorkspace, (Array.isArray(body.attachments) ? body.attachments : [])
147
+ .filter((a) => a && typeof a === "object" && a.type === "doc").map((a) => a.id));
148
+ if (!taskWorkspace.file && taskWorkspace.preview && materials.some(m => m.bytes)) imageOptionsFor();
149
+ const materialDir = taskWorkspace.file && !isBundledTemplate(taskWorkspace.file) ? path.dirname(taskWorkspace.file) : null;
150
+ if (materialDir) materials = await prepareDocumentMaterials(materials, materialDir, {signal,onProgress:emit});
133
151
  for (const [i, url] of (Array.isArray(body.attachments) ? body.attachments : []).entries()) {
134
152
  if (typeof url === "string" && url.startsWith("data:image/")) visuals.push({ label: `imagem colada pelo usuário ${i + 1}`, dataUrl: url });
135
153
  }
@@ -154,7 +172,7 @@ _${c.naoConfirmados.length} observação(ões) descartada(s): o trecho citado n
154
172
  targetSlide: target,
155
173
  issues,
156
174
  images: true, // a IA decide (regra no prompt: só quando pedirem ou aceitarem)
157
- imageOptions: imageOptions(taskWorkspace, withBase(taskWorkspace, spec)),
175
+ imageOptions: () => imageOptionsFor(),
158
176
  history,
159
177
  onProgress: emit,
160
178
  visuals,
@@ -212,6 +230,7 @@ _${c.naoConfirmados.length} observação(ões) descartada(s): o trecho citado n
212
230
  result.actions = [...(before.actions || []), ...r.feitos.map((f) => `Mapa: gravei ${f.arquivo} (${f.linhas} ${f.tipo === "contorno" ? "contorno" : "itens"}${f.fonte ? `; fonte: ${f.fonte}` : ""})`), ...r.falhas.map((x) => `Mapa: não deu (${x})`), ...(result.actions || [])];
213
231
  }
214
232
  if (result.geo?.length) result = { ...result, geo: undefined, talk: true, reply: P ? `${result.reply}\n(Já busquei os dados duas vezes; me diga o que ainda falta.)` : "Os dados do mapa ficam na pasta da apresentação: abra-a pela biblioteca e peça de novo." };
233
+ if (!taskWorkspace.file && taskWorkspace.preview && !result.talk && !result.variants) imageOptionsFor(result.spec);
215
234
  // documento anexado NESTA mensagem e a resposta montou/reescreveu a maior parte do deck (criar a apresentação
216
235
  // do paper pelo chat): a mesma conferência de cobertura da geração. Pedido pontual ("use a figura 3 no slide
217
236
  // 5") não dispara: mexe em poucos slides.
@@ -220,12 +239,12 @@ _${c.naoConfirmados.length} observação(ões) descartada(s): o trecho citado n
220
239
  const rewritten = Array.isArray(result.changed) ? result.changed.length : 0;
221
240
  if (rewritten >= Math.max(5, result.spec.slides.length / 2)) {
222
241
  const covered = await coverDocumentVisuals(result.spec, { materials, briefing: prompt, say: (s) => emit({ phase: "step", text: s }),
223
- edit: (o) => editDeck({ signal, images: true, imageOptions: imageOptions(taskWorkspace, withBase(taskWorkspace, spec)), onProgress: emit, materials, drawCheck: diagramCheck, ...o }) });
242
+ edit: (o) => editDeck({ signal, images: true, imageOptions: () => imageOptionsFor(), onProgress: emit, materials, drawCheck: diagramCheck, ...o }) });
224
243
  signal.throwIfAborted();
225
244
  if (covered.spec !== result.spec) { result.spec = covered.spec; result.actions = [...(result.actions || []), ...covered.actions]; }
226
245
  if (covered.coverage?.missing.length) result.actions = [...(result.actions || []), `Sem slide (figuras/tabelas do material): ${covered.coverage.missing.join("; ")}`];
227
246
  const checked = await checkNumbers(result.spec, { materials, say: (s) => emit({ phase: "step", text: s }),
228
- edit: (o) => editDeck({ signal, images: true, imageOptions: imageOptions(taskWorkspace, withBase(taskWorkspace, spec)), onProgress: emit, materials, drawCheck: diagramCheck, ...o }) });
247
+ edit: (o) => editDeck({ signal, images: true, imageOptions: () => imageOptionsFor(), onProgress: emit, materials, drawCheck: diagramCheck, ...o }) });
229
248
  signal.throwIfAborted();
230
249
  if (checked.spec !== result.spec) { result.spec = checked.spec; result.actions = [...(result.actions || []), ...checked.actions]; }
231
250
  if (checked.facts?.unsupported.length) result.actions = [...(result.actions || []), `Números sem base no material: ${checked.facts.unsupported.map((b) => `${b.number} (slide ${b.slide})`).join(", ")}`];
@@ -264,7 +283,7 @@ _${c.naoConfirmados.length} observação(ões) descartada(s): o trecho citado n
264
283
  emit({ phase: "test", text: reports.every((x) => x.ok) ? "Os testes passaram; conferindo…" : "Corrigindo com base no resultado…" });
265
284
  const next = await editDeck({
266
285
  spec: withBase(taskWorkspace, result.spec), signal, instruction, targetSlide: target, images: false,
267
- imageOptions: imageOptions(taskWorkspace, withBase(taskWorkspace, result.spec)), history: convo, onProgress: emit, apiContext: apiContextFor(req, taskWorkspace),
286
+ imageOptions: () => imageOptionsFor(result.spec), history: convo, onProgress: emit, apiContext: apiContextFor(req, taskWorkspace),
268
287
  drawCheck: diagramCheck, runCommand: commandRunner(req, emit, body, taskWorkspace, signal),
269
288
  });
270
289
  convo.push({ role: "user", text: instruction });
@@ -286,7 +305,7 @@ _${c.naoConfirmados.length} observação(ões) descartada(s): o trecho citado n
286
305
  persist(taskWorkspace);
287
306
  if (owner.file === taskWorkspace.file) owner.spec = taskWorkspace.spec;
288
307
  return { ...result, spec: taskWorkspace.spec, conflicts: merged.conflicts, kept: merged.kept };
289
- }, signal)); } finally { owner.aiChats.delete(requestId); res.off("close", onClose); }
308
+ }, signal).then(result => materialized ? { ...result, materialized } : result)); } finally { owner.aiChats.delete(requestId); res.off("close", onClose); }
290
309
  return true;
291
310
  }
292
311
  return false;
@@ -0,0 +1,46 @@
1
+ // Bases curtas para começar uma apresentação técnica sem esperar pela IA.
2
+ // São modelos de fábrica; cada uso cria uma instância pela biblioteca.
3
+ export function developerStarter(kind, { title } = {}) {
4
+ const code = 'function normalizar(nome) {\n return nome.trim().toLowerCase();\n}\n\nconst usuario = normalizar(" Ada ");\nconsole.log(usuario);';
5
+ const curl = ['curl --request POST "$BASE/echo" \\', ' --header "Content-Type: application/json" \\', ' --data \'{"mensagem":"Olá, API"}\''].join("\n");
6
+ if (!["programacao", "apis"].includes(kind)) throw new Error("Escolha uma base de programação ou APIs com cURL.");
7
+ const programming = kind === "programacao";
8
+ const name = String(title || (programming ? "Meu código, passo a passo" : "Minha primeira API com cURL")).trim();
9
+ return {
10
+ title: name, theme: programming ? "terminal-pro" : "aurora-dev", duration: 6, purpose: "palestra", maxWords: 70,
11
+ ...(!programming ? { env: "ensaio" } : {}),
12
+ slides: [
13
+ { layout: "cover", kicker: "Exemplo para editar", title: name,
14
+ subtitle: programming ? "Entrada, transformação e resultado" : "Entenda o comando e experimente uma chamada",
15
+ figure: { motion: { type: "console", caption: programming ? "normalizar.js · saída de exemplo" : "curl · saída de exemplo", scenes: [{ cmd: programming ? "node normalizar.js" : 'curl --request POST "$BASE/echo"', out: programming ? ["ada"] : ['200 OK', '{ "mensagem": "Olá, API" }'] }] } },
16
+ notes: "Este é um exemplo de fábrica para você editar. Use Inserir › Código / cURL para colar seu conteúdo. A animação da capa mostra uma saída ilustrativa." },
17
+ programming ? {
18
+ layout: "codewalk", title: "Uma transformação em ==três passos==", filename: "normalizar.js", language: "JavaScript", code,
19
+ steps: [
20
+ { title: "Definir a regra", text: "A função recebe um nome. trim remove os espaços nas pontas; toLowerCase converte as letras para minúsculas.", highlight: [1, 2, 3] },
21
+ { title: "Aplicar à entrada", text: "Os espaços e as letras maiúsculas entram no mesmo exemplo.", highlight: [5] },
22
+ { title: "Conferir a saída", text: "O resultado pode ser copiado e usado no restante do programa.", highlight: [6], output: "ada" },
23
+ ], notes: "Troque o código em Formatar. Selecione as linhas e crie uma etapa para explicar o trecho. A saída deste guia é ilustrativa, sem executar o código."
24
+ } : {
25
+ layout: "codewalk", title: "O cURL em ==três partes==", filename: "pedido.sh", language: "Bash", code: curl,
26
+ steps: [
27
+ { title: "Método e destino", text: "POST envia os dados para o endpoint. BASE é o endereço do ambiente.", highlight: [1] },
28
+ { title: "Formato dos dados", text: "Content-Type avisa ao servidor que o corpo é JSON.", highlight: [2] },
29
+ { title: "O corpo enviado", text: "A mensagem é o dado que vamos conferir na resposta.", highlight: [3] },
30
+ ], notes: "Este comando explica a chamada, sem executá-la. O próximo slide chama a API de ensaio do Studio local."
31
+ },
32
+ programming ? {
33
+ layout: "diagram", title: "Veja o ==caminho== dos dados", mermaid: 'flowchart LR\n A["Entrada: espaços e maiúsculas"] --> B["trim + toLowerCase"] --> C["Saída: ada"]',
34
+ notes: "Troque os nós pelo fluxo do seu programa. Use a mesma entrada e a mesma saída em toda a explicação."
35
+ } : {
36
+ layout: "api", title: "Experimente ==ao vivo==", text: "No Studio local, clique em Executar. Troque a mensagem no Corpo e execute novamente.", tab: "curl",
37
+ request: { method: "POST", url: "{{base}}/echo", auth: false, body: { mensagem: "Olá, API" } }, answer: "$.recebido",
38
+ notes: "Ambiente ENSAIO: API de mentira que o Studio local fornece. No servidor multiusuário, chamadas não são executadas. Para usar seu serviço, troque o ambiente e importe seu cURL por Inserir › Código / cURL."
39
+ },
40
+ { layout: "code", title: programming ? "Conferir a ==entrada e saída==" : "Ler a ==resposta==", filename: "resposta.json", language: "JSON",
41
+ code: programming ? '{\n "entrada": " Ada ",\n "saida": "ada"\n}' : '{\n "metodo": "POST",\n "recebido": { "mensagem": "Olá, API" },\n "consulta": {}\n}',
42
+ note: programming ? "Mantenha um exemplo concreto do começo ao fim da explicação." : "O campo recebido contém os mesmos dados enviados no corpo." },
43
+ { layout: "end", title: "Agora, o ==seu exemplo==", subtitle: "Cole seu conteúdo em Inserir › Código / cURL ou peça ao assistente para explicar os trechos.", notes: "Você pode aplicar outro tema em Design. O exemplo é seu para editar; cada uso cria uma apresentação independente." },
44
+ ],
45
+ };
46
+ }
@@ -9,6 +9,7 @@ import { writeDeckFile } from "../deck-file.js";
9
9
  import { createExperienceDeck } from "../experiences.js";
10
10
  import { demoDeck } from "./demo-decks.js";
11
11
  import { DEMO_FILES } from "../api-demo.js";
12
+ import { developerStarter } from "./developer-starters.js";
12
13
 
13
14
  export async function libraryRoutes({ req, res, pathname, url, W, opts, readJSON, readBody, slideSnapshots, sendExport, galleryKey, galleryEntry, materializePreview, copyModelAssets, generateIntoLibrary, llmAvailable, llmConfig, respond, templateFile, TEMPLATE_DIRS }) {
14
15
  const L = W.library;
@@ -74,6 +75,12 @@ export async function libraryRoutes({ req, res, pathname, url, W, opts, readJSON
74
75
  case "/api/library/topics/update": return ok({ id: L.updateTopic(b.id, b) });
75
76
  case "/api/library/topics/delete": L.deleteTopic(b.id); return ok();
76
77
  case "/api/library/decks": {
78
+ if (b.starter) {
79
+ const spec = developerStarter(b.starter, { title: b.title });
80
+ if (b.theme) spec.theme = b.theme;
81
+ buildHTML(spec);
82
+ return ok({ id: L.createDeck(b.topic || "", spec) });
83
+ }
77
84
  // nova: em branco (uma capa) — "com IA" usa /api/library/decks/ai
78
85
  const title = String(b.title || "Nova apresentação").trim();
79
86
  const id = L.createDeck(b.topic || "", { title, theme: b.theme || "bauhaus", duration: 10,
@@ -0,0 +1,68 @@
1
+ // "Sobre o SagaDeck" (Ajuda › Sobre, no editor; botão Sobre, na biblioteca): a versão que está rodando e o essencial
2
+ // para dar suporte (commit, Node, desde quando, a biblioteca, se a IA está configurada), com Copiar informações.
3
+ window.SagaAbout = (() => {
4
+ // as cores vêm das variáveis que o editor e a biblioteca definem nos dois temas (claro e escuro); fundo e texto
5
+ // sempre do mesmo par, senão o tema escuro dá texto claro sobre fundo branco
6
+ const css = `.about-dlg{margin:auto;max-width:min(520px,calc(100vw - 32px));width:100%;border:1px solid var(--ui-line,#c9ced6);border-radius:12px;padding:20px 22px;background:var(--chrome,#fff);color:var(--text,#1d2433);font:14px/1.45 system-ui,sans-serif;box-shadow:0 20px 60px #0004}
7
+ .about-dlg::backdrop{background:rgba(0,0,0,.45)}.about-dlg h2{margin:0;font-size:20px;color:var(--text,#1d2433)}.about-dlg .ver{margin:2px 0 14px;font-size:15px;color:var(--text-2,#555)}
8
+ .about-dlg .ver b{color:var(--text,#1d2433);font-size:22px}.about-dlg dl{display:grid;grid-template-columns:auto 1fr;gap:6px 14px;margin:0 0 14px}
9
+ .about-dlg dt{color:var(--text-2,#555)}.about-dlg dd{margin:0;overflow-wrap:anywhere;color:var(--text,#1d2433)}.about-dlg code{font:12px ui-monospace,Consolas,monospace;color:inherit}
10
+ .about-dlg .links{display:flex;gap:14px;flex-wrap:wrap;margin:0 0 6px}.about-dlg .links a{color:var(--brand,#5b4bdb)}
11
+ .about-dlg .row{display:flex;gap:8px;justify-content:flex-end;margin-top:14px}.about-dlg button{padding:7px 14px;border-radius:8px;border:1px solid var(--ui-line,#c9ced6);background:transparent;color:var(--text,#1d2433);cursor:pointer;font:inherit}
12
+ .about-dlg button.primary{background:var(--brand-fill,#5b4bdb);border-color:var(--brand-fill,#5b4bdb);color:#fff}.about-dlg [data-copied]{color:var(--text-2,#555);align-self:center;margin-right:auto}`;
13
+ const REPO = "https://github.com/naruminho/sagadeck";
14
+ const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);
15
+ let dialog;
16
+
17
+ function build() {
18
+ if (!document.getElementById("about-style")) { const st = document.createElement("style"); st.id = "about-style"; st.textContent = css; document.head.append(st); }
19
+ dialog = document.createElement("dialog");
20
+ dialog.className = "about-dlg";
21
+ dialog.id = "about-dialog";
22
+ dialog.setAttribute("aria-labelledby", "about-title");
23
+ document.body.append(dialog);
24
+ }
25
+
26
+ // o que o servidor diz de si (versão, commit, Node, desde quando) e se a IA está configurada
27
+ async function facts() {
28
+ const get = async (u) => { try { const r = await fetch(u, { cache: "no-store" }); return r.ok ? r.json() : {}; } catch { return {}; } };
29
+ const [inst, ia] = await Promise.all([get("api/instance"), get("api/ia")]);
30
+ return { ...inst, ia: ia.configured == null ? null : !!ia.configured };
31
+ }
32
+ const text = (f) => [`SagaDeck ${f.version || "?"} (commit ${f.commit || "?"})`, `Node ${f.node || "?"} · ${f.platform || "?"}`,
33
+ f.started ? `No ar desde ${new Date(f.started).toLocaleString("pt-BR")} (processo ${f.pid || "?"})` : "",
34
+ f.multiuser ? "Servidor multiusuário" : f.library ? `Biblioteca: ${f.library}` : "",
35
+ f.ia == null ? "" : `IA: ${f.ia ? "configurada" : "não configurada"}`].filter(Boolean).join("\n");
36
+
37
+ async function open() {
38
+ if (!dialog) build();
39
+ dialog.innerHTML = `<h2 id="about-title">Sobre o SagaDeck</h2><p class="ver">Carregando…</p><div class="row"><button type="button" data-close>Fechar</button></div>`;
40
+ dialog.querySelector("[data-close]").onclick = () => dialog.close();
41
+ if (!dialog.open) dialog.showModal();
42
+ const f = await facts();
43
+ const row = (k, v) => (v ? `<dt>${k}</dt><dd>${v}</dd>` : "");
44
+ dialog.innerHTML = `<h2 id="about-title">Sobre o SagaDeck</h2>
45
+ <p class="ver">Versão <b data-version>${esc(f.version || "?")}</b></p>
46
+ <dl>
47
+ ${row("Commit", f.commit ? `<code>${esc(f.commit)}</code>` : "")}
48
+ ${row("Node", f.node ? `${esc(f.node)} · ${esc(f.platform || "")}` : "")}
49
+ ${row("No ar desde", f.started ? esc(new Date(f.started).toLocaleString("pt-BR")) : "")}
50
+ ${row("Biblioteca", !f.multiuser && f.library ? `<code>${esc(f.library)}</code>` : f.multiuser ? "neste servidor (multiusuário)" : "")}
51
+ ${row("IA", f.ia == null ? "" : f.ia ? "configurada (para testar cada recurso: <code>sagadeck ia-check</code>)" : "não configurada")}
52
+ </dl>
53
+ <div class="links">
54
+ <a href="${REPO}/blob/main/CHANGELOG.md" target="_blank" rel="noopener">O que mudou em cada versão</a>
55
+ <a href="${REPO}" target="_blank" rel="noopener">Código e problemas</a>
56
+ <a href="https://www.npmjs.com/package/sagadeck" target="_blank" rel="noopener">Pacote no npm</a>
57
+ </div>
58
+ <div class="row"><span data-copied hidden>Copiado.</span><button type="button" data-copy>Copiar informações</button><button type="button" class="primary" data-close>Fechar</button></div>`;
59
+ dialog.querySelector("[data-close]").onclick = () => dialog.close();
60
+ dialog.querySelector("[data-copy]").onclick = async () => {
61
+ try { await navigator.clipboard.writeText(text(f)); dialog.querySelector("[data-copied]").hidden = false; }
62
+ catch { dialog.querySelector("[data-copied]").textContent = "Não deu para copiar."; dialog.querySelector("[data-copied]").hidden = false; }
63
+ };
64
+ }
65
+ // qualquer botão ou link com data-about abre (Ajuda › Sobre no editor, o botão da biblioteca)
66
+ document.addEventListener("click", (e) => { const b = e.target.closest?.("[data-about]"); if (b) { e.preventDefault(); open(); } });
67
+ return { open };
68
+ })();
@@ -2156,6 +2156,7 @@
2156
2156
  showToast("Screenshot pronto. Use Apresentar para percorrer os destaques.");
2157
2157
  }
2158
2158
  function setupCreativeTools() {
2159
+ window.SagaDeveloperAuthoring.bind({ state, syncDeckToServer, renderThumbnails, renderCurrentSlide, openPane, showToast });
2159
2160
  const shotButton = document.createElement("button");
2160
2161
  shotButton.id = "btn-screenshot";
2161
2162
  shotButton.className = "rbtn rbtn-lg";
@@ -3706,7 +3707,7 @@ ${ta.value}`;
3706
3707
 
3707
3708
  // Chat lateral com IA (chat.js): mesma API, agora em módulo próprio.
3708
3709
  const chatApi = window.SagaChat({ state, dom, escHtml, hydrateIcons, showToast, openPane, currentPane,
3709
- streamAI, syncDeckToServer, renderThumbnails, renderCurrentSlide, trackDeck, fitRendered, refreshAIStatus });
3710
+ streamAI, syncDeckToServer, renderThumbnails, renderCurrentSlide, trackDeck, fitRendered, refreshAIStatus, previewMaterialized });
3710
3711
  const { handleChatSubmit, loadChatHistory, appendChatMessage, createProgressBubble, autoGrowChat, applyBrainstorm, addChatFile } = chatApi;
3711
3712
 
3712
3713
  function setNotesVisible(on) {
@@ -2,7 +2,7 @@
2
2
  // Saiu do app.js pela trava de tamanho dos monolitos; o comportamento é o mesmo.
3
3
  // Fábrica no molde de slide-select.js: recebe o estado e os ajudantes, devolve a API.
4
4
  window.SagaChat = function ({ state, dom, escHtml, hydrateIcons, showToast, openPane, currentPane,
5
- streamAI, syncDeckToServer, renderThumbnails, renderCurrentSlide, trackDeck, fitRendered, refreshAIStatus }) {
5
+ streamAI, syncDeckToServer, renderThumbnails, renderCurrentSlide, trackDeck, fitRendered, refreshAIStatus, previewMaterialized }) {
6
6
  let chatJob = null;
7
7
  const getChatJob = () => chatJob;
8
8
  // ==========================================================================
@@ -159,6 +159,10 @@ window.SagaChat = function ({ state, dom, escHtml, hydrateIcons, showToast, open
159
159
  }, (ev) => { if (!commandEvent(ev)) work.update(ev); }, job.controller.signal);
160
160
  if (state.file !== job.file) { work.done(); showToast("O pedido terminou na apresentação em que começou; a apresentação atual foi preservada.", 6000); return; }
161
161
  if (!data.spec) throw new Error(data.error || "resposta sem deck");
162
+ if (data.materialized) {
163
+ state.file = data.materialized.file;
164
+ previewMaterialized(data.materialized);
165
+ }
162
166
  // conversa salva antes de dispensar o indicador: quem espera o fim da resposta já encontra o arquivo gravado
163
167
  const finishWork = async () => { await saveChatHistory(); work.done(); };
164
168
  if (data.variants) {
@@ -0,0 +1,12 @@
1
+ /* Conteúdo técnico em um só lugar: fonte à esquerda, resultado à direita. */
2
+ .dev-author-dialog{margin:auto;width:min(1110px,96vw);max-width:96vw;height:min(810px,94dvh);max-height:94dvh;padding:0;border:1px solid var(--ui-line);border-radius:18px;background:var(--chrome);color:var(--text);box-shadow:0 24px 90px #0005;font:13px var(--font-sans)}
3
+ .dev-author-dialog[open]{display:flex;flex-direction:column}.dev-author-dialog::backdrop{background:#101521aa;backdrop-filter:blur(4px)}
4
+ .dev-author-dialog header{display:flex;justify-content:space-between;align-items:center;padding:20px 25px;border-bottom:1px solid var(--ui-line)}.dev-author-dialog header small{color:var(--brand);font-size:10px;font-weight:700;letter-spacing:.13em}.dev-author-dialog h2{margin:5px 0;font-size:25px;letter-spacing:-.035em}.dev-author-dialog header p{margin:5px 0 0;color:var(--text-2)}
5
+ .dev-author-dialog button{padding:9px 12px;border:1px solid var(--ui-line-strong);border-radius:8px;background:var(--chrome);color:var(--text);font:inherit;cursor:pointer}.dev-author-dialog button:hover{background:var(--hover)}.dev-author-dialog button:disabled{opacity:.45;cursor:default}.dev-author-dialog button:focus-visible,.dev-author-dialog input:focus-visible,.dev-author-dialog select:focus-visible,.dev-author-dialog textarea:focus-visible{outline:2px solid var(--brand);outline-offset:2px}
6
+ .dev-author-work{display:grid;grid-template-columns:minmax(0,1fr) 340px;min-height:0;flex:1}.dev-author-source{display:flex;flex-direction:column;min-height:0;padding:22px 24px;gap:10px}.dev-author-dialog label{display:block;color:var(--text-2);font-size:12px}.dev-author-dialog input:not([type=radio]),.dev-author-dialog textarea,.dev-author-dialog select{display:block;width:100%;margin-top:6px;padding:9px 10px;border:1px solid var(--ui-line-strong);border-radius:7px;background:var(--chrome);color:var(--text);font:13px var(--font-sans)}
7
+ .dev-author-dialog #dev-source{flex:1;min-height:150px;margin-top:0;resize:none;white-space:pre;tab-size:2;font:13px/1.65 var(--font-mono,monospace);background:var(--canvas-bg)}.dev-author-meta{display:grid;grid-template-columns:1fr 1fr;gap:10px}.dev-author-meta>label:first-child{grid-column:1/-1}.dev-author-dialog small{font-size:10px}.dev-author-dialog aside{padding:22px;overflow-y:auto;border-left:1px solid var(--ui-line)}.dev-author-dialog fieldset{border:0;padding:0;margin:0 0 22px}.dev-author-dialog legend{font-size:12px;font-weight:700;color:var(--text);margin-bottom:12px}
8
+ .dev-author-modes label{display:flex;align-items:flex-start;gap:9px;padding:12px;margin-bottom:8px;border:1px solid var(--ui-line);border-radius:9px;cursor:pointer}.dev-author-modes label:has(input:checked){background:var(--selected);border-color:var(--brand)}.dev-author-modes input{margin-top:3px;accent-color:var(--brand)}.dev-author-modes b{display:block;color:var(--text);font-size:13px}.dev-author-modes small{display:block;line-height:1.5;margin-top:4px;color:var(--text-2)}
9
+ .dev-author-dialog h3{font-size:13px;margin-bottom:9px}.dev-author-dialog .dev-author-tip{font-size:11px;line-height:1.5;color:var(--text-2);margin:0 0 10px}.dev-author-range{display:grid;grid-template-columns:1fr 1fr;gap:10px}.dev-author-dialog [data-guide]>label{margin:10px 0}.dev-author-dialog [data-step-text]{resize:vertical;max-height:150px}.dev-author-dialog [data-add-step]{width:100%;margin:4px 0 12px}.dev-author-steps{display:grid;gap:6px}.dev-author-step{display:flex;gap:4px}.dev-author-step button:first-child{flex:1;min-width:0;text-align:left;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:11px}.dev-author-step button.active{border-color:var(--brand);color:var(--brand)}.dev-author-step button:last-child{font-size:10px;padding:7px}
10
+ .dev-author-dialog [data-request]{max-height:210px;overflow:auto;border:1px solid var(--ui-line);padding:12px;border-radius:8px;background:var(--canvas-bg);font:11px/1.6 var(--font-mono,monospace);white-space:pre-wrap;overflow-wrap:anywhere;margin-bottom:12px}.dev-author-secrets{padding:12px;border:1px solid var(--brand);border-radius:8px;color:var(--text);background:var(--selected);line-height:1.6;font-size:11px;overflow-wrap:anywhere}.dev-author-dialog [hidden]{display:none!important}
11
+ .dev-author-dialog footer{display:flex;align-items:center;justify-content:flex-end;gap:9px;padding:14px 24px;border-top:1px solid var(--ui-line)}.dev-author-dialog [data-status]{flex:1;font-size:11px;line-height:1.5;color:var(--text-2)}.dev-author-dialog .dev-author-primary{background:var(--brand);color:#fff;border-color:var(--brand)}.dev-author-dialog .dev-author-primary:hover{filter:brightness(1.08)}
12
+ @media(max-width:760px){.dev-author-dialog{height:94dvh}.dev-author-work{display:flex;flex-direction:column;overflow-y:auto}.dev-author-source{min-height:390px;flex-shrink:0;padding:16px}.dev-author-dialog aside{border-left:0;border-top:1px solid var(--ui-line);overflow:visible;padding:16px}.dev-author-dialog header{padding:15px 16px}.dev-author-dialog h2{font-size:21px}.dev-author-dialog footer{padding:12px 16px;flex-wrap:wrap}.dev-author-dialog [data-status]{flex-basis:100%}}