sagadeck 1.3.1 → 1.5.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 (53) hide show
  1. package/README.md +4 -4
  2. package/bin/sagadeck.js +9 -1
  3. package/docs/ANDAMENTO.md +376 -0
  4. package/docs/REFERENCIA.md +4 -4
  5. package/package.json +1 -1
  6. package/src/ai/bench.js +132 -0
  7. package/src/ai/context.js +12 -3
  8. package/src/ai/critique.js +114 -0
  9. package/src/ai/deck-ai.js +234 -30
  10. package/src/ai/document-materials.js +2 -2
  11. package/src/ai/document-visuals.js +400 -20
  12. package/src/ai/llm.js +19 -5
  13. package/src/ai/progress.js +23 -0
  14. package/src/ai/quality.js +60 -0
  15. package/src/api-client.js +2 -1
  16. package/src/build.js +5 -1
  17. package/src/decision-lab.js +2 -1
  18. package/src/deck-file.js +2 -1
  19. package/src/elements.js +44 -7
  20. package/src/figures/charts.js +7 -4
  21. package/src/figures/diagrams.js +15 -1
  22. package/src/figures/ufmap.js +2 -1
  23. package/src/fs-retry.js +19 -0
  24. package/src/import/crop.js +27 -8
  25. package/src/layouts.js +22 -2
  26. package/src/library.js +68 -16
  27. package/src/locale.js +18 -0
  28. package/src/mcp/server.js +2 -2
  29. package/src/motion.js +4 -2
  30. package/src/preferences.js +2 -1
  31. package/src/runtime/base.css +3 -0
  32. package/src/runtime/calc.js +3 -3
  33. package/src/runtime/decision-lab.js +1 -1
  34. package/src/runtime/fit.js +20 -1
  35. package/src/runtime/runtime.js +2 -1
  36. package/src/studio/ai-chat-route.js +119 -4
  37. package/src/studio/code-version.js +35 -0
  38. package/src/studio/generate.js +53 -0
  39. package/src/studio/project.js +8 -7
  40. package/src/studio/public/app.js +7 -216
  41. package/src/studio/public/chat.js +71 -1
  42. package/src/studio/public/header-footer.js +140 -0
  43. package/src/studio/public/index.html +5 -0
  44. package/src/studio/public/library.html +1 -0
  45. package/src/studio/public/library.js +4 -3
  46. package/src/studio/public/preferences-ui.js +83 -0
  47. package/src/studio/public/provenance.js +13 -0
  48. package/src/studio/public/style.css +17 -0
  49. package/src/studio/public/ui-icons.js +1 -1
  50. package/src/studio/public/update-notice.js +25 -0
  51. package/src/studio/public-files.js +11 -1
  52. package/src/studio/server.js +20 -29
  53. package/src/studio/share-routes.js +2 -1
@@ -6,6 +6,7 @@
6
6
  // Protegida: só some junto com o projeto inteiro. Nada disso vai no pacote .sagadeck
7
7
  // (src/package.js leva só o que o deck usa).
8
8
  import fs from "node:fs";
9
+ import { renameRetry } from "../fs-retry.js";
9
10
  import path from "node:path";
10
11
  import crypto from "node:crypto";
11
12
  import JSZip from "jszip";
@@ -82,7 +83,7 @@ export function ensureMeta(P) {
82
83
  fs.mkdirSync(path.join(P.dir, CONTEXT), { recursive: true }); // à vista: é aqui que moram anexos, prints e planilhas
83
84
  const conv = path.join(P.dir, META, "conversa.json"), legacy = P.file.replace(/\.ya?ml$/i, ".conversa.json");
84
85
  // conversa antiga (ao lado do deck) passa para dentro do projeto
85
- if (fs.existsSync(legacy) && !fs.existsSync(conv)) { try { fs.renameSync(legacy, conv); } catch {} }
86
+ if (fs.existsSync(legacy) && !fs.existsSync(conv)) { try { renameRetry(legacy, conv); } catch {} }
86
87
  return conv;
87
88
  }
88
89
  export const conversationFile = (P) => ensureMeta(P);
@@ -101,7 +102,7 @@ export function writeText(P, rel, text) {
101
102
  fs.mkdirSync(path.dirname(abs), { recursive: true });
102
103
  const tmp = `${abs}.tmp-${process.pid}`;
103
104
  fs.writeFileSync(tmp, String(text ?? ""), "utf8");
104
- fs.renameSync(tmp, abs);
105
+ renameRetry(tmp, abs);
105
106
  return { path: r };
106
107
  }
