sagadeck 1.8.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.
Files changed (107) hide show
  1. package/README.md +25 -8
  2. package/SKILL.md +10 -3
  3. package/bin/sagadeck.js +23 -0
  4. package/docs/ANDAMENTO.md +482 -7
  5. package/docs/PLANO-MELHORIAS.md +180 -0
  6. package/docs/REFERENCIA.md +178 -111
  7. package/package.json +35 -4
  8. package/src/ai/commands.js +2 -2
  9. package/src/ai/context.js +7 -2
  10. package/src/ai/deck-ai.js +4 -2
  11. package/src/ai/ia-check.js +127 -0
  12. package/src/ai/ia-config.js +2 -2
  13. package/src/ai/llm.js +5 -1
  14. package/src/ai/map-analysis-run.js +1 -1
  15. package/src/ai/quality.js +1 -1
  16. package/src/ai/video-generation.js +70 -25
  17. package/src/api-client.js +42 -10
  18. package/src/build.js +46 -14
  19. package/src/code-highlight.js +19 -2
  20. package/src/code-language.js +13 -0
  21. package/src/elements.js +39 -1
  22. package/src/export/pptx.js +7 -2
  23. package/src/export/shots.js +7 -2
  24. package/src/fiscal/normalize.js +19 -0
  25. package/src/fs-retry.js +37 -2
  26. package/src/import/office.js +5 -4
  27. package/src/layouts.js +104 -99
  28. package/src/lessons.js +1 -1
  29. package/src/library.js +7 -7
  30. package/src/map-analysis.js +4 -1
  31. package/src/map-config.js +10 -4
  32. package/src/map.js +21 -8
  33. package/src/mcp/server.js +17 -0
  34. package/src/motion.js +20 -6
  35. package/src/purpose.js +1 -1
  36. package/src/runtime/api-core.js +122 -11
  37. package/src/runtime/api-ui.js +2 -1
  38. package/src/runtime/base.css +88 -332
  39. package/src/runtime/fit.js +3 -2
  40. package/src/runtime/fonts/amatic-sc.css +3 -0
  41. package/src/runtime/fonts/bebas-neue.css +2 -0
  42. package/src/runtime/fonts/cabin-sketch.css +2 -0
  43. package/src/runtime/fonts/fraunces.css +3 -0
  44. package/src/runtime/fonts/gelasio.css +3 -0
  45. package/src/runtime/fonts/ibm-plex-mono.css +4 -0
  46. package/src/runtime/fonts/index.json +10 -1
  47. package/src/runtime/fonts/itim.css +2 -0
  48. package/src/runtime/fonts/montserrat.css +2 -0
  49. package/src/runtime/fonts/space-grotesk.css +2 -0
  50. package/src/runtime/map-background.js +91 -0
  51. package/src/runtime/map.js +41 -9
  52. package/src/runtime/runtime.js +81 -100
  53. package/src/runtime/skins/aurora-dev.css +39 -0
  54. package/src/runtime/skins/caderno.css +33 -0
  55. package/src/runtime/skins/cartaz.css +27 -0
  56. package/src/runtime/skins/infografico.css +29 -0
  57. package/src/runtime/skins/lousa.css +22 -0
  58. package/src/runtime/skins/musgo.css +42 -0
  59. package/src/runtime/skins/terminal-pro.css +33 -0
  60. package/src/runtime/vendor/MAPLIBRE-LEAFLET-LICENSE.txt +15 -0
  61. package/src/runtime/vendor/MAPLIBRE-LICENSE.txt +116 -0
  62. package/src/runtime/vendor/geo-contornos.json +1 -1
  63. package/src/runtime/vendor/maplibre-gl.css +1 -0
  64. package/src/runtime/vendor/maplibre-gl.js +825 -0
  65. package/src/runtime/vendor/maplibre-leaflet.js +234 -0
  66. package/src/runtime/vendor/maplibre-worker.js +10 -0
  67. package/src/studio/ai-chat-route.js +26 -7
  68. package/src/studio/assets/imagens/tour-mapa-botao.jpg +0 -0
  69. package/src/studio/assets/imagens/tour-mapa-editor.jpg +0 -0
  70. package/src/studio/command-approval.js +2 -3
  71. package/src/studio/demo-decks.js +4 -2
  72. package/src/studio/developer-starters.js +46 -0
  73. package/src/studio/export-jobs.js +86 -0
  74. package/src/studio/exporting.js +36 -18
  75. package/src/studio/feature-demos.js +144 -0
  76. package/src/studio/ia-routes.js +9 -0
  77. package/src/studio/layout-samples.js +9 -18
  78. package/src/studio/library-routes.js +7 -9
  79. package/src/studio/project.js +1 -0
  80. package/src/studio/public/about.js +68 -0
  81. package/src/studio/public/ai-settings.js +2 -2
  82. package/src/studio/public/app.js +43 -52
  83. package/src/studio/public/chat.js +5 -1
  84. package/src/studio/public/developer-authoring.css +12 -0
  85. package/src/studio/public/developer-authoring.js +306 -0
  86. package/src/studio/public/export-status.js +154 -0
  87. package/src/studio/public/generation-guide.js +42 -0
  88. package/src/studio/public/index.html +31 -10
  89. package/src/studio/public/library.css +48 -3
  90. package/src/studio/public/library.html +5 -1
  91. package/src/studio/public/library.js +150 -41
  92. package/src/studio/public/map-data.js +7 -3
  93. package/src/studio/public/map-editor.js +625 -236
  94. package/src/studio/public/map-settings.js +3 -1
  95. package/src/studio/public/slide-form.js +27 -29
  96. package/src/studio/public/style.css +56 -9
  97. package/src/studio/public/ui-icons.js +1 -1
  98. package/src/studio/server.js +29 -14
  99. package/src/studio/snapshot.js +8 -4
  100. package/src/studio/template-collections.js +1 -1
  101. package/src/studio/tour.js +89 -7
  102. package/src/themes.js +202 -0
  103. package/templates/cenario-e-ciencia.yaml +1 -21
  104. package/templates/ensaio-api.yaml +3 -3
  105. package/templates/cenario/Texto no cen/303/241rio.yaml" +0 -171
  106. package/templates/cenario/imagens/cidade.svg +0 -30
  107. package/templates/cenario/imagens/pessoa.svg +0 -15
