sagadeck 1.9.0 → 1.11.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 (56) hide show
  1. package/README.md +9 -2
  2. package/SKILL.md +1 -1
  3. package/bin/sagadeck.js +1 -1
  4. package/docs/ANDAMENTO.md +302 -0
  5. package/docs/REFERENCIA.md +72 -4
  6. package/package.json +4 -4
  7. package/src/ai/context.js +7 -2
  8. package/src/ai/critique.js +8 -1
  9. package/src/ai/deck-ai.js +36 -12
  10. package/src/ai/document-visuals.js +6 -2
  11. package/src/ai/ia-check.js +13 -4
  12. package/src/ai/ia-config.js +4 -2
  13. package/src/ai/llm.js +12 -7
  14. package/src/ai/math-check.js +318 -0
  15. package/src/ai/quality.js +3 -3
  16. package/src/ai/units.js +85 -0
  17. package/src/api-client.js +42 -10
  18. package/src/build.js +1 -0
  19. package/src/code-highlight.js +19 -2
  20. package/src/code-language.js +13 -0
  21. package/src/fs-retry.js +2 -2
  22. package/src/lessons.js +72 -11
  23. package/src/mcp/server.js +1 -1
  24. package/src/package.js +4 -0
  25. package/src/runtime/api-core.js +122 -11
  26. package/src/runtime/api-ui.js +2 -1
  27. package/src/runtime/base.css +3 -0
  28. package/src/runtime/skins/aurora-dev.css +39 -0
  29. package/src/runtime/skins/terminal-pro.css +33 -0
  30. package/src/runtime/solution.js +131 -0
  31. package/src/studio/ai-chat-route.js +69 -7
  32. package/src/studio/conference-routes.js +44 -0
  33. package/src/studio/conference.js +144 -0
  34. package/src/studio/developer-starters.js +46 -0
  35. package/src/studio/generation-report.js +61 -1
  36. package/src/studio/generation-request.js +80 -0
  37. package/src/studio/ia-routes.js +1 -1
  38. package/src/studio/layout-samples.js +1 -1
  39. package/src/studio/library-routes.js +7 -0
  40. package/src/studio/public/about.js +68 -0
  41. package/src/studio/public/ai-settings.js +4 -4
  42. package/src/studio/public/app.js +14 -2
  43. package/src/studio/public/chat.js +11 -3
  44. package/src/studio/public/conference-ui.js +91 -0
  45. package/src/studio/public/developer-authoring.css +12 -0
  46. package/src/studio/public/developer-authoring.js +306 -0
  47. package/src/studio/public/generation-guide.js +42 -0
  48. package/src/studio/public/index.html +21 -0
  49. package/src/studio/public/library.css +36 -0
  50. package/src/studio/public/library.html +4 -1
  51. package/src/studio/public/library.js +118 -28
  52. package/src/studio/public/slide-form.js +17 -17
  53. package/src/studio/public/style.css +25 -0
  54. package/src/studio/public/ui-icons.js +1 -1
  55. package/src/studio/server.js +20 -8
  56. package/src/themes.js +48 -0