107
108
  export function createFile(P, dirRel, name, text = "") {
@@ -136,7 +137,7 @@ export function rename(P, fromRel, toName) {
136
137
  const to = resolveIn(P, path.posix.join(path.posix.dirname(r), n));
137
138
  if (fs.existsSync(to)) throw new Error(`Já existe "${n}" nessa pasta.`);
138
139
  if (isMeta(relOf(P, to))) throw new Error(`A pasta ${META} é do Studio.`);
139
- fs.renameSync(abs, to);
140
+ renameRetry(abs, to);
140
141
  return { path: relOf(P, to) };
141
142
  }
142
143
  export function move(P, fromRel, toDirRel) {
@@ -146,7 +147,7 @@ export function move(P, fromRel, toDirRel) {
146
147
  if (isMeta(d)) throw new Error(`A pasta ${META} é do Studio.`);
147
148
  if (dest === abs || dest.startsWith(abs + path.sep)) throw new Error("Não dá para mover uma pasta para dentro dela mesma.");
148
149
  const to = unique(path.join(dest, path.basename(abs)));
149
- fs.renameSync(abs, to);
150
+ renameRetry(abs, to);
150
151
  return { path: relOf(P, to) };
151
152
  }
152
153
  // apagar = mandar para a lixeira do projeto (.sagadeck/lixeira), de onde dá para restaurar
@@ -158,7 +159,7 @@ export function remove(P, rel) {
158
159
  fs.mkdirSync(bin, { recursive: true });
159
160
  const stamp = new Date().toISOString().replace(/[:.]/g, "-");
160
161
  const to = path.join(bin, `${stamp}__${r.replace(/\//g, "__")}`);
161
- fs.renameSync(abs, to);
162
+ renameRetry(abs, to);
162
163
  return { trashed: relOf(P, to) };
163
164
  }
164
165
  export function restore(P, trashedRel) {
@@ -167,7 +168,7 @@ export function restore(P, trashedRel) {
167
168
  const orig = path.basename(abs).split("__").slice(1).join("/");
168
169
  const to = unique(resolveIn(P, orig));
169
170
  fs.mkdirSync(path.dirname(to), { recursive: true });
170
- fs.renameSync(abs, to);
171
+ renameRetry(abs, to);
171
172
  return { path: relOf(P, to) };
172
173
  }
173
174
 
@@ -228,7 +229,7 @@ export function writeSheet(P, rel, rows) {
228
229
  const clean = rows.map((r) => r.map((c) => String(c ?? "")));
229
230
  while (clean.length > 1 && clean.at(-1).every((c) => c === "")) clean.pop(); // linha vazia no fim (o cursor passou dela) não vai para o arquivo
230
231
  fs.writeFileSync(tmp, toCSV(clean, old));
231
- fs.renameSync(tmp, abs);
232
+ renameRetry(tmp, abs);
232
233
  return { path: relOf(P, abs), delimiter: old.delimiter };
233
234
  }
234
235
 
@@ -1907,6 +1907,8 @@
1907
1907
  rv.title = slide.review.note || "";
1908
1908
  card.appendChild(rv);
1909
1909
  }
1910
+ const pv = window.SagaProvenance?.(slide, hydrateIcons); // origem do conteúdo (provenance.js)
1911
+ if (pv) card.appendChild(pv);
1910
1912
  if (Array.isArray(slide.auto) && slide.auto.length) {
1911
1913
  const badge = document.createElement("span");
1912
1914
  badge.className = "thumb-auto";
@@ -3343,142 +3345,8 @@
3343
3345
  }
3344
3346
  }
3345
3347
 
3346
- // --------------------------------------------------------------------------
3347
- // CABEÇALHO E RODAPÉ: modelos prontos + campos com variáveis + prévia do slide atual
3348
- // --------------------------------------------------------------------------
3349
- const HF_PRESETS = [
3350
- { id: "padrao", name: "Padrão", desc: "Título e número", footer: null, header: null },
3351
- { id: "autor", name: "Autor e evento", desc: "Autor · evento e 3 / 20", footer: { left: "{autor} · {evento}", right: "{n} / {total}" }, header: null },
3352
- { id: "evento", name: "Evento e data", desc: "Evento, data e número", footer: { left: "{evento}", center: "{data:DD MMM AAAA}", right: "{pagina}" }, header: null },
3353
- { id: "corp", name: "Corporativo", desc: "Área e Confidencial em cima; título, data e página embaixo",
3354
- header: { left: "{depto}", right: "Confidencial" }, footer: { left: "{titulo}", center: "{data:MM/AAAA}", right: "{n} / {total}" } },
3355
- { id: "numero", name: "Só o número", desc: "Número discreto no canto", footer: { right: "{n}" }, header: null },
3356
- { id: "nenhum", name: "Nenhum", desc: "Slides limpos", footer: false, header: null },
3357
- ];
3358
- const HF_TOKENS = [["{titulo}", "Título"], ["{autor}", "Autor"], ["{evento}", "Evento"], ["{depto}", "Departamento"], ["{data:DD/MM/AAAA}", "Data"],
3359
- ["{data:DD MMM AAAA}", "Data por extenso"], ["{pagina}", "Página (01)"], ["{n}", "Página (1)"], ["{total}", "Total"]];
3360
- const HF_SLOTS = ["header.left", "header.center", "header.right", "footer.left", "footer.center", "footer.right"];
3361
- const hfModal = () => document.getElementById("modal-hf");
3362
- let hfLastInput = null;
3363
- let hfTimer = null;
3364
-
3365
- // footer/header do deck -> valores dos 6 campos (o padrão sem footer = título + número)
3366
- function hfSlotsFrom(v, kind, deck) {
3367
- if (v === false) return { left: "", center: "", right: "" };
3368
- if (v == null) return kind === "footer" ? { left: "{titulo}", center: "", right: "{pagina}" } : { left: "", center: "", right: "" };
3369
- if (typeof v === "string") return { left: v, center: "", right: "{pagina}" };
3370
- return { left: v.left || "", center: v.center || "", right: v.right || "" };
3371
- }
3372
-
3373
- function hfFill(deckLike) {
3374
- const m = hfModal();
3375
- for (const k of ["author", "event", "department", "date"]) m.querySelector(`[data-deck="${k}"]`).value = deckLike[k] || "";
3376
- for (const kind of ["header", "footer"]) {
3377
- const v = hfSlotsFrom(deckLike[kind], kind, deckLike);
3378
- for (const pos of ["left", "center", "right"]) m.querySelector(`[data-slot="${kind}.${pos}"]`).value = v[pos];
3379
- }
3380
- }
3381
-
3382
- // o que os campos do modal significam como deck (footer/header "limpos": null = padrão, false = nenhum)
3383
- function hfRead() {
3384
- const m = hfModal();
3385
- const out = {};
3386
- for (const k of ["author", "event", "department", "date"]) out[k] = m.querySelector(`[data-deck="${k}"]`).value.trim();
3387
- for (const kind of ["header", "footer"]) {
3388
- const v = Object.fromEntries(["left", "center", "right"].map((p) => [p, m.querySelector(`[data-slot="${kind}.${p}"]`).value.trim()]));
3389
- const empty = !v.left && !v.center && !v.right;
3390
- if (kind === "footer" && v.left === "{titulo}" && !v.center && v.right === "{pagina}") out.footer = null;
3391
- else if (empty) out[kind] = kind === "footer" ? false : null;
3392
- else out[kind] = Object.fromEntries(Object.entries(v).filter(([, x]) => x));
3393
- }
3394
- return out;
3395
- }
3396
-
3397
- function hfApplyTo(deck, vals) {
3398
- for (const [k, v] of Object.entries(vals)) {
3399
- if (v == null || v === "") delete deck[k];
3400
- else deck[k] = v;
3401
- }
3402
- return deck;
3403
- }
3404
-
3405
- function openHeaderFooter() {
3406
- const m = hfModal();
3407
- const presets = m.querySelector(".hf-presets");
3408
- if (!presets.childElementCount) {
3409
- for (const p of HF_PRESETS) {
3410
- const b = document.createElement("button");
3411
- b.type = "button";
3412
- b.className = "hf-preset";
3413
- b.dataset.preset = p.id;
3414
- b.innerHTML = `<b></b><span></span>`;
3415
- b.querySelector("b").textContent = p.name;
3416
- b.querySelector("span").textContent = p.desc;
3417
- b.onclick = () => {
3418
- const cur = hfRead();
3419
- hfFill({ ...cur, footer: p.footer, header: p.header });
3420
- presets.querySelectorAll(".hf-preset").forEach((x) => x.classList.toggle("active", x === b));
3421
- hfPreview();
3422
- };
3423
- presets.append(b);
3424
- }
3425
- const chips = m.querySelector(".hf-tokens");
3426
- for (const [tok, label] of HF_TOKENS) {
3427
- const c = document.createElement("button");
3428
- c.type = "button";
3429
- c.className = "hf-token";
3430
- c.dataset.token = tok;
3431
- c.textContent = label;
3432
- c.title = tok;
3433
- c.onmousedown = (e) => e.preventDefault(); // não rouba o foco do campo
3434
- c.onclick = () => {
3435
- const inp = hfLastInput || m.querySelector('[data-slot="footer.left"]');
3436
- const a = inp.selectionStart ?? inp.value.length, z = inp.selectionEnd ?? a;
3437
- inp.value = inp.value.slice(0, a) + tok + inp.value.slice(z);
3438
- inp.focus();
3439
- inp.setSelectionRange(a + tok.length, a + tok.length);
3440
- hfPreview();
3441
- };
3442
- chips.append(c);
3443
- }
3444
- m.querySelectorAll("input").forEach((inp) => {
3445
- inp.addEventListener("focus", () => { if (inp.dataset.slot) hfLastInput = inp; });
3446
- inp.addEventListener("input", () => { clearTimeout(hfTimer); hfTimer = setTimeout(hfPreview, 250); });
3447
- });
3448
- }
3449
- hfFill(state.deck);
3450
- m.classList.remove("hidden");
3451
- hfPreview();
3452
- }
3453
-
3454
- function closeHeaderFooter() {
3455
- hfModal().classList.add("hidden");
3456
- }
3457
-
3458
- async function hfPreview() {
3459
- const stage = hfModal().querySelector(".hf-stage");
3460
- const spec = hfApplyTo(JSON.parse(JSON.stringify(state.deck)), hfRead());
3461
- // na prévia, um slide que mostra rodapé (a capa não mostra)
3462
- let i = state.currentSlideIndex;
3463
- const NO_BARS = ["cover", "section", "end", "image", "canvas", "full", "headline"];
3464
- if (NO_BARS.includes(spec.slides[i]?.layout)) i = Math.max(0, spec.slides.findIndex((s) => !NO_BARS.includes(s.layout)));
3465
- try {
3466
- const r = await (await fetch("api/render-slide", { method: "POST", headers: { "Content-Type": "application/json" },
3467
- body: JSON.stringify({ slide: spec.slides[i], index: i, spec }) })).json();
3468
- stage.innerHTML = `<div class="thumb-render">${r.html}</div>`;
3469
- requestAnimationFrame(() => fitRendered(stage));
3470
- stage.style.setProperty("--thumb-scale", String(stage.clientWidth / 1920));
3471
- } catch {}
3472
- }
3473
-
3474
- function applyHeaderFooter() {
3475
- hfApplyTo(state.deck, hfRead());
3476
- closeHeaderFooter();
3477
- syncDeckToServer();
3478
- renderCurrentSlide();
3479
- renderThumbnails();
3480
- showToast("Cabeçalho e rodapé aplicados em todos os slides", 2200);
3481
- }
3348
+ // Cabeçalho e rodapé: modelos prontos, campos com variáveis e prévia (header-footer.js)
3349
+ const { openHeaderFooter, closeHeaderFooter, applyHeaderFooter } = window.SagaHeaderFooter({ state, fitRendered, syncDeckToServer, renderCurrentSlide, renderThumbnails, showToast });
3482
3350
 
3483
3351
  // ---------------------------------------------------------------- slides de API
3484
3352
  // Ambientes: o ~/.sagadeck/ambientes.yaml como texto, validado pelo Studio antes de gravar
@@ -3758,86 +3626,8 @@ ${ta.value}`;
3758
3626
  document.querySelectorAll(".split-button.show, .file-menu-wrap.show").forEach((m) => { if (!m.contains(except)) m.classList.remove("show"); });
3759
3627
  }
3760
3628
 
3761
- // ==========================================================================
3762
- // PREFERÊNCIAS (tela única, com busca): as desta máquina ficam em ~/.sagadeck/preferencias.json (servidor);
3763
- // as do editor, neste navegador. Cada linha diz o que faz; gravar é automático.
3764
- // ==========================================================================
3765
- const PREFS_UI = [
3766
- { id: "texto", title: "Texto e código", items: [
3767
- { k: "texto.minCodePt", type: "num", unit: "pt", min: 6, max: 24, label: "Tamanho mínimo do código ao encolher", hint: "Código que não cabe encolhe até aqui (10 pt ≈ 20 px no slide). Se nem assim couber, ele rola na apresentação e o fiscal avisa." },
3768
- { k: "texto.minTextPt", type: "num", unit: "pt", min: 6, max: 30, label: "Tamanho mínimo do texto ao encolher", hint: "Títulos e textos que se ajustam para caber não ficam menores que isto." },
3769
- { k: "texto.wrapCode", type: "bool", label: "Quebrar linhas longas de código", hint: "Em vez de encolher o bloco todo por causa de uma linha comprida." },
3770
- ] },
3771
- { id: "ia", title: "Inteligência artificial", items: [
3772
- { k: "ia.pesquisa", type: "bool", label: "Pesquisar na web quando precisar", hint: "Ranking, lançamentos, preços, um artigo científico: a IA pesquisa, lê as fontes confiáveis e cita cada uma. Desligado (ou sem internet), ela usa só o que sabe e avisa." },
3773
- { k: "ia.perguntar", type: "bool", label: "Perguntar quando o pedido não disser para que serve o material", hint: "Ex.: um workshop sem dizer se o pessoal vai guardar o material. Desligado, a IA decide sozinha." },
3774
- { k: "ia.imagens", type: "bool", label: "Gerar imagens quando o pedido pedir", hint: "Desligado: só ícones, gráficos e diagramas do sagadeck (sem custo de imagem)." },
3775
- { k: "ia.autor", type: "text", label: "Seu nome", hint: "Vai na capa e no rodapé das apresentações novas (no lugar de \"Seu Nome\")." },
3776
- { k: "ia.idioma", type: "select", options: [["auto", "O do pedido"], ["português do Brasil", "Português (Brasil)"], ["inglês", "Inglês"], ["espanhol", "Espanhol"]], label: "Idioma do conteúdo gerado" },
3777
- ] },
3778
- { id: "exportacao", title: "Exportação", items: [
3779
- { k: "exportacao.pdfClaro", type: "bool", label: "PDF na versão clara do tema", hint: "Tema escuro com par claro (ex.: Manual noite) sai claro no PDF, melhor para imprimir." },
3780
- ] },
3781
- { id: "editor", title: "Editor (este navegador)", local: true, items: [
3782
- { k: "editor.theme", type: "select", options: [["system", "Automática (do sistema)"], ["light", "Clara"], ["dark", "Escura"]], label: "Tema da interface", hint: "Só a interface do Studio; o slide mantém o tema dele.",
3783
- get: () => store.get("appTheme", "system"), set: (v) => setAppTheme(v) },
3784
- { k: "editor.guides", type: "bool", label: "Mostrar as guias da área segura", hint: "O retângulo pontilhado onde o conteúdo cabe sem cortar.",
3785
- get: () => dom.chkGuides.checked, set: (v) => { dom.chkGuides.checked = v; dom.chkGuides.dispatchEvent(new Event("change", { bubbles: true })); } },
3786
- { k: "editor.inspect", type: "bool", label: "Marcar no slide os avisos do fiscal", hint: "Caixas em volta do texto fora da margem, código que não coube, sobreposição.",
3787
- get: () => dom.chkInspectOverlay.checked, set: (v) => { dom.chkInspectOverlay.checked = v; dom.chkInspectOverlay.dispatchEvent(new Event("change", { bubbles: true })); } },
3788
- ] },
3789
- ];
3790
- let prefsData = null, prefsTimer = null;
3791
- const prefGet = (key) => { const [sec, k] = key.split("."); return prefsData?.[sec]?.[k]; };
3792
- async function openPrefs() {
3793
- const modal = document.getElementById("modal-prefs");
3794
- modal.classList.remove("hidden");
3795
- try {
3796
- const r = await fetch("api/preferences"); const j = await r.json();
3797
- prefsData = j.prefs; document.getElementById("prefs-file").textContent = j.file || "";
3798
- } catch { prefsData = {}; }
3799
- renderPrefs();
3800
- const q = document.getElementById("prefs-search"); q.value = ""; q.focus();
3801
- }
3802
- function renderPrefs() {
3803
- const nav = document.getElementById("prefs-nav"), list = document.getElementById("prefs-list");
3804
- nav.innerHTML = PREFS_UI.map((sec) => `<button type="button" data-prefs-sec="${sec.id}">${escHtml(sec.title)}</button>`).join("");
3805
- list.innerHTML = PREFS_UI.map((sec) => `<section class="prefs-sec" data-prefs-sec="${sec.id}"><h4>${escHtml(sec.title)}</h4>${sec.items.map((it) => {
3806
- const v = it.get ? it.get() : prefGet(it.k);
3807
- const ctl = it.type === "bool" ? `<input type="checkbox" data-pref="${it.k}"${v ? " checked" : ""} aria-label="${escAttr(it.label)}">`
3808
- : it.type === "select" ? `<select class="form-control" data-pref="${it.k}" aria-label="${escAttr(it.label)}">${it.options.map(([ov, ol]) => `<option value="${ov}"${String(v) === ov ? " selected" : ""}>${escHtml(ol)}</option>`).join("")}</select>`
3809
- : it.type === "num" ? `<span class="pref-num"><input type="number" class="form-control" data-pref="${it.k}" value="${escAttr(v ?? "")}" min="${it.min}" max="${it.max}" step="1" aria-label="${escAttr(it.label)}"><span>${it.unit || ""}</span></span>`
3810
- : `<input type="text" class="form-control" data-pref="${it.k}" value="${escAttr(v ?? "")}" aria-label="${escAttr(it.label)}">`;
3811
- return `<div class="pref-row" data-search="${escAttr(fold(`${sec.title} ${it.label} ${it.hint || ""}`))}"><div class="pref-label"><b>${escHtml(it.label)}</b>${it.hint ? `<small>${escHtml(it.hint)}</small>` : ""}</div><div class="pref-ctl">${ctl}</div></div>`;
3812
- }).join("")}</section>`).join("");
3813
- nav.querySelectorAll("[data-prefs-sec]").forEach((b) => b.onclick = () => list.querySelector(`section[data-prefs-sec="${b.dataset.prefsSec}"]`)?.scrollIntoView({ block: "start" }));
3814
- list.querySelectorAll("[data-pref]").forEach((el) => el.addEventListener("change", () => changePref(el)));
3815
- }
3816
- function changePref(el) {
3817
- const key = el.dataset.pref, it = PREFS_UI.flatMap((s) => s.items).find((x) => x.k === key);
3818
- const value = el.type === "checkbox" ? el.checked : it.type === "num" ? Number(el.value) : el.value;
3819
- const status = document.getElementById("prefs-status");
3820
- if (it.set) { it.set(value); status.textContent = "Salvo neste navegador"; return; }
3821
- const [sec, k] = key.split(".");
3822
- (prefsData[sec] ||= {})[k] = value;
3823
- clearTimeout(prefsTimer);
3824
- status.textContent = "Salvando…";
3825
- prefsTimer = setTimeout(async () => {
3826
- try {
3827
- const r = await fetch("api/preferences", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ prefs: { [sec]: prefsData[sec] } }) });
3828
- const j = await r.json(); if (!r.ok) throw new Error(j.error || r.status);
3829
- prefsData = j.prefs;
3830
- const shown = document.querySelector(`#prefs-list [data-pref="${key}"]`); if (shown && shown.type === "number") shown.value = prefGet(key); // voltou para a faixa aceita
3831
- status.textContent = "Salvo";
3832
- renderCurrentSlide(); renderThumbnails(); // os mínimos do ajuste para caber mudam o desenho
3833
- } catch (e) { status.textContent = "Não salvou: " + e.message; }
3834
- }, 250);
3835
- }
3836
- function filterPrefs() {
3837
- const q = fold(document.getElementById("prefs-search").value.trim());
3838
- document.querySelectorAll("#prefs-list .pref-row").forEach((r) => { r.hidden = !!q && !r.dataset.search.includes(q); });
3839
- document.querySelectorAll("#prefs-list .prefs-sec").forEach((sec) => { sec.hidden = !sec.querySelector(".pref-row:not([hidden])"); });
3840
- }
3629
+ // Preferências: tela única com busca (preferences-ui.js)
3630
+ const { openPrefs, filterPrefs } = window.SagaPrefs({ dom, store, escHtml, escAttr, fold, setAppTheme, renderCurrentSlide, renderThumbnails });
3841
3631
 