@@ -22,11 +22,10 @@ export const LAYOUT_INFO = {
22
22
  science: ["Fórmulas e funções", "Escreva a fórmula e ela vira curva; cada letra vira um controle deslizante. Aceita pontos colados do Excel e superfície 3D"],
23
23
  infographic: ["Infográfico", "Arco, ramos, lados, trilhas ou metrô: de 2 a 8 itens coloridos em volta de um centro"],
24
24
  diagram: ["Diagrama", "Fluxograma, sequência, estados, UML, jornada, mapa mental (Mermaid) nas cores do tema"],
25
- scenography: ["Texto no cenário", "13 composições (palco, terminal, café, embarque, pregão, cinema, revista…), sem animação"],
26
25
  cover: ["Capa", "Abertura com título, subtítulo e autor"],
27
26
  section: ["Seção", "Abre um novo ato ou capítulo"],
28
27
  statement: ["Frase de impacto", "Uma ideia em letras grandes"],
29
- calendar: ["Calendário", "Datas num calendário: um cartão por mês com o mini calendário e os eventos marcados; o que não tem data vai em \"sem data\""],
28
+ calendar: ["Calendário", "Datas num calendário: um cartão por mês, um mês grande de parede, a semana com horários ou a agenda em lista; com muitos eventos, um slide por mês"],
30
29
  poster: ["Pôster (infográfico)", "Uma página como figura de revista científica: ilustração principal e painéis com letra (a, b, c), desenho, texto curto e números"],
31
30
  definition: ["Definição", "O termo em destaque, a origem da palavra em partes e a definição, com um ícone ilustrativo"],
32
31
  headline: ["Manchete", "Uma frase enorme ocupando o slide"],
@@ -58,7 +57,6 @@ export const LAYOUT_INFO = {
58
57
  codewalk: ["Código guiado", "Explique uma linha por vez, com destaques e saída simulada"],
59
58
  api: ["API ao vivo", "Requisição que executa na hora (tipo Postman): síncrono, polling ou streaming, com código curl/Python"],
60
59
  spotlight: ["Foco guiado", "Conduza o olhar por detalhes de uma imagem ou diagrama"],
61
- kinetic: ["Tipografia cinética", "Frases em sequência viram parte da cena com ritmo e estilos tipográficos que mudam"],
62
60
  video: ["Vídeo", "Player YouTube online ou MP4 incorporado"],
63
61
  blocks: ["Livre (blocos)", "Monte com blocos em linhas e colunas"],
64
62
  canvas: ["Livre (posições)", "Posicione cada elemento à mão"],
@@ -87,7 +85,6 @@ export const LAYOUT_ASK = {
87
85
  science: "Mostre a parábola y = ax² + bx + c com controles para a, b e c",
88
86
  infographic: "Faça um infográfico com os cinco pilares da sustentabilidade em volta de um centro",
89
87
  diagram: "Desenhe o fluxo de atendimento de um pedido, da chegada à entrega",
90
- scenography: "Abra com a frase \"Além do óbvio\" num cenário de palco",
91
88
  cover: "Faça a capa da palestra sobre energia solar, com o meu nome e a data",
92
89
  section: "Abra a segunda parte, sobre os resultados",
93
90
  statement: "Uma frase de impacto: a água mais barata é a que não se desperdiça",
@@ -123,7 +120,6 @@ export const LAYOUT_ASK = {
123
120
  codewalk: "Explique este código uma linha por vez, com a saída simulada",
124
121
  api: "Faça uma chamada de API ao vivo que consulta o clima e mostra a resposta",
125
122
  spotlight: "Conduza o olhar pelas partes desta planta baixa, uma por vez",
126
- kinetic: "Faça a abertura com frases curtas aparecendo em ritmo, estilo tipografia cinética",
127
123
  video: "Ponha este vídeo do YouTube no slide",
128
124
  blocks: "Monte livremente um slide com título, dois textos e uma imagem em colunas",
129
125
  canvas: "Posicione o logotipo, o título e a foto onde eu indicar",
@@ -145,8 +141,8 @@ export const LAYOUT_SAMPLES = {
145
141
  ribbon: {layout:"ribbon",title:"Do primeiro passo ao resultado",items:[{title:"Explorar",text:"Encontre uma oportunidade."},{title:"Testar",text:"Experimente em pequena escala."},{title:"Evoluir",text:"Repita o que funciona."}]},
146
142
  dossier: {layout:"dossier",title:"Referência rápida",items:[{title:"Requisição",text:"GET consulta o recurso.",code:"GET /exemplos/42"},{title:"Resposta",text:"Confira o status antes de usar o corpo.",code:'{"id":42,"status":"ok"}'}]},
147
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}},
148
- infographic: { layout: "infographic", shape: "arco", title: "Os ==desafios== do projeto", center: { title: "Desafios" }, items: [{ title: "Acesso", text: "Pedido pela Bridge" }, { title: "Dados", text: "Mockados no experimento" }, { title: "Segurança", text: "Chaves no Key Vault" }, { title: "Infra", text: "Namespace e firewall" }] },
149
- 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: Experimento]\n B -.->|não| X[Arquivar]:::vazado\n C ==> D[:rocket: Produção]:::em" },
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
+ 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" },
150
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."}},
151
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}}]},
152
148
  algo: {layout:"algo", title:"Bubble sort passo a passo", algorithm:"bubble", array:[5, 1, 4, 2, 8, 3]},