@@ -0,0 +1,80 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import crypto from 'node:crypto';
4
+ import { llmConfig } from '../ai/llm.js';
5
+ import { loadPreferences } from '../preferences.js';
6
+ import { renameRetry } from '../fs-retry.js';
7
+
8
+ // O pedido que originou a apresentação (.sagadeck/geracao.json), para dar para auditar depois: foi um prompt ou um
9
+ // material? o que a pessoa pediu? Grava o briefing exatamente como foi para a IA (e se era o texto padrão da tela),
10
+ // a resposta à pergunta da IA, duração, estilo, os anexos (nome, tipo, tamanho, onde ficaram em contexto/), as
11
+ // Preferências que mudam a geração e os modelos. Nada de chave: do provedor, só o endereço sem usuário e senha.
12
+ // Recriar pelo chat grava por cima; o pedido anterior vai para `anteriores` (os 10 últimos).
13
+ export const REQUEST_FILE = path.join('.sagadeck', 'geracao.json');
14
+
15
+ const text = (v, max = 20000) => (v == null || v === '' ? undefined : String(v).slice(0, max));
16
+ const num = (v) => (Number(v) > 0 ? Number(v) : undefined);
17
+ const clean = (o) => Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined));
18
+
19
+ // onde o anexo ficou guardado (mesma pasta de prepareDocumentMaterials: contexto/documentos/<sha256 12>/<nome>)
20
+ function storedPath(dir, name, bytes) {
21
+ if (!bytes) return undefined;
22
+ const hash = crypto.createHash('sha256').update(bytes).digest('hex').slice(0, 12);
23
+ const rel = ['contexto', 'documentos', hash, path.basename(name)].join('/');
24
+ return fs.existsSync(path.join(dir, ...rel.split('/'))) ? rel : undefined;
25
+ }
26
+
27
+ function material(dir, m) {
28
+ const name = String(m.name || '');
29
+ const ext = path.extname(name).slice(1).toLowerCase();
30
+ return clean({
31
+ nome: name,
32
+ tipo: /^https?:/i.test(name) ? 'link' : ext || 'texto',
33
+ detalhe: text(m.detail, 200),
34
+ tamanho: m.bytes ? m.bytes.length : undefined, // bytes do arquivo anexado
35
+ caracteres: String(m.text || '').length || undefined, // texto que a IA leu
36
+ caminho: storedPath(dir, name, m.bytes),
37
+ });
38
+ }
39
+
40
+ // o endereço do provedor, sem usuário, senha nem parâmetros (onde às vezes vai uma chave)
41
+ function provider(cfg) {
42
+ if (cfg.adaptador) return 'adaptador';
43
+ try { const u = new URL(cfg.url); return `${u.protocol}//${u.host}${u.pathname.replace(/\/+$/, '')}`; } catch { return undefined; }
44
+ }
45
+
46
+ // origem: "biblioteca" | "editor" | "chat"; pedido: o corpo do pedido (briefing, answer, duration, style…);
47
+ // materials: os anexos como vieram (com bytes); spec: a apresentação gerada (tema e título finais)
48
+ export function saveGenerationRequest(dir, { origem, pedido = {}, materials = [], spec = null, research = null, prefs = loadPreferences().ia, cfg = llmConfig() } = {}) {
49
+ if (!dir) return null;
50
+ const guide = pedido.guide && typeof pedido.guide === 'object' ? pedido.guide : {};
51
+ const entry = clean({
52
+ data: new Date().toISOString(),
53
+ origem,
54
+ briefing: String(pedido.briefing || ''),
55
+ briefingPadrao: !!guide.padrao,
56
+ guia: clean({ intencao: text(guide.intent, 40), publico: text(guide.audience, 500), objetivo: text(guide.goal, 500), roteiro: text(guide.route, 40) }),
57
+ resposta: text(pedido.answer, 500),
58
+ duracao: num(pedido.duration),
59
+ slides: num(pedido.slides),
60
+ tema: text(pedido.theme, 80),
61
+ estilo: text(pedido.style, 80),
62
+ direcao: text(pedido.direction && typeof pedido.direction === 'object' ? JSON.stringify(pedido.direction) : pedido.direction, 4000),
63
+ materiais: materials.map((m) => material(dir, m)),
64
+ preferencias: clean({ idioma: prefs?.idioma || 'auto', imagens: prefs?.imagens !== false, pesquisa: prefs?.pesquisa !== false, perguntar: prefs?.perguntar !== false, autor: text(prefs?.autor, 120) }),
65
+ pesquisa: research?.fontes?.length ? research.fontes.map((f) => clean({ site: text(f.site, 200), url: text(f.url, 500) })) : undefined,
66
+ modelos: clean({ texto: cfg.textModel, visao: cfg.visionModel, imagem: cfg.imageModel, busca: cfg.searchModel || undefined }),
67
+ provedor: provider(cfg),
68
+ resultado: spec ? clean({ titulo: text(spec.title, 200), tema: text(spec.theme, 80), slides: Array.isArray(spec.slides) ? spec.slides.length : undefined }) : undefined,
69
+ });
70
+ if (!Object.keys(entry.guia).length) delete entry.guia;
71
+ const file = path.join(dir, REQUEST_FILE);
72
+ let anteriores = [];
73
+ try { const old = JSON.parse(fs.readFileSync(file, 'utf8')); anteriores = [{ ...old, anteriores: undefined }, ...(old.anteriores || [])].slice(0, 10); } catch { /* primeiro pedido */ }
74
+ fs.mkdirSync(path.dirname(file), { recursive: true });
75
+ const out = { ...entry, ...(anteriores.length ? { anteriores: JSON.parse(JSON.stringify(anteriores)) } : {}) };
76
+ const tmp = `${file}.tmp-${process.pid}`;
77
+ fs.writeFileSync(tmp, JSON.stringify(out, null, 2), 'utf8');
78
+ renameRetry(tmp, file);
79
+ return out;
80
+ }
@@ -52,7 +52,7 @@ export async function iaRoutes({ req, res, pathname, opts, readJSON }) {
52
52
  // testar: uma pergunta curta com o que está na tela (antes de salvar)
53
53
  const base = llmConfig();
54
54
  const cfg = { ...base, url: next.url.replace(/\/+$/, ""), key: keyOf(next), headers: next.headers || {}, adaptador: next.adaptador || "",
55
- textModel: next.models.text, visionModel: next.models.vision || next.models.text, imageModel: next.models.image || "image", searchModel: next.models.search || "",
55
+ textModel: next.models.text, visionModel: next.models.vision || next.models.text, imageModel: next.models.image || "image", searchModel: next.models.search || "", checkModel: next.models.check || next.models.text,
56
56
  timeoutMs: 60_000, firstTimeoutMs: 60_000 };
57
57
  const t0 = Date.now();
58
58
  try {
@@ -143,7 +143,7 @@ export const LAYOUT_SAMPLES = {
143
143
  decisionlab: {layout:"decisionlab", title:"A revisão melhora o resultado?", lab:{volume:10000,errorRate:2,reviewRate:100,catchRate:60,introducedRate:0.2,seconds:30}},
144
144
  infographic: { layout: "infographic", shape: "arco", title: "Os ==desafios== da viagem", center: { title: "Desafios" }, items: [{ title: "Roteiro", text: "Três cidades em dez dias" }, { title: "Orçamento", text: "Hospedagem pesa mais" }, { title: "Documentos", text: "Passaporte e visto" }, { title: "Clima", text: "Chuva na segunda semana" }] },
145
145
  diagram: { layout: "diagram", kicker: "Processo", title: "Da ideia à ==produção==", mermaid: "flowchart LR\n A([:lightbulb: Ideia]):::hi --> B{Vale testar?}\n B -->|sim| C[:flask-conical: Protótipo]\n B -.->|não| X[Arquivar]:::vazado\n C ==> D[:rocket: Produção]:::em" },
146
- solution: {layout:"solution", title:"Exercício: quanto rende em 3 anos?", problem:"R$ 1.000 aplicados a 1% ao mês, com juros compostos. Quanto há depois de 36 meses?", givens:[{symbol:"C", value:"1000", unit:"R\\$", label:"capital"}, {symbol:"i", value:"0,01", label:"taxa ao mês"}, {symbol:"n", value:"36", label:"meses"}], find:"M", steps:[{text:"Juros compostos", latex:"M = C\\,(1 + i)^n"}, {text:"Substitui os dados", latex:"M = 1000 \\cdot 1{,}01^{36}"}, {text:"Calcula a potência", latex:"1{,}01^{36} \\approx 1{,}4308"}], answer:{latex:"M \\approx R\\$\\,1.430{,}77", text:"Rende cerca de 43% em 3 anos."}},
146
+ solution: {layout:"solution", title:"Exercício: quanto rende em 3 anos?", problem:"R$ 1.000 aplicados a 1% ao mês, com juros compostos. Quanto há depois de 36 meses?", givens:[{symbol:"C", value:"1000", unit:"R\\$", label:"capital"}, {symbol:"i", value:"0,01", label:"taxa ao mês"}, {symbol:"n", value:"36", label:"meses"}], find:"M", steps:[{text:"Juros compostos", latex:"M = C\\,(1 + i)^n"}, {text:"Calcula o fator", latex:"(1 + i)^n = (1 + {{i}})^{ {{n}} }", fn:"(1+i)^n", name:"fator", decimals:4}, {text:"Multiplica pelo capital", latex:"M = {{C}} \\cdot {{fator}}", fn:"C*fator", name:"M", decimals:2}], answer:{latex:"M \\approx R\\$\\,{{M}}", text:"Rende cerca de {{(M/C - 1)*100:0}}% em {{n}} meses."}, interactive:true},
147
147
  calc: {layout:"calc", title:"Qual é o regime do escoamento?", inputs:{V:{label:"Velocidade", latex:"V", value:1.5, min:0.01, max:4, step:0.01, unit:"m/s"}, D:{label:"Diâmetro", latex:"D", value:0.05, min:0.005, max:0.3, step:0.005, unit:"m"}, nu:{label:"Viscosidade (água, 20 °C)", latex:"\\nu = 1{,}0\\times10^{-6}\\ \\mathrm{m^2/s}", value:0.000001, fixed:true}}, outputs:[{name:"Re", label:"Número de Reynolds", latex:"Re = \\frac{VD}{\\nu}", fn:"V*D/nu", decimals:0}, {name:"regime", label:"Regime", of:"Re", cases:[{below:2300, text:"Laminar", color:"s3"}, {below:4000, text:"Transição", color:"em"}, {text:"Turbulento", color:"hi"}], scale:{min:100, max:1000000, log:true}}]},
148
148
  algo: {layout:"algo", title:"Bubble sort passo a passo", algorithm:"bubble", array:[5, 1, 4, 2, 8, 3]},
149
149
  carousel: {layout:"carousel", title:"Cinco lugares, cinco lições", style:"arc", items:[{title:"Montanha", text:"Subir devagar é o jeito mais rápido de chegar.", label:"Capítulo 1"}, {title:"Mar", text:"Tudo o que é fundo começa raso."}, {title:"Cidade", text:"Muita gente, muita luz, pouco silêncio."}, {title:"Deserto", text:"Onde o pouco vira muito."}, {title:"Floresta", text:"Cada árvore conta uma história."}]},
@@ -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
+ })();
@@ -4,12 +4,12 @@ window.SagaAISettings = (() => {
4
4
  const css = `.ai-cfg{max-width:min(560px,calc(100vw - 32px));width:100%;border:1px solid var(--border,#c9ced6);border-radius:12px;padding:18px 20px;background:var(--panel,var(--bg,#fff));color:var(--text,#1d2433);font:14px/1.45 system-ui,sans-serif}
5
5
  .ai-cfg::backdrop{background:rgba(0,0,0,.35)}.ai-cfg h2{margin:0 0 6px;font-size:18px}.ai-cfg p{margin:4px 0 10px;color:var(--text-2,#555)}
6
6
  .ai-cfg label{display:block;margin:10px 0 3px;font-weight:600;font-size:12px}.ai-cfg input,.ai-cfg select{width:100%;box-sizing:border-box;padding:7px 9px;border:1px solid var(--border,#c9ced6);border-radius:8px;background:var(--bg,#fff);color:inherit;font:inherit}
7
- .ai-cfg .grid{display:grid;grid-template-columns:1fr 1fr;gap:0 12px}.ai-cfg small{color:var(--text-3,#777);font-size:12px}.ai-cfg .warn{background:#fef3c7;color:#78350f;border-radius:8px;padding:8px 10px}
7
+ .ai-cfg .grid{display:grid;grid-template-columns:1fr 1fr;gap:0 12px}.ai-cfg .grid .wide{grid-column:1/-1}.ai-cfg small{color:var(--text-3,#777);font-size:12px}.ai-cfg .warn{background:#fef3c7;color:#78350f;border-radius:8px;padding:8px 10px}
8
8
  .ai-cfg .row{display:flex;gap:8px;justify-content:flex-end;margin-top:16px;flex-wrap:wrap}.ai-cfg button{padding:7px 14px;border-radius:8px;border:1px solid var(--border,#c9ced6);background:transparent;color:inherit;cursor:pointer;font:inherit}
9
9
  .ai-cfg .rec{background:var(--selected,#eef);border-radius:8px;padding:8px 10px;margin:6px 0 4px}.ai-cfg .rec button{margin-top:6px;display:block}
10
10
  .ai-cfg button.primary{background:var(--brand,#5b4bdb);border-color:var(--brand,#5b4bdb);color:#fff}.ai-cfg [data-status]{margin-top:10px;min-height:1.4em}.ai-cfg [data-status].ok{color:#15803d}.ai-cfg [data-status].err{color:#b91c1c}`;
11
- const LABEL = { text: "Modelo de texto", vision: "Modelo que vê imagens", image: "Modelo de imagem", search: "Modelo de busca na web", video: "Modelo de vídeo" };
12
- const HINT = { text: "obrigatório", vision: "opcional; vazio = o de texto", image: "opcional; para gerar ilustrações", search: "opcional; no OpenRouter, um modelo :online", video: "opcional; para gerar clipes (cobrado por segundo)" };
11
+ const LABEL = { text: "Modelo de texto", vision: "Modelo que vê imagens", image: "Modelo de imagem", search: "Modelo de busca na web", video: "Modelo de vídeo", check: "Modelo para conferir" };
12
+ const HINT = { text: "obrigatório", vision: "opcional; vazio = o de texto", image: "opcional; para gerar ilustrações", search: "opcional; no OpenRouter, um modelo :online", video: "opcional; para gerar clipes (cobrado por segundo)", check: "opcional; vazio = o de texto. Um modelo de raciocínio mais forte só onde a conta precisa estar certa (contas, leitura crítica, revisão)" };
13
13
  const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);
14
14
  let dialog, info, onSaved = () => {};
15
15
 
@@ -47,7 +47,7 @@ window.SagaAISettings = (() => {
47
47
  <input id="ai-cfg-url" data-f="url" placeholder="https://…/v1" spellcheck="false">
48
48
  <label for="ai-cfg-key">Chave</label>
49
49
  <input id="ai-cfg-key" data-f="key" type="password" autocomplete="off" spellcheck="false" placeholder="${c.keySet ? `configurada (${esc(c.keyHint)}${c.keyFrom === "variável" ? `, da variável ${esc(c.keyEnv)}` : ""}); em branco mantém` : "cole a chave do provedor"}">
50
- <div class="grid">${(info.roles || []).map((r) => `<div><label for="ai-cfg-${r}">${LABEL[r] || r}</label><input id="ai-cfg-${r}" data-model="${r}" value="${esc(c.models?.[r])}" placeholder="${info.recommended?.models?.[r] ? `ex.: ${esc(info.recommended.models[r])}` : ""}" spellcheck="false"><small>${HINT[r] || ""}</small></div>`).join("")}</div>
50
+ <div class="grid">${(info.roles || []).map((r) => `<div${r === "check" ? ' class="wide"' : ""}><label for="ai-cfg-${r}">${LABEL[r] || r}</label><input id="ai-cfg-${r}" data-model="${r}" value="${esc(c.models?.[r])}" placeholder="${info.recommended?.models?.[r] ? `ex.: ${esc(info.recommended.models[r])}` : ""}" spellcheck="false"><small>${HINT[r] || ""}</small></div>`).join("")}</div>
51
51
  <p><small>Texto e visão funcionam em qualquer provedor compatível com a API da OpenAI. Gerar imagem e buscar na web dependem do provedor: funcionam no OpenRouter com modelos de imagem e modelos <code>:online</code>; em outros, a pesquisa usa buscadores diretos e a imagem pode não sair.</small></p>
52
52
  <div data-status role="status"></div>
53
53
  <div class="row"><button type="button" data-close>Fechar</button><button type="button" data-test>Testar</button><button type="button" class="primary" data-save>Salvar</button></div>`;
@@ -378,6 +378,7 @@
378
378
  renderThumbnails();
379
379
  selectSlide(0);
380
380
  loadChatHistory();
381
+ conference.refresh(); // o selo da Conferência: uma consulta ao abrir; depois, a cada salvamento
381
382
  } catch (err) {
382
383
  showToast("Erro ao carregar apresentação: " + err.message);
383
384
  }
@@ -431,6 +432,7 @@
431
432
  window.SagaMap?.mount(dom.renderedSlideContainer); // só aqui (prévia grande); miniaturas e galeria ficam na prévia em SVG
432
433
  window.SagaMapData?.attach(dom.renderedSlideContainer, slide, () => { syncDeckToServer(); renderCurrentSlide(); }, { toast: showToast, hydrate: hydrateIcons, materialized: previewMaterialized });
433
434
  window.SagaCalc?.mount(dom.renderedSlideContainer); // calculadora ao vivo: os controles funcionam no editor também
435
+ window.SagaSolution?.mount(dom.renderedSlideContainer); // exercício interativo: idem
434
436
  window.SagaDiagrams?.mount(dom.renderedSlideContainer).then(() => reportDiagram(dom.renderedSlideContainer));
435
437
  window.SagaDecisionLab?.mount(dom.renderedSlideContainer);
436
438
  applyEditorStep(idx);
@@ -2156,6 +2158,7 @@
2156
2158
  showToast("Screenshot pronto. Use Apresentar para percorrer os destaques.");
2157
2159
  }
2158
2160
  function setupCreativeTools() {
2161
+ window.SagaDeveloperAuthoring.bind({ state, syncDeckToServer, renderThumbnails, renderCurrentSlide, openPane, showToast });
2159
2162
  const shotButton = document.createElement("button");
2160
2163
  shotButton.id = "btn-screenshot";
2161
2164
  shotButton.className = "rbtn rbtn-lg";
@@ -2806,6 +2809,13 @@
2806
2809
  state, showToast: (...a) => showToast(...a), renderThumbnails: () => renderThumbnails(), renderCurrentSlide: () => renderCurrentSlide(),
2807
2810
  trackDeck: (l) => trackDeck(l), hydrateIcons: (el) => hydrateIcons(el), plainTitle: (sl, i) => plainTitle(sl, i),
2808
2811
  });
2812
+ // Revisar › Conferência (conference-ui.js): o que as checagens acharam, com Corrigir com IA (pedido pontual ao chat)
2813
+ const conference = window.SagaConference.create({
2814
+ state, showToast: (...a) => showToast(...a), renderThumbnails: () => renderThumbnails(), renderCurrentSlide: () => renderCurrentSlide(),
2815
+ hydrateIcons: (el) => hydrateIcons(el), plainTitle: (sl, i) => plainTitle(sl, i),
2816
+ chatBusy: () => !!chatApi.getChatJob(),
2817
+ askChat: (text, extra) => { openPane("chat"); dom.chatInput.value = text; chatApi.handleChatSubmit(null, extra); },
2818
+ });
2809
2819
  // Desfazer / refazer único (history.js): todo caminho que muda o deck passa por trackDeck, com o rótulo da origem
2810
2820
  const deckHistory = window.SagaHistory.createHistory();
2811
2821
  function trackDeck(label = "Edição") {
@@ -2865,7 +2875,8 @@
2865
2875
  });
2866
2876
  if (adopted && !state.applyingHistory) deckHistory.rebase(state.deck);
2867
2877
  updateSaveStatus(res.ok ? "saved" : "error");
2868
- if (res.ok) window.SagaProject?.deckChanged();
2878
+ // o selo da Conferência acompanha o que mudou no deck (navegar entre slides não muda nada: não consulta)
2879
+ if (res.ok) { window.SagaProject?.deckChanged(); conference.refreshSoon(); }
2869
2880
  else if (throwOnError) throw new Error(data.error || 'Não foi possível salvar as mudanças antes de exportar.');
2870
2881
  } catch (err) {
2871
2882
  console.warn("Erro ao sincronizar com servidor:", err);
@@ -3706,7 +3717,7 @@ ${ta.value}`;
3706
3717
 
3707
3718
  // Chat lateral com IA (chat.js): mesma API, agora em módulo próprio.
3708
3719
  const chatApi = window.SagaChat({ state, dom, escHtml, hydrateIcons, showToast, openPane, currentPane,
3709
- streamAI, syncDeckToServer, renderThumbnails, renderCurrentSlide, trackDeck, fitRendered, refreshAIStatus });
3720
+ streamAI, syncDeckToServer, renderThumbnails, renderCurrentSlide, trackDeck, fitRendered, refreshAIStatus, previewMaterialized });
3710
3721
  const { handleChatSubmit, loadChatHistory, appendChatMessage, createProgressBubble, autoGrowChat, applyBrainstorm, addChatFile } = chatApi;
3711
3722
 
3712
3723
  function setNotesVisible(on) {
@@ -3909,6 +3920,7 @@ ${ta.value}`;
3909
3920
  document.getElementById("btn-header-footer").addEventListener("click", openHeaderFooter);
3910
3921
  document.getElementById("btn-styles")?.addEventListener("click", openStyles);
3911
3922
  document.getElementById("btn-review-changes")?.addEventListener("click", openReviewList);
3923
+ document.getElementById("btn-conference")?.addEventListener("click", () => conference.open());
3912
3924
  document.getElementById("btn-close-hf").addEventListener("click", closeHeaderFooter);
3913
3925
  document.getElementById("btn-hf-cancel").addEventListener("click", closeHeaderFooter);
3914
3926
  document.getElementById("btn-hf-apply").addEventListener("click", applyHeaderFooter);
@@ -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
  // ==========================================================================
@@ -96,7 +96,8 @@ window.SagaChat = function ({ state, dom, escHtml, hydrateIcons, showToast, open
96
96
  box.scrollIntoView({ block: "nearest" });
97
97
  }
98
98
 
99
- async function handleChatSubmit(e) {
99
+ // extra: o que o Studio manda junto do pedido (contas: [n] do painel Conferência, conferidas depois da resposta)
100
+ async function handleChatSubmit(e, extra = {}) {
100
101
  if (e) e.preventDefault();
101
102
  if (chatJob) { if(e?.type === "submit") chatJob.stop(); return; }
102
103
  const message = dom.chatInput.value.trim();
@@ -156,9 +157,14 @@ window.SagaChat = function ({ state, dom, escHtml, hydrateIcons, showToast, open
156
157
  renderNotes: state.renderNotes || [],
157
158
  autoRun: !!state.autoRunCommands, // a pessoa liberou os comandos desta conversa (só enquanto a página está aberta)
158
159
  expectFile: state.file,
160
+ ...(Array.isArray(extra?.contas) ? { contas: extra.contas } : {}),
159
161
  }, (ev) => { if (!commandEvent(ev)) work.update(ev); }, job.controller.signal);
160
162
  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
163
  if (!data.spec) throw new Error(data.error || "resposta sem deck");
164
+ if (data.materialized) {
165
+ state.file = data.materialized.file;
166
+ previewMaterialized(data.materialized);
167
+ }
162
168
  // conversa salva antes de dispensar o indicador: quem espera o fim da resposta já encontra o arquivo gravado
163
169
  const finishWork = async () => { await saveChatHistory(); work.done(); };
164
170
  if (data.variants) {
@@ -241,6 +247,7 @@ window.SagaChat = function ({ state, dom, escHtml, hydrateIcons, showToast, open
241
247
  dom.chatInput.disabled = false;
242
248
  dom.chatInput.focus();
243
249
  await saveChatHistory();
250
+ window.dispatchEvent(new Event("sagadeck:chat-done")); // o painel Conferência confere o selo de novo
244
251
  }
245
252
  }
246
253
 
@@ -317,7 +324,8 @@ window.SagaChat = function ({ state, dom, escHtml, hydrateIcons, showToast, open
317
324
  async function loadChatHistory() {
318
325
  state.autoRunCommands = false; // outra apresentação: nada fica liberado
319
326
  try {
320
- const { history } = await (await fetch("api/chat/history")).json();
327
+ const { history, notice } = await (await fetch("api/chat/history")).json();
328
+ if (notice) showToast("A criação com IA deixou avisos sem resolver: veja no chat da IA.", 8000);
321
329
  state.chatHistory = Array.isArray(history) ? history : [];
322
330
  state.loadingHistory = true; // conversa guardada não é resposta nova (o atalho do chat não acende)
323
331
  setTimeout(() => { state.loadingHistory = false; });
@@ -0,0 +1,91 @@
1
+ // Painel Revisar › Conferência: tudo o que as checagens automáticas acharam no deck aberto (contas, números sem base
2
+ // no material, avisos da revisão visual, figuras do material sem slide, slides que a IA calculou ou incluiu), por
3
+ // slide, com Ir ao slide, Corrigir com IA (um pedido pontual ao chat) e Ignorar (gravado no relatório). A lista vem
4
+ // do servidor (src/studio/conference.js); o app passa o que o painel usa (create(ctx)).
5
+ (function () {
6
+ const KIND_ICON = { conta: "calculator", material: "file-text", numero: "hash", visual: "scan-eye", figura: "image-off", derivado: "calculator", proprio: "sparkles" };
7
+ function create(ctx) {
8
+ const { state, showToast, renderThumbnails, renderCurrentSlide, hydrateIcons, plainTitle, askChat, chatBusy } = ctx;
9
+ let items = [], salvo = false, conferidoEm = null, timer = null, checking = false;
10
+ const json = async (r) => { const j = await r.json().catch(() => ({})); if (!r.ok || j.error) throw new Error(j.error || `HTTP ${r.status}`); return j; };
11
+ function take(j) {
12
+ items = j.items || []; salvo = !!j.salvo; conferidoEm = j.conferidoEm || null;
13
+ const b = document.getElementById("conference-badge");
14
+ if (b) { b.textContent = items.length; b.classList.toggle("clean", !items.length); }
15
+ document.getElementById("conference-dialog")?.dispatchEvent(new Event("refresh"));
16
+ }
17
+ async function refresh() {
18
+ try { take(await json(await fetch("api/conferencia"))); } catch { /* sem servidor: o selo fica como estava */ }
19
+ }
20
+ // o deck muda o tempo todo (formulário, IA): o selo acompanha sem uma consulta por tecla
21
+ const refreshSoon = () => { clearTimeout(timer); timer = setTimeout(refresh, 700); };
22
+ function goTo(n) {
23
+ if (!(n >= 1 && n <= (state.deck?.slides?.length || 0))) return;
24
+ state.currentSlideIndex = n - 1;
25
+ renderThumbnails(); renderCurrentSlide();
26
+ }
27
+ async function decide(id, status) {
28
+ try { take(await json(await fetch("api/conferencia/item", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id, status }) }))); }
29
+ catch (e) { showToast(e.message); }
30
+ }
31
+ async function recheck(btn) {
32
+ if (checking) return;
33
+ checking = true; btn.disabled = true;
34
+ btn.innerHTML = '<i class="ic" data-ic="loader-circle"></i> Conferindo…'; hydrateIcons(btn);
35
+ try {
36
+ const j = await json(await fetch("api/conferencia/conferir", { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" }));
37
+ take(j);
38
+ showToast(`Conferido: ${items.length ? `${items.length} pendência(s)` : "nada pendente"}.${j.notes?.length ? ` ${j.notes.join(" ")}` : ""}`, 6000);
39
+ } catch (e) { showToast(`A conferência não saiu: ${e.message}`); }
40
+ finally { checking = false; document.getElementById("conference-dialog")?.dispatchEvent(new Event("refresh")); }
41
+ }
42
+ function fix(it, dlg) {
43
+ if (chatBusy()) { showToast("Espere a IA terminar o pedido atual.", 3000); return; }
44
+ if (it.slide) goTo(it.slide);
45
+ dlg.close();
46
+ // conta que não bate: o Studio confere de novo depois da resposta (contas: [n]) e o resultado decide se ela
47
+ // sai do painel; o resto (sem como conferir sozinho) sai ao ir para o chat
48
+ if (it.kind !== "conta" && it.kind !== "material") decide(it.id, "enviado");
49
+ askChat(it.fix, it.contas ? { contas: it.contas } : {});
50
+ }
51
+ function open() {
52
+ let dlg = document.getElementById("conference-dialog");
53
+ const fill = () => {
54
+ const groups = new Map();
55
+ for (const it of items) { const k = it.slide || 0; if (!groups.has(k)) groups.set(k, []); groups.get(k).push(it); }
56
+ const order = [...groups.keys()].sort((a, b) => (a || 1e9) - (b || 1e9));
57
+ dlg.innerHTML = `<header><i class="ic" data-ic="list-checks"></i><b>Conferência</b><span class="rv-count">${items.length}</span><span class="grow"></span>${salvo ? `<button type="button" class="btn btn-sm" data-recheck title="Refaz no deck atual a conferência das contas, a checagem dos números e das figuras do material"><i class="ic" data-ic="${checking ? "loader-circle" : "refresh-cw"}"></i> ${checking ? "Conferindo…" : "Conferir de novo"}</button>` : ""}<button type="button" class="btn btn-sm" data-close title="Fechar"><i class="ic" data-ic="x"></i></button></header>
58
+ <div class="rv-list conf-list">${items.length ? order.map((k) => `<section class="conf-group" data-slide="${k}"><div class="conf-group-head">${k ? `<b class="rv-num">${k}</b><span class="conf-slide-title"></span><button type="button" class="btn btn-sm" data-go="${k}">Ir ao slide</button>` : "<b>Deck todo</b>"}</div>${groups.get(k).map((it) => `<div class="conf-item conf-${it.kind}" data-id="${it.id}"><span class="conf-kind"><i class="ic" data-ic="${KIND_ICON[it.kind] || "circle-alert"}"></i><span></span></span><span class="conf-text"></span><span class="conf-actions"><button type="button" class="btn btn-primary btn-sm" data-fix><i class="ic" data-ic="wand-sparkles"></i> Corrigir com IA</button><button type="button" class="btn btn-sm" data-ignore title="Está certo assim: sai do painel e dos próximos pedidos à IA"><i class="ic" data-ic="eye-off"></i> Ignorar</button></span></div>`).join("")}</section>`).join("")
59
+ : `<div class="rv-empty">${salvo ? "Nada pendente: as checagens automáticas não deixaram avisos neste deck." : "A conferência vale para apresentações salvas na biblioteca."}</div>`}</div>
60
+ ${conferidoEm ? `<div class="conf-foot">Conferido em ${new Date(conferidoEm).toLocaleString("pt-BR")}</div>` : ""}`;
61
+ dlg.querySelectorAll(".conf-group").forEach((g) => {
62
+ const n = +g.dataset.slide;
63
+ if (n && state.deck?.slides?.[n - 1]) g.querySelector(".conf-slide-title").textContent = plainTitle(state.deck.slides[n - 1], n - 1);
64
+ g.querySelector("[data-go]")?.addEventListener("click", () => { goTo(n); dlg.close(); });
65
+ });
66
+ dlg.querySelectorAll(".conf-item").forEach((row) => {
67
+ const it = items.find((x) => x.id === row.dataset.id);
68
+ row.querySelector(".conf-kind span").textContent = it.label;
69
+ row.querySelector(".conf-text").textContent = it.text;
70
+ row.querySelector("[data-fix]").onclick = () => fix(it, dlg);
71
+ row.querySelector("[data-ignore]").onclick = () => decide(it.id, "ignorado");
72
+ });
73
+ dlg.querySelector("[data-recheck]")?.addEventListener("click", (e) => recheck(e.currentTarget));
74
+ dlg.querySelector("[data-close]").onclick = () => dlg.close();
75
+ hydrateIcons(dlg);
76
+ };
77
+ if (!dlg) {
78
+ dlg = document.createElement("dialog");
79
+ dlg.id = "conference-dialog"; dlg.className = "review-dialog conference-dialog";
80
+ document.body.append(dlg);
81
+ dlg.addEventListener("refresh", () => { if (dlg.open) fill(); });
82
+ }
83
+ fill();
84
+ if (!dlg.open) dlg.showModal();
85
+ refresh();
86
+ }
87
+ window.addEventListener("sagadeck:chat-done", refreshSoon);
88
+ return { open, refresh, refreshSoon };
89
+ }
90
+ window.SagaConference = { create };
91
+ })();
@@ -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%}}