3842
3632
  function syncMotionMenu() {
3843
3633
  const cur = state.deck?.motion || "subtle";
@@ -4300,6 +4090,7 @@ ${ta.value}`;
4300
4090
  if (files.length) { e.preventDefault(); files.forEach(addChatImage); }
4301
4091
  });
4302
4092
  dom.chatAttach.onclick = () => dom.chatAttachInput.click();
4093
+ document.getElementById("chat-critique").onclick = () => chatApi.runCritique();
4303
4094
  dom.chatAttachInput.onchange = () => { [...dom.chatAttachInput.files].forEach(addChatFile); dom.chatAttachInput.value = ""; };
4304
4095
 
4305
4096
  // Chips de Sugestões de Prompt do Chat
@@ -451,9 +451,79 @@ window.SagaChat = function ({ state, dom, escHtml, hydrateIcons, showToast, open
451
451
  dom.chatMessages.scrollTop = dom.chatMessages.scrollHeight;
452
452
  return msgDiv;
453
453
  }
454
+ // Leitura crítica do material anexado (botão do chat): o servidor lê o documento como um revisor da área e devolve
455
+ // inconsistências, achados escondidos, perguntas prováveis e slides que representam mal o material, cada item com
456
+ // trecho conferido. Nada muda nos slides; vira uma mensagem da conversa com opções para aplicar.
457
+ async function runCritique() {
458
+ if (chatJob) return;
459
+ appendChatMessage("user", "Leitura crítica do material");
460
+ const job = chatJob = { file: state.file, id: crypto.randomUUID(), controller: new AbortController() };
461
+ job.stop = () => job.controller.abort();
462
+ const work = createProgressBubble("Lendo o material com olho crítico…");
463
+ try {
464
+ const data = await streamAI("api/ai/critique", { spec: state.deck, requestId: job.id }, (ev) => work.update(ev), job.controller.signal);
465
+ state.chatHistory.push({ role: "user", text: "Leitura crítica do material" });
466
+ state.chatHistory.push({ role: "assistant", text: data.reply + (data.options?.length ? `\n(opções: ${data.options.join(" | ")})` : ""), talk: true });
467
+ await saveChatHistory();
468
+ work.done();
469
+ // com os pontos estruturados, a bolha traz o resumo e cada ponto vira um cartão (o texto inteiro fica no histórico)
470
+ const msg = appendChatMessage("ai", data.critique?.itens?.length ? data.critique.summary : data.reply, data.actions || []);
471
+ msg.classList.add("bs");
472
+ if (data.critique?.itens?.length) renderCritiqueItems(msg, data.critique);
473
+ const tag = document.createElement("div");
474
+ tag.className = "bs-tag";
475
+ tag.innerHTML = '<i class="ic" data-ic="scan-search"></i> Leitura crítica — nada mudou nos slides'; hydrateIcons(tag);
476
+ msg.querySelector(".ai-content").prepend(tag);
477
+ renderChatOptions(msg, data.options);
478
+ updateBrainstormApply();
479
+ } catch (e) {
480
+ work.done();
481
+ appendChatMessage("ai", `A leitura crítica não saiu: ${e.message}`);
482
+ } finally { chatJob = null; }
483
+ }
484
+ // Cada ponto da leitura crítica como cartão: o slide a que ele se liga (clique leva até lá), Aplicar (pede à IA só
485
+ // aquele ponto, mirando aquele slide; a autoria decide se vai ao slide ou às notes) e Ignorar (gravado: o ponto deixa
486
+ // de acompanhar os pedidos).
487
+ const CRIT_LABEL = { destaque: "Achado", inconsistencia: "Inconsistência", slide: "Slide a rever", pergunta: "Pergunta provável", limitacao: "Limitação", forte: "Ponto forte" };
488
+ function goToSlide(n) {
489
+ if (!(n >= 1 && n <= state.deck.slides.length)) return;
490
+ state.currentSlideIndex = n - 1;
491
+ renderThumbnails(); renderCurrentSlide();
492
+ }
493
+ async function markCritique(id, status) {
494
+ try { await fetch("api/ai/critique/item", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id, status }) }); } catch { /* fica só na tela */ }
495
+ }
496
+ function renderCritiqueItems(msg, critique) {
497
+ const list = document.createElement("div");
498
+ list.className = "crit-list";
499
+ for (const it of critique.itens) {
500
+ const card = document.createElement("div");
501
+ card.className = `crit-item crit-${it.tipo}`;
502
+ card.dataset.id = it.id;
503
+ card.innerHTML = `<div class="crit-head"><span class="crit-kind"></span>${it.slide ? `<button type="button" class="crit-slide" title="Ir ao slide ${it.slide}">Slide ${it.slide}</button>` : ""}</div><b class="crit-title"></b><div class="crit-text"></div>${it.trecho ? '<blockquote class="crit-quote"></blockquote>' : ""}<div class="crit-actions"><button type="button" class="bs-option crit-apply"><i class="ic" data-ic="check"></i> Aplicar${it.slide ? ` no slide ${it.slide}` : ""}</button><button type="button" class="bs-option crit-ignore"><i class="ic" data-ic="x"></i> Ignorar</button></div>`;
504
+ card.querySelector(".crit-kind").textContent = CRIT_LABEL[it.tipo] || it.tipo;
505
+ card.querySelector(".crit-title").textContent = it.titulo;
506
+ card.querySelector(".crit-text").textContent = `${it.texto}${it.onde ? ` (${it.onde})` : ""}`;
507
+ if (it.trecho) card.querySelector(".crit-quote").textContent = `"${it.trecho}"`;
508
+ const done = (label) => { card.classList.add("crit-done"); card.querySelector(".crit-actions").innerHTML = `<span class="crit-status">${label}</span>`; };
509
+ card.querySelector(".crit-slide")?.addEventListener("click", () => goToSlide(it.slide));
510
+ card.querySelector(".crit-apply").onclick = () => {
511
+ if (chatJob) { showToast("Espere a IA terminar o pedido atual.", 3000); return; }
512
+ goToSlide(it.slide);
513
+ done(it.slide ? `Aplicado no slide ${it.slide}` : "Aplicado");
514
+ markCritique(it.id, "aplicado");
515
+ dom.chatInput.value = `Aplique este ponto da leitura crítica${it.slide ? ` no slide ${it.slide}` : ", no slide em que ele se encaixa"}: ${it.titulo}. ${it.texto}${it.trecho ? ` (trecho do material: "${it.trecho}")` : ""}. Siga a autoria da apresentação: achado do material pode ir para o slide; crítica, inconsistência e pergunta vão para as notes ("Prepare-se: …"), salvo se a apresentação for livre.`;
516
+ handleChatSubmit();
517
+ };
518
+ card.querySelector(".crit-ignore").onclick = () => { done("Ignorado"); markCritique(it.id, "ignorado"); };
519
+ list.append(card);
520
+ }
521
+ hydrateIcons(list);
522
+ msg.querySelector(".ai-content").append(list);
523
+ }
454
524
  // Bolha de progresso do chat e de outros fluxos (transformação usa a mesma).
455
525
  const createProgressBubble = window.SagaChatProgress({ dom, appendChatMessage, hydrateIcons, getChatJob });
456
- const api = { getChatJob, handleChatSubmit, loadChatHistory, saveChatHistory, appendChatMessage,
526
+ const api = { getChatJob, handleChatSubmit, runCritique, loadChatHistory, saveChatHistory, appendChatMessage,
457
527
  createProgressBubble, commandEvent, updateBrainstormApply, renderVariants, applyBrainstorm,
458
528
  renderChatOptions, clearChatAttachments, renderChatAttachments, addChatImage, addChatDoc,
459
529
  addChatFile, autoGrowChat, commandCard };
@@ -0,0 +1,140 @@
1
+ // Cabeçalho e rodapé do deck: modelos prontos, campos com variáveis ({autor}, {data:…}, {n}) e prévia do slide atual.
2
+ // Saiu do app.js pela trava de tamanho dos monolitos; o comportamento é o mesmo.
3
+ // Fábrica no molde de slide-select.js: recebe o estado e os ajudantes, devolve a API.
4
+ window.SagaHeaderFooter = function ({ state, fitRendered, syncDeckToServer, renderCurrentSlide, renderThumbnails, showToast }) {
5
+ const HF_PRESETS = [
6
+ { id: "padrao", name: "Padrão", desc: "Título e número", footer: null, header: null },
7
+ { id: "autor", name: "Autor e evento", desc: "Autor · evento e 3 / 20", footer: { left: "{autor} · {evento}", right: "{n} / {total}" }, header: null },
8
+ { id: "evento", name: "Evento e data", desc: "Evento, data e número", footer: { left: "{evento}", center: "{data:DD MMM AAAA}", right: "{pagina}" }, header: null },
9
+ { id: "corp", name: "Corporativo", desc: "Área e Confidencial em cima; título, data e página embaixo",
10
+ header: { left: "{depto}", right: "Confidencial" }, footer: { left: "{titulo}", center: "{data:MM/AAAA}", right: "{n} / {total}" } },
11
+ { id: "numero", name: "Só o número", desc: "Número discreto no canto", footer: { right: "{n}" }, header: null },
12
+ { id: "nenhum", name: "Nenhum", desc: "Slides limpos", footer: false, header: null },
13
+ ];
14
+ const HF_TOKENS = [["{titulo}", "Título"], ["{autor}", "Autor"], ["{evento}", "Evento"], ["{depto}", "Departamento"], ["{data:DD/MM/AAAA}", "Data"],
15
+ ["{data:DD MMM AAAA}", "Data por extenso"], ["{pagina}", "Página (01)"], ["{n}", "Página (1)"], ["{total}", "Total"]];
16
+ const HF_SLOTS = ["header.left", "header.center", "header.right", "footer.left", "footer.center", "footer.right"];
17
+ const hfModal = () => document.getElementById("modal-hf");
18
+ let hfLastInput = null;
19
+ let hfTimer = null;
20
+
21
+ // footer/header do deck -> valores dos 6 campos (o padrão sem footer = título + número)
22
+ function hfSlotsFrom(v, kind, deck) {
23
+ if (v === false) return { left: "", center: "", right: "" };
24
+ if (v == null) return kind === "footer" ? { left: "{titulo}", center: "", right: "{pagina}" } : { left: "", center: "", right: "" };
25
+ if (typeof v === "string") return { left: v, center: "", right: "{pagina}" };
26
+ return { left: v.left || "", center: v.center || "", right: v.right || "" };
27
+ }
28
+
29
+ function hfFill(deckLike) {
30
+ const m = hfModal();
31
+ for (const k of ["author", "event", "department", "date"]) m.querySelector(`[data-deck="${k}"]`).value = deckLike[k] || "";
32
+ for (const kind of ["header", "footer"]) {
33
+ const v = hfSlotsFrom(deckLike[kind], kind, deckLike);
34
+ for (const pos of ["left", "center", "right"]) m.querySelector(`[data-slot="${kind}.${pos}"]`).value = v[pos];
35
+ }
36
+ }
37
+
38
+ // o que os campos do modal significam como deck (footer/header "limpos": null = padrão, false = nenhum)
39
+ function hfRead() {
40
+ const m = hfModal();
41
+ const out = {};
42
+ for (const k of ["author", "event", "department", "date"]) out[k] = m.querySelector(`[data-deck="${k}"]`).value.trim();
43
+ for (const kind of ["header", "footer"]) {
44
+ const v = Object.fromEntries(["left", "center", "right"].map((p) => [p, m.querySelector(`[data-slot="${kind}.${p}"]`).value.trim()]));
45
+ const empty = !v.left && !v.center && !v.right;
46
+ if (kind === "footer" && v.left === "{titulo}" && !v.center && v.right === "{pagina}") out.footer = null;
47
+ else if (empty) out[kind] = kind === "footer" ? false : null;
48
+ else out[kind] = Object.fromEntries(Object.entries(v).filter(([, x]) => x));
49
+ }
50
+ return out;
51
+ }
52
+
53
+ function hfApplyTo(deck, vals) {
54
+ for (const [k, v] of Object.entries(vals)) {
55
+ if (v == null || v === "") delete deck[k];
56
+ else deck[k] = v;
57
+ }
58
+ return deck;
59
+ }
60
+
61
+ function openHeaderFooter() {
62
+ const m = hfModal();
63
+ const presets = m.querySelector(".hf-presets");
64
+ if (!presets.childElementCount) {
65
+ for (const p of HF_PRESETS) {
66
+ const b = document.createElement("button");
67
+ b.type = "button";
68
+ b.className = "hf-preset";
69
+ b.dataset.preset = p.id;
70
+ b.innerHTML = `<b></b><span></span>`;
71
+ b.querySelector("b").textContent = p.name;
72
+ b.querySelector("span").textContent = p.desc;
73
+ b.onclick = () => {
74
+ const cur = hfRead();
75
+ hfFill({ ...cur, footer: p.footer, header: p.header });
76
+ presets.querySelectorAll(".hf-preset").forEach((x) => x.classList.toggle("active", x === b));
77
+ hfPreview();
78
+ };
79
+ presets.append(b);
80
+ }
81
+ const chips = m.querySelector(".hf-tokens");
82
+ for (const [tok, label] of HF_TOKENS) {
83
+ const c = document.createElement("button");
84
+ c.type = "button";
85
+ c.className = "hf-token";
86
+ c.dataset.token = tok;
87
+ c.textContent = label;
88
+ c.title = tok;
89
+ c.onmousedown = (e) => e.preventDefault(); // não rouba o foco do campo
90
+ c.onclick = () => {
91
+ const inp = hfLastInput || m.querySelector('[data-slot="footer.left"]');
92
+ const a = inp.selectionStart ?? inp.value.length, z = inp.selectionEnd ?? a;
93
+ inp.value = inp.value.slice(0, a) + tok + inp.value.slice(z);
94
+ inp.focus();
95
+ inp.setSelectionRange(a + tok.length, a + tok.length);
96
+ hfPreview();
97
+ };
98
+ chips.append(c);
99
+ }
100
+ m.querySelectorAll("input").forEach((inp) => {
101
+ inp.addEventListener("focus", () => { if (inp.dataset.slot) hfLastInput = inp; });
102
+ inp.addEventListener("input", () => { clearTimeout(hfTimer); hfTimer = setTimeout(hfPreview, 250); });
103
+ });
104
+ }
105
+ hfFill(state.deck);
106
+ m.classList.remove("hidden");
107
+ hfPreview();
108
+ }
109
+
110
+ function closeHeaderFooter() {
111
+ hfModal().classList.add("hidden");
112
+ }
113
+
114
+ async function hfPreview() {
115
+ const stage = hfModal().querySelector(".hf-stage");
116
+ const spec = hfApplyTo(JSON.parse(JSON.stringify(state.deck)), hfRead());
117
+ // na prévia, um slide que mostra rodapé (a capa não mostra)
118
+ let i = state.currentSlideIndex;
119
+ const NO_BARS = ["cover", "section", "end", "image", "canvas", "full", "headline"];
120
+ if (NO_BARS.includes(spec.slides[i]?.layout)) i = Math.max(0, spec.slides.findIndex((s) => !NO_BARS.includes(s.layout)));
121
+ try {
122
+ const r = await (await fetch("api/render-slide", { method: "POST", headers: { "Content-Type": "application/json" },
123
+ body: JSON.stringify({ slide: spec.slides[i], index: i, spec }) })).json();
124
+ stage.innerHTML = `<div class="thumb-render">${r.html}</div>`;
125
+ requestAnimationFrame(() => fitRendered(stage));
126
+ stage.style.setProperty("--thumb-scale", String(stage.clientWidth / 1920));
127
+ } catch {}
128
+ }
129
+
130
+ function applyHeaderFooter() {
131
+ hfApplyTo(state.deck, hfRead());
132
+ closeHeaderFooter();
133
+ syncDeckToServer();
134
+ renderCurrentSlide();
135
+ renderThumbnails();
136
+ showToast("Cabeçalho e rodapé aplicados em todos os slides", 2200);
137
+ }
138
+
139
+ return { openHeaderFooter, closeHeaderFooter, applyHeaderFooter };
140
+ };
@@ -500,6 +500,7 @@
500
500
  <div id="chat-attachments" class="chat-attachments" hidden></div>
501
501
  <div class="chat-compose">
502
502
  <textarea id="chat-input" class="chat-field" rows="1" placeholder="Pergunte, peça uma mudança, anexe um arquivo ou cole um link…" autocomplete="off"></textarea>
503
+ <button type="button" id="chat-critique" class="icon-btn" title="Leitura crítica do material anexado: inconsistências, achados escondidos, perguntas prováveis da banca (nada muda nos slides)"><i class="ic" data-ic="scan-search"></i></button>
503
504
  <button type="button" id="chat-attach" class="icon-btn" title="Anexar imagem ou documento (pdf, docx, xlsx, pptx, txt)"><i class="ic" data-ic="image-plus"></i></button>
504
505
  <button type="submit" id="chat-send" class="icon-btn icon-btn-primary" title="Enviar (Enter)"><i class="ic" data-ic="send"></i></button>
505
506
  </div>
@@ -840,6 +841,7 @@
840
841
 
841
842
  <script src="fit.js"></script>
842
843
  <script src="ui-icons.js"></script>
844
+ <script src="update-notice.js"></script>
843
845
  <script src="calc-fields.js"></script>
844
846
  <script src="art-preview.js"></script>
845
847
  <script src="api-collections.js"></script>
@@ -867,6 +869,9 @@
867
869
  <script src="workspace.js"></script>
868
870
  <script src="studio-awareness.js"></script>
869
871
  <script src="chat.js"></script>
872
+ <script src="provenance.js"></script>
873
+ <script src="header-footer.js"></script>
874
+ <script src="preferences-ui.js"></script>
870
875
  <script src="app.js"></script>
871
876
  </body>
872
877
  </html>
@@ -50,6 +50,7 @@
50
50
  <div class="ldlg-back" id="dlg"><div class="ldlg" role="dialog" aria-modal="true" aria-labelledby="dialog-heading"></div></div>
51
51
  <div class="ltoast" id="toast" role="status"></div>
52
52
  <script src="ui-icons.js"></script>
53
+ <script src="update-notice.js"></script>
53
54
  <script src="library.js"></script>
54
55
  </body>
55
56
  </html>
@@ -111,7 +111,7 @@
111
111
  const match = (d) => !q || d.title.toLowerCase().includes(q);
112
112
  const main = $("#main");
113
113
  if (view === "lixeira") {
114
- main.innerHTML = `<div class="head"><div><h1>Lixeira</h1><div class="sub">Fica aqui por 30 dias; depois é apagado de vez.</div></div>${data.trash.length ? `<button class="lib-btn ghost danger" data-empty-trash>${ic("trash-2")}Esvaziar lixeira</button>` : ""}</div>` +
114
+ main.innerHTML = `<div class="head"><div><h1>Lixeira</h1><div class="sub">Fica aqui por um dia; depois é apagado de vez.</div></div>${data.trash.length ? `<button class="lib-btn ghost danger" data-empty-trash>${ic("trash-2")}Esvaziar lixeira</button>` : ""}</div>` +
115
115
  (data.trash.length ? data.trash.map((t) => `<div class="trash-row"><div class="t"><b>${esc(t.title)}</b><div class="sub">Era de ${esc(t.topic || NO_TOPIC.name)} · ${t.slides || 0} slides · excluída ${ago(t.deleted)}</div></div>
116
116
  <button class="lib-btn ghost" data-restore="${esc(t.id)}">${ic("rotate-ccw")}Restaurar</button><button class="lib-btn ghost danger" data-purge="${esc(t.id)}">Excluir de vez</button></div>`).join("")
117
117
  : `<div class="empty"><div class="big">${ic("trash-2")}</div><h2>Lixeira vazia</h2></div>`);
@@ -211,7 +211,7 @@
211
211
  if (a === "open" || a === "present") return openEditor(id, a === "present");
212
212
  if (a === "rename") return nameDialog("Renomear apresentação", d.title, (title) => act(() => api("api/library/decks/rename", { id, title }), "Renomeada"));
213
213
  if (a === "dup") return act(() => api("api/library/decks/duplicate", { id }), "Cópia criada");
214
- if (a === "trash") return act(() => api("api/library/decks/trash", { id }), "Na lixeira — dá para restaurar por 30 dias");
214
+ if (a === "trash") return act(() => api("api/library/decks/trash", { id }), "Na lixeira — dá para restaurar por um dia");
215
215
  });
216
216
  m.querySelectorAll("[data-move]").forEach((b) => b.onclick = () => { closeMenus(); act(() => api("api/library/decks/move", { id, topic: b.dataset.move }), `Movida para ${topicOf(b.dataset.move).name}`); });
217
217
  m.querySelectorAll("[data-dl]").forEach((b) => b.onclick = () => {
@@ -435,7 +435,8 @@
435
435
  const line = buf.slice(0, nl); buf = buf.slice(nl + 1);
436
436
  if (!line.trim()) continue;
437
437
  const ev = JSON.parse(line);
438
- if (ev.type === "progress" && ev.text) status.textContent = ev.text;
438
+ // a etapa (ou as etapas que correm juntas) e o tempo: a geração com documento leva minutos
439
+ if (ev.type === "progress" && ev.text) status.textContent = `${ev.text}${ev.elapsed ? ` · ${Math.round(ev.elapsed / 1000)}s` : ""}`;
439
440
  if (ev.type === "result") result = ev.data;
440
441
  if (ev.type === "error") throw new Error(ev.error || "falhou");
441
442
  }