@@ -157,15 +153,14 @@ export const LAYOUT_SAMPLES = {
157
153
  terminals: {layout:"terminals", title:"Dois terminais", file:"README.md", base:"# Projeto\nversão 1", people:["Ana","Beto"],
158
154
  turns:[{who:1, edit:{2:"versão 2"}, commit:"sobe a versão"}, {who:1, push:true}, {who:2, pull:true}, {who:2, log:true}]},
159
155
  turns: {layout:"turns", title:"Code review ao vivo", people:[{name:"Ana", role:"autora do PR"}, {name:"Beto", role:"revisor"}],
160
- turns:[{who:1, text:"Abri o PR com a validação do Pix."}, {who:2, text:"Por que não usa o **schema** que já existe?", tag:"pergunta"}, {who:1, text:"Boa. Troquei e testei assim:", method:"POST", url:"/pix", status:201, code:'{ "valor": 10 }', language:"json"}, {who:2, text:"Aprovado.", tag:"aprovado"}]},
156
+ turns:[{who:1, text:"Abri o PR com a validação das inscrições."}, {who:2, text:"Por que não usa o **schema** que já existe?", tag:"pergunta"}, {who:1, text:"Boa. Troquei e testei assim:", method:"POST", url:"/inscricoes", status:201, code:'{ "nome": "Ana" }', language:"json"}, {who:2, text:"Aprovado.", tag:"aprovado"}]},
161
157
  science: {layout:"science", title:"Mexa no a e no b", equations:[{label:"A função",latex:"f(x) = a\\sin(bx)"},{label:"A derivada",latex:"f'(x) = ab\\cos(bx)"}], plot:{functions:["a*sin(b*x)","a*b*cos(b*x)"], x:[-6.3,6.3], params:{a:{value:1.5,min:0,max:3,label:"amplitude"},b:{value:1,min:0.5,max:3,label:"frequência"}}}},
162
- scenography: {layout:"scenography", scene:"stage", title:"ALÉM DO\nÓBVIO", kicker:"UMA NOVA PERSPECTIVA", subtitle:"Ideias que ocupam espaço.", tone:"dark", deco:"none"},
163
158
  cover: { layout: "cover", kicker: "Evento 2026", title: "Título da ==apresentação==", subtitle: "Um subtítulo curto", author: "Seu nome", figure: { icon: "rocket", size: 320 } },
164
159
  section: { layout: "section", number: 2, kicker: "Parte dois", title: "O problema" },
165
160
  statement: { layout: "statement", kicker: "A ideia", text: "Uma frase que ==muda tudo==." },
166
161
  calendar: { layout: "calendar", kicker: "Agenda", title: "Próximos lançamentos", events: [
167
162
  { date: "2026-11-12", title: "Beta aberto", tag: "Produto", text: "Para os 500 primeiros" }, { date: "2026-11-28", title: "Evento de lançamento", tag: "Evento" },
168
- { date: "2027-01-15", title: "Versão 2.0", tag: "Produto", text: "Com o app de celular" }], undated: [{ title: "Integração com o banco", text: "Em negociação" }] },
163
+ { date: "2027-01-15", title: "Versão 2.0", tag: "Produto", text: "Com o app de celular" }], undated: [{ title: "Loja em outra cidade", text: "Em negociação" }] },
169
164
  poster: { layout: "poster", kicker: "Receita", title: "Pão de fermentação natural", subtitle: "Do fermento ao forno em quatro etapas", flow: true,
170
165
  panels: [
171
166
  { title: "Fermento", icon: "wheat", text: "Farinha e água, alimentados por 5 dias", facts: [{ value: "1:1", label: "farinha e água" }] },
@@ -176,9 +171,9 @@ export const LAYOUT_SAMPLES = {
176
171
  definition: { layout: "definition", kicker: "O que é", term: "Hidrologia", origin: "do grego", parts: [{ word: "hydor", meaning: "água" }, { word: "logos", meaning: "ciência" }], text: "É a ciência que estuda ==a água sobre a Terra==: sua quantidade, distribuição e circulação, e sua relação com o meio ambiente e com os seres vivos.", icon: "droplet" },
177
172
  headline: { layout: "headline", text: "78% ==desistem==", caption: "no segundo passo" },
178
173
  quote: { layout: "quote", quote: "Dados vencem opiniões.", by: "Alguém sábio" },
179
- onepage: { layout: "onepage", title: "Cadastro digital", journey: [{ icon: "user", title: "Cliente chega", text: "Fila de 40 min" }, { icon: "file-text", title: "Preenche papel" }, { icon: "check", title: "Conta aberta", text: "Até 5 dias" }], problem: { text: "Cadastro em papel atrasa a abertura de contas", numbers: [{ value: "5 dias", label: "para abrir uma conta" }] }, solution: { text: "Cadastro no tablet, com assinatura digital", items: ["Conta aberta no mesmo dia"] } },
180
- status: { layout: "status", kicker: "Semana 39", title: "Portal do cliente", health: "risco", progress: 65, done: ["Tela de login", "Integração com o cadastro"], doing: ["Recuperação de senha"], blocked: [{ text: "Liberação de firewall", owner: "Infra", due: "30/09" }], upcoming: ["Homologar com o negócio"] },
181
- hub: { layout: "hub", title: "Qual é o seu caso?", question: "Cada caminho tem o seu prazo", options: [{ icon: "flask-conical", title: "Experimento", meta: "30 dias", goto: "exp" }, { icon: "rocket", title: "Projeto", meta: "DEV, HOM e PROD", goto: "proj" }] },
174
+ onepage: { layout: "onepage", title: "Agendamento online", journey: [{ icon: "user", title: "Paciente liga", text: "Espera de 15 min" }, { icon: "file-text", title: "Preenche a ficha" }, { icon: "check", title: "Consulta marcada", text: "Até 5 dias" }], problem: { text: "Marcar por telefone atrasa as consultas", numbers: [{ value: "5 dias", label: "para marcar uma consulta" }] }, solution: { text: "Agendamento pelo celular, com confirmação automática", items: ["Consulta marcada no mesmo dia"] } },
175
+ status: { layout: "status", kicker: "Semana 39", title: "Portal do cliente", health: "risco", progress: 65, done: ["Tela de login", "Integração com o cadastro"], doing: ["Recuperação de senha"], blocked: [{ text: "Aprovação do texto de privacidade", owner: "Jurídico", due: "30/09" }], upcoming: ["Teste com 20 usuários"] },
176
+ hub: { layout: "hub", title: "Qual é o seu caso?", question: "Cada caminho tem o seu ritmo", options: [{ icon: "user", title: "Primeira vez", meta: "4 passos", goto: "inicio" }, { icon: "rocket", title: "Já uso", meta: "Atalhos", goto: "atalhos" }] },
182
177
  number: { layout: "number", value: 42, suffix: "%", label: "mais rápido" },
183
178
  split: { layout: "split", title: "Texto e figura", bullets: ["Primeiro ponto", "Segundo ponto"], figure: { icon: "lightbulb", size: 300 } },
184
179
  full: { layout: "full", figure: { chart: "donut", value: 72, center: "72%" }, title: "Figura em página inteira", overlay: "left" },
@@ -209,11 +204,7 @@ export const LAYOUT_SAMPLES = {
209
204
  hotspots: [{ x: 4, y: 18, width: 92, height: 15, title: "O pedido", text: "O verbo GET consulta o recurso. A rota identifica a coleção de aulas." },
210
205
  { x: 4, y: 38, width: 92, height: 10, title: "A confirmação", text: "200 OK confirma o sucesso. O tempo ajuda a investigar a experiência do usuário." },
211
206
  { x: 5, y: 52, width: 78, height: 39, title: "Os dados", text: "A estrutura JSON é o contrato que o front-end precisa entender." }] },
212
- kinetic: { layout: "kinetic", figure: { svg: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1920 1080"><defs><linearGradient id="stage" x2="0" y2="1"><stop stop-color="#697a78"/><stop offset="1" stop-color="#26353a"/></linearGradient><linearGradient id="window" x2="0" y2="1"><stop stop-color="#e1cba1"/><stop offset="1" stop-color="#8d9990"/></linearGradient></defs><path fill="url(#stage)" d="M0 0h1920v1080H0z"/><path fill="#202b2e" d="M0 0h430v1080H0zM1490 0h430v1080h-430z"/><path fill="url(#window)" d="M500 90h910v660H500z"/><path fill="#d0b88e" opacity=".4" d="M500 560h910v190H500z"/><path fill="#293432" d="M0 850h1920v230H0z"/><path fill="#394342" d="M720 814h480v36H720z"/><path fill="#26302f" d="M836 555c28-120 205-154 266-14l76 263H760z"/><circle cx="960" cy="465" r="74" fill="#39403b"/></svg>' }, autoplay: true, interval: 1800,
213
- beats: [{ text: "Uma ideia", style: "clean", position: "left", color: "white", size: "medium", tag: "GANHA FORMA" },
214
- { text: "muda o olhar", style: "clean", position: "right", color: "gold", size: "medium" },
215
- { text: "e abre caminhos", style: "clean", position: "left", color: "white", size: "medium" }] },
216
- // genérico de propósito: troque método, URL e corpo pelos do seu serviço (os exemplos prontos ficam no deck "Exemplo: aula de APIs")
207
+ // genérico de propósito: troque método, URL e corpo pelos do seu serviço (os exemplos prontos ficam na demo "APIs com curl" e no `sagadeck ensaio-api`)
217
208
  api: { layout: "api", kicker: "Ao vivo", title: "Minha ==chamada==", text: "Troque o método, a URL e o corpo pelos do seu serviço", request: { method: "POST", url: "{{base}}/echo", body: { mensagem: "Olá, API" } }, answer: "$.recebido.mensagem" },
218
209
  video: { layout: "video", title: "Assista", url: "https://example.com", label: "Assistir" },
219
210
  blocks: { layout: "blocks", title: "Blocos livres", content: [{ row: [{ lead: "Esquerda" }, { lead: "Direita" }] }] },
@@ -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,
@@ -103,15 +110,6 @@ export async function libraryRoutes({ req, res, pathname, url, W, opts, readJSON
103
110
  if (!made) throw new Error("Não há um modelo em prévia aberto.");
104
111
  return ok(made);
105
112
  }
106
- case "/api/library/decks/example-cenario": {
107
- // demonstração do Texto no cenário: o YAML e as imagens de exemplo (fundo e recorte transparente)
108
- const dir0 = TEMPLATE_DIRS.map((d) => path.join(d, "cenario")).find((d) => fs.existsSync(d));
109
- if (!dir0) throw new Error("o exemplo não veio no pacote (templates/cenario)");
110
- const tpl = fs.readdirSync(dir0).find((f) => f.endsWith(".yaml"));
111
- const id = L.createDeck(b.topic || "", YAML.parse(fs.readFileSync(path.join(dir0, tpl), "utf8")));
112
- fs.cpSync(path.join(dir0, "imagens"), path.join(path.dirname(L.resolveId(id)), "imagens"), { recursive: true, force: false });
113
- return ok({ id });
114
- }
115
113
  case "/api/library/decks/example": {
116
114
  // exemplo de slides api (aula de APIs de IA): roda no ambiente embutido "ensaio", sem configurar nada
117
115
  const tpl = templateFile("ensaio-api.yaml");
@@ -226,6 +226,7 @@ export function writeSheet(P, rel, rows) {
226
226
  if (!Array.isArray(rows) || rows.some((r) => !Array.isArray(r))) throw new Error("Planilha inválida.");
227
227
  const old = fs.existsSync(abs) ? parseCSV(fs.readFileSync(abs, "utf8"), ext === "tsv" ? "\t" : undefined) : { delimiter: ext === "tsv" ? "\t" : ",", eol: "\n" };
228
228
  const tmp = abs + ".tmp";
229
+ fs.mkdirSync(path.dirname(abs), { recursive: true }); // planilha nova numa pasta que ainda não existe (contexto/)
229
230
  const clean = rows.map((r) => r.map((c) => String(c ?? "")));
230
231
  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
231
232
  fs.writeFileSync(tmp, toCSV(clean, old));
@@ -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
+ })();
@@ -8,8 +8,8 @@ window.SagaAISettings = (() => {
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" };
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" };
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)" };
13
13
  const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);
14
14
  let dialog, info, onSaved = () => {};
15
15
 
@@ -35,7 +35,7 @@
35
35
  const LAYOUT_NAMES = [
36
36
  "cover", "section", "statement", "definition", "poster", "calendar", "headline", "quote", "number", "split", "full",
37
37
  "cards", "bento", "mosaic", "ribbon", "stats", "steps", "funnel", "pyramid", "list", "agenda", "timeline", "hub", "status", "onepage",
38
- "chart", "table", "map", "compare", "matrix", "decisionlab", "diagram", "infographic", "question", "poll", "image", "code", "codewalk", "spotlight", "scenography", "portal", "science", "kinetic", "video", "carousel", "duel", "terminals", "turns", "solution", "calc", "algo", "graphlab", "codelab", "playground",
38
+ "chart", "table", "map", "compare", "matrix", "decisionlab", "diagram", "infographic", "question", "poll", "image", "code", "codewalk", "spotlight", "portal", "science", "video", "carousel", "duel", "terminals", "turns", "solution", "calc", "algo", "graphlab", "codelab", "playground",
39
39
  "blocks", "dossier", "canvas", "references", "end",
40
40
  ];
41
41
 
@@ -45,7 +45,7 @@
45
45
  split: "Texto e figura", cards: "Cartões", stats: "Indicadores", steps: "Etapas", list: "Lista",
46
46
  timeline: "Linha do tempo", chart: "Gráfico de dados", table: "Tabela", map: "Mapa", compare: "Comparação", matrix: "Matriz 2×2",
47
47
  question: "Pergunta", poll: "Enquete", image: "Imagem", code: "Código", video: "Vídeo",
48
- diagram: "Diagrama", infographic: "Infográfico", mosaic: "Grade adaptável", ribbon: "Cápsulas", dossier: "Página de consulta", decisionlab: "Laboratório de decisões", science: "Fórmulas e funções", scenography: "Texto no cenário", codewalk: "Código guiado", spotlight: "Foco guiado", kinetic: "Tipografia cinética",
48
+ diagram: "Diagrama", infographic: "Infográfico", mosaic: "Grade adaptável", ribbon: "Cápsulas", dossier: "Página de consulta", decisionlab: "Laboratório de decisões", science: "Fórmulas e funções", codewalk: "Código guiado", spotlight: "Foco guiado",
49
49
  duel: "Duelo de commits", terminals: "Dois terminais", turns: "Turnos a dois", carousel: "Carrossel", solution: "Exercício resolvido", calc: "Calculadora ao vivo", algo: "Algoritmo animado",
50
50
  graphlab: "Rede explorável", codelab: "Código explorável", playground: "Experiência personalizada", portal: "Portal e transformação",
51
51
  blocks: "Livre (blocos)", canvas: "Livre (posições)", end: "Encerramento", references: "Referências",
@@ -429,7 +429,7 @@
429
429
  dom.renderedSlideContainer.innerHTML = data.html;
430
430
  window.SagaScience?.mount(dom.renderedSlideContainer);
431
431
  window.SagaMap?.mount(dom.renderedSlideContainer); // só aqui (prévia grande); miniaturas e galeria ficam na prévia em SVG
432
- window.SagaMapData?.attach(dom.renderedSlideContainer, slide, () => { syncDeckToServer(); renderCurrentSlide(); }, { toast: showToast, hydrate: hydrateIcons });
432
+ window.SagaMapData?.attach(dom.renderedSlideContainer, slide, () => { syncDeckToServer(); renderCurrentSlide(); }, { toast: showToast, hydrate: hydrateIcons, materialized: previewMaterialized });
433
433
  window.SagaCalc?.mount(dom.renderedSlideContainer); // calculadora ao vivo: os controles funcionam no editor também
434
434
  window.SagaDiagrams?.mount(dom.renderedSlideContainer).then(() => reportDiagram(dom.renderedSlideContainer));
435
435
  window.SagaDecisionLab?.mount(dom.renderedSlideContainer);
@@ -2053,7 +2053,7 @@
2053
2053
  ["processo", "Processos e diagramas", ["steps", "timeline", "funnel", "pyramid", "diagram", "infographic", "hub"]],
2054
2054
  ["gestao", "Gestão e status", ["status", "onepage", "decisionlab"]],
2055
2055
  ["codigo", "Código e API", ["code", "codewalk", "api"]],
2056
- ["visual", "Imagem e movimento", ["image", "full", "carousel", "spotlight", "video", "scenography", "portal", "kinetic"]],
2056
+ ["visual", "Imagem e movimento", ["image", "full", "carousel", "spotlight", "video", "portal"]],
2057
2057
  ["plateia", "Plateia", ["question", "poll"]],
2058
2058
  ["dinamicas", "Dinâmicas a dois", ["duel", "terminals", "turns"]],
2059
2059
  ["aula", "Aula: exercícios e algoritmos", ["solution", "calc", "algo", "graphlab", "codelab", "playground"]],
@@ -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";
@@ -2329,38 +2330,13 @@
2329
2330
 
2330
2331
  // Ao trocar de layout, o conteúdo em lista (cartões, etapas, eventos, níveis…) vai junto
2331
2332
  const LIST_KEY = { cards: "items", bento: "tiles", stats: "stats", steps: "steps", funnel: "stages", pyramid: "levels", list: "items",
2332
- agenda: "items", timeline: "events", matrix: "cells", question: "options", poll: "options", split: "bullets", statement: "lines", references: "items", end: "contacts", kinetic: "beats" };
2333
+ agenda: "items", timeline: "events", matrix: "cells", question: "options", poll: "options", split: "bullets", statement: "lines", references: "items", end: "contacts" };
2333
2334
  const SOURCE_KEYS = ["items", "tiles", "stats", "kpis", "steps", "process", "flow", "stages", "levels", "events", "cells", "options", "bullets", "lines", "contacts"];
2334
- function kineticPhrases(value) {
2335
- const words = String(value || "").match(/==[^=]+==|\*\*.*?\*\*|\*[^*]+\*|~~.*?~~|`[^`]+`|\[[^\]]+\]\([^)]+\)|\S+/g) || [];
2336
- const connectors = new Set(["a", "as", "de", "do", "da", "dos", "das", "e", "em", "no", "na", "nos", "nas", "para", "por", "com", "o", "os"]);
2337
- const phrases = [];
2338
- while (words.length) {
2339
- let take = Math.ceil(words.length / Math.ceil(words.length / 4));
2340
- const last = words[take - 1]?.replace(/^[^A-Za-zÀ-ÿ]+|[^A-Za-zÀ-ÿ]+$/g, "").toLowerCase();
2341
- if (take > 1 && connectors.has(last)) take--;
2342
- phrases.push(words.splice(0, take).join(" "));
2343
- }
2344
- return phrases;
2345
- }
2346
2335
  function carryContent(slide, to) {
2347
2336
  const target = LIST_KEY[to];
2348
2337
  if (!target) return;
2349
2338
  const activeKey = LIST_KEY[slide.layout];
2350
2339
  if (activeKey === target && Array.isArray(slide[target]) && slide[target].length) return;
2351
- if (to === "kinetic") {
2352
- const source = SOURCE_KEYS.find((key) => Array.isArray(slide[key]) && slide[key].length);
2353
- if (source) {
2354
- slide.beats = slide[source].map((item) => {
2355
- if (typeof item !== "object" || item == null) return { text: String(item ?? "") };
2356
- return { text: item.title ?? item.label ?? item.text ?? item.name ?? "", ...(item.text && item.title ? { tag: item.text } : {}) };
2357
- });
2358
- } else {
2359
- const phrases = [...kineticPhrases(slide.title || slide.text), ...kineticPhrases(slide.subtitle)];
2360
- if (phrases.length) slide.beats = phrases.map((text, i) => ({ text, ...(i === 0 && slide.kicker ? { tag: slide.kicker } : {}) }));
2361
- }
2362
- return;
2363
- }
2364
2340
  const srcKey = [activeKey, ...SOURCE_KEYS].find((k) => k && Array.isArray(slide[k]) && slide[k].length);
2365
2341
  if (!srcKey) return;
2366
2342
  const entries = slide[srcKey].map((x) => {
@@ -2391,7 +2367,7 @@
2391
2367
  }
2392
2368
  if (layoutName === "quote") slide.quote = primary;
2393
2369
  if (["question", "poll"].includes(layoutName)) slide.question = primary;
2394
- if (LIST_KEY[layoutName] && !slide[LIST_KEY[layoutName]]?.length && !["kinetic", "statement"].includes(layoutName)) {
2370
+ if (LIST_KEY[layoutName] && !slide[LIST_KEY[layoutName]]?.length && layoutName !== "statement") {
2395
2371
  const content = [slide.subtitle, slide.body, primary].find(v => typeof v === "string" && v.trim());
2396
2372
  if (content) slide[LIST_KEY[layoutName]] = content.split(/\n+/).filter(Boolean).map(text => ["list", "split", "question", "poll", "references", "end"].includes(layoutName) ? text : {title:text});
2397
2373
  }
@@ -2452,6 +2428,23 @@
2452
2428
  } catch (e) { showToast(`Não deu para atualizar: ${e.message}`); }
2453
2429
  },
2454
2430
  hydrate: hydrateIcons,
2431
+ splitCalendar: async () => {
2432
+ const at = state.currentSlideIndex, slide = state.deck.slides[at];
2433
+ const { calendarMonth, calendarMonths } = window.SagaCalendarMonths;
2434
+ const MESES = ["janeiro", "fevereiro", "março", "abril", "maio", "junho", "julho", "agosto", "setembro", "outubro", "novembro", "dezembro"];
2435
+ const months = calendarMonths(slide);
2436
+ if (months.length < 2) return;
2437
+ const { uid, events, undated, month, week, ...base } = slide;
2438
+ const slides = months.map((m, k) => ({ ...base, ...(k === 0 && uid ? { uid } : {}), view: "mes", month: m,
2439
+ kicker: base.kicker || `${MESES[+m.slice(5) - 1]} ${m.slice(0, 4)}`,
2440
+ events: events.filter((e) => calendarMonth(e?.date) === m),
2441
+ ...(k === 0 && (undated?.length || events.some((e) => !calendarMonth(e?.date))) ? { undated: [...(undated || []), ...events.filter((e) => !calendarMonth(e?.date)).map(({ title, text }) => ({ title, ...(text ? { text } : {}) }))] } : {}) }));
2442
+ state.deck.slides.splice(at, 1, ...slides);
2443
+ await syncDeckToServer();
2444
+ renderThumbnails();
2445
+ await renderCurrentSlide();
2446
+ showToast(`Um slide por mês: ${slides.length} slides, na visão de mês.`, 4000);
2447
+ },
2455
2448
  pickIcon: (cb) => openIconPicker(null, cb),
2456
2449
  layoutLabel,
2457
2450
  generateImage: (element, btn) => {
@@ -2609,7 +2602,9 @@
2609
2602
  }
2610
2603
 
2611
2604
  async function startPresentation({ pen = false } = {}) {
2612
- await syncDeckToServer(); // a prévia é montada a partir do deck do servidor
2605
+ // a apresentação é montada a partir do deck do servidor. Modelo em prévia: o servidor já tem o mesmo deck (qualquer
2606
+ // mudança teria criado a cópia e saído da prévia), e enviar de novo contaria como mudança: apresentar não grava nada
2607
+ if (!state.preview) await syncDeckToServer();
2613
2608
  const start = state.currentSlideIndex + 1;
2614
2609
  dom.presModal.classList.remove("hidden");
2615
2610
  dom.presFrame.src = scopedURL(`preview?t=${Date.now()}#${start}`);
@@ -2848,7 +2843,7 @@
2848
2843
  // outra aba/aparelho abriu outra apresentação neste Studio: nada desta foi gravado (o servidor recusou)
2849
2844
  function staleTab(msg) { updateSaveStatus("error"); showToast(msg, 20000, { label: "Recarregar", fn: () => location.reload() }); }
2850
2845
 
2851
- async function syncDeckToServer() {
2846
+ async function syncDeckToServer({ throwOnError = false } = {}) {
2852
2847
  trackDeck("Edição");
2853
2848
  updateSaveStatus("saving");
2854
2849
  try {
@@ -2858,7 +2853,7 @@
2858
2853
  body: JSON.stringify({ spec: state.deck, expectFile: state.file }),
2859
2854
  });
2860
2855
  const data = await res.json().catch(() => ({}));
2861
- if (data.stale) return staleTab(data.error);
2856
+ if (data.stale) { staleTab(data.error); if (throwOnError) throw new Error(data.error); return; }
2862
2857
  if ("file" in data) state.file = data.file;
2863
2858
  if (data.materialized) previewMaterialized(data.materialized);
2864
2859
  // o servidor dá identidade a slide novo (uid) e leva o ajuste visual junto quando um texto muda: o Studio adota
@@ -2872,9 +2867,11 @@
2872
2867
  if (adopted && !state.applyingHistory) deckHistory.rebase(state.deck);
2873
2868
  updateSaveStatus(res.ok ? "saved" : "error");
2874
2869
  if (res.ok) window.SagaProject?.deckChanged();
2870
+ else if (throwOnError) throw new Error(data.error || 'Não foi possível salvar as mudanças antes de exportar.');
2875
2871
  } catch (err) {
2876
2872
  console.warn("Erro ao sincronizar com servidor:", err);
2877
2873
  updateSaveStatus("error");
2874
+ if (throwOnError) throw err;
2878
2875
  }
2879
2876
  }
2880
2877
 
@@ -3635,9 +3632,8 @@ ${ta.value}`;
3635
3632
 
3636
3633
  const isMobile = () => window.matchMedia("(max-width: 900px)").matches;
3637
3634
 
3638
- // Painel lateral: Formatar, Assistente ou Variáveis
3639
- // abas do painel lateral: Formatar (conteúdo), Propriedades (inspetor), Assistente, Variáveis
3640
- const PANE_TABS = ["props", "inspect", "chat", "vars"];
3635
+ // Formatar, Propriedades e Variáveis ficam agrupados; o Assistente tem acesso fixo separado, por último.
3636
+ const PANE_TABS = ["props", "inspect", "vars", "chat"];
3641
3637
  const paneEls = (w) => ({ btn: document.getElementById(`tab-btn-${w}`), panel: document.getElementById(`tab-panel-${w}`) });
3642
3638
  const currentPane = () => PANE_TABS.find((w) => paneEls(w).panel?.classList.contains("active")) || "props";
3643
3639
  // menu de botão direito genérico (itens: { label, ic, fn, danger, disabled } ou { sep: true })
@@ -3665,7 +3661,7 @@ ${ta.value}`;
3665
3661
  const fab = document.getElementById("chat-fab");
3666
3662
  if (!fab) return;
3667
3663
  const visible = isMobile() ? dom.inspectorSidebar.classList.contains("mobile-open") : !dom.inspectorSidebar.classList.contains("collapsed");
3668
- fab.classList.toggle("hidden", visible && currentPane() === "chat");
3664
+ fab.classList.toggle("hidden", visible); // com o painel aberto, o botão Assistente já está sempre à vista
3669
3665
  const badge = document.getElementById("chat-badge");
3670
3666
  if (visible && currentPane() === "chat" && badge?.classList.contains("show")) badge.classList.remove("show"); // só se precisar: o observador do selo chama esta função
3671
3667
  fab.classList.toggle("has-news", !!document.getElementById("chat-badge")?.classList.contains("show"));
@@ -3711,7 +3707,7 @@ ${ta.value}`;
3711
3707
 
3712
3708
  // Chat lateral com IA (chat.js): mesma API, agora em módulo próprio.
3713
3709
  const chatApi = window.SagaChat({ state, dom, escHtml, hydrateIcons, showToast, openPane, currentPane,
3714
- streamAI, syncDeckToServer, renderThumbnails, renderCurrentSlide, trackDeck, fitRendered, refreshAIStatus });
3710
+ streamAI, syncDeckToServer, renderThumbnails, renderCurrentSlide, trackDeck, fitRendered, refreshAIStatus, previewMaterialized });
3715
3711
  const { handleChatSubmit, loadChatHistory, appendChatMessage, createProgressBubble, autoGrowChat, applyBrainstorm, addChatFile } = chatApi;
3716
3712
 
3717
3713
  function setNotesVisible(on) {
@@ -4314,29 +4310,24 @@ ${ta.value}`;
4314
4310
  return { res, name };
4315
4311
  }
4316
4312
 
4317
- // PowerPoint / PDF / roteiro: levam alguns segundos (o Chrome invisível desenha cada slide)
4313
+ // PowerPoint / PDF / roteiro: acompanham a fila do servidor, sem prender a página no download.
4318
4314
  const EXPORTS = {
4319
- pptx: { label: "o PowerPoint", done: "PowerPoint editável, com animações e notas." },
4320
- pdf: { label: "o PDF", done: "PDF com um slide por página." },
4321
- roteiro: { label: "o roteiro", done: "roteiro com miniaturas, notas e tempos." },
4322
- estudo: { label: "o material de estudo", done: "material de estudo: cada slide inteiro e o texto de consulta." },
4323
- "estudo-html": { label: "o material de estudo", done: "material de estudo num HTML só." },
4324
- tudo: { label: "o PowerPoint, o PDF e o roteiro", done: "um .zip com o PowerPoint (com notas), o PDF e o roteiro." },
4315
+ pptx: "o PowerPoint", pdf: "o PDF", roteiro: "o roteiro", estudo: "o material de estudo",
4316
+ "estudo-html": "o material de estudo", tudo: "o PowerPoint, o PDF e o roteiro",
4325
4317
  };
4326
4318
  document.querySelectorAll("[data-export]").forEach((btn) => {
4327
4319
  btn.onclick = async (e) => {
4328
4320
  e.preventDefault();
4329
4321
  if (btn.disabled) return;
4330
4322
  const kind = btn.dataset.export, clean = btn.dataset.notas === "0";
4331
- const x = clean ? { label: "o PowerPoint sem as notas", done: "PowerPoint sem as notas do apresentador, pronto para mandar." } : EXPORTS[kind];
4323
+ const label = clean ? "o PowerPoint sem as notas" : EXPORTS[kind];
4332
4324
  btn.disabled = true;
4333
- showToast(`Gerando ${x.label} (${state.deck.slides.length} slides)… pode levar alguns segundos.`, 60000);
4334
4325
  try {
4335
- const { res, name } = await downloadFrom(`api/export/${kind}${clean ? "?notas=0" : ""}`, `apresentacao.${{ pptx: "pptx", tudo: "zip", "estudo-html": "html" }[kind] || "pdf"}`);
4336
- const warns = JSON.parse(decodeURIComponent(res.headers.get("X-Sagadeck-Warnings") || "%5B%5D"));
4337
- showToast(warns.length ? `"${name}" baixado, com ${warns.length} aviso(s): ${warns.slice(0, 2).join("; ")}` : `"${name}" baixado: ${x.done}`, warns.length ? 9000 : 4000);
4326
+ await window.SagadeckExports.start({ kind, notes: !clean, title: state.deck.title }, { beforeStart: async () => {
4327
+ if (!state.preview) await syncDeckToServer({ throwOnError: true });
4328
+ } });
4338
4329
  } catch (err) {
4339
- showToast(`Não deu para gerar ${x.label}: ${err.message}`, 8000);
4330
+ showToast(`Não deu para gerar ${label}: ${err.message}`, 8000);
4340
4331
  } finally {
4341
4332
  btn.disabled = false;
4342
4333
  }
@@ -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%}}