sagadeck 1.4.0 → 1.6.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 (58) hide show
  1. package/README.md +59 -67
  2. package/SKILL.md +18 -11
  3. package/bin/sagadeck.js +12 -11
  4. package/docs/ANDAMENTO.md +355 -0
  5. package/docs/REFERENCIA.md +4 -4
  6. package/package.json +2 -3
  7. package/src/ai/bench.js +132 -0
  8. package/src/ai/context.js +4 -2
  9. package/src/ai/critique.js +114 -0
  10. package/src/ai/deck-ai.js +142 -32
  11. package/src/ai/document-visuals.js +7 -3
  12. package/src/ai/ia-config.js +101 -0
  13. package/src/ai/llm.js +62 -26
  14. package/src/ai/progress.js +23 -0
  15. package/src/ai/quality.js +30 -2
  16. package/src/api-client.js +2 -1
  17. package/src/build.js +3 -0
  18. package/src/decision-lab.js +2 -1
  19. package/src/deck-file.js +2 -1
  20. package/src/elements.js +10 -3
  21. package/src/figures/charts.js +7 -4
  22. package/src/figures/diagrams.js +15 -1
  23. package/src/figures/ufmap.js +2 -1
  24. package/src/fs-retry.js +19 -0
  25. package/src/layouts.js +1 -1
  26. package/src/library.js +75 -19
  27. package/src/locale.js +18 -0
  28. package/src/mcp/server.js +2 -2
  29. package/src/motion.js +5 -3
  30. package/src/preferences.js +5 -1
  31. package/src/research/research.js +6 -5
  32. package/src/runtime/calc.js +3 -3
  33. package/src/runtime/decision-lab.js +1 -1
  34. package/src/runtime/motion.js +5 -1
  35. package/src/runtime/runtime.js +2 -1
  36. package/src/studio/ai-chat-route.js +100 -4
  37. package/src/studio/banner.js +18 -0
  38. package/src/studio/code-version.js +35 -0
  39. package/src/studio/generate.js +53 -0
  40. package/src/studio/ia-routes.js +53 -0
  41. package/src/studio/project.js +8 -7
  42. package/src/studio/public/ai-settings.js +76 -0
  43. package/src/studio/public/app.js +11 -239
  44. package/src/studio/public/chat.js +71 -1
  45. package/src/studio/public/header-footer.js +140 -0
  46. package/src/studio/public/index.html +6 -0
  47. package/src/studio/public/library.html +3 -1
  48. package/src/studio/public/library.js +7 -5
  49. package/src/studio/public/preferences-ui.js +86 -0
  50. package/src/studio/public/provenance.js +13 -0
  51. package/src/studio/public/style.css +17 -0
  52. package/src/studio/public/ui-icons.js +1 -1
  53. package/src/studio/public/update-notice.js +25 -0
  54. package/src/studio/public-files.js +11 -1
  55. package/src/studio/server.js +26 -47
  56. package/src/studio/share-routes.js +2 -1
  57. package/src/studio/slide-copy.js +3 -1
  58. package/src/ai/relay.js +0 -45
package/src/ai/llm.js CHANGED
@@ -1,20 +1,23 @@
1
1
  import { mediaPrompt } from './art-direction.js';
2
2
  // sagadeck · Cliente de LLM (qualquer endpoint compatível com OpenAI: /v1/chat/completions)
3
3
  //
4
- // Por padrão fala com o `modelrelay serve` local (http://127.0.0.1:8765/v1), que decide pela
5
- // configuração dele para onde a chamada vai (OpenRouter, OpenAI, gateway corporativo...).
6
- // Também funciona apontando direto para um provedor.
4
+ // Fala direto com o provedor configurado nesta máquina (~/.sagadeck/ia.json, tela Configurar IA do Studio:
5
+ // src/ai/ia-config.js). As variáveis abaixo valem por cima da configuração:
7
6
  //
8
- // SAGADECK_LLM_URL base da API (padrão: http://127.0.0.1:8765/v1)
9
- // SAGADECK_LLM_KEY bearer token opcional (ex.: chave do OpenRouter se apontar direto)
10
- // SAGADECK_TEXT_MODEL modelo de texto (padrão: "text" — apelido no [models] do modelrelay)
11
- // SAGADECK_IMAGE_MODEL modelo de imagem (padrão: "image" — idem)
12
- // SAGADECK_VISION_MODEL modelo que vê imagens (padrão: "vision" — quando o de texto não enxerga, a chamada com
13
- // imagem vai para ele em vez de perder a imagem; quem configura o relay decide qual é)
7
+ // SAGADECK_LLM_URL base da API (ex.: https://openrouter.ai/api/v1)
8
+ // SAGADECK_LLM_KEY bearer token (ex.: a chave do OpenRouter)
9
+ // SAGADECK_TEXT_MODEL modelo de texto (padrão: o "text" da configuração)
10
+ // SAGADECK_IMAGE_MODEL modelo de imagem (padrão: o "image" da configuração)
11
+ // SAGADECK_VISION_MODEL modelo que vê imagens (padrão: o "vision" da configuração; quando o de texto não enxerga,
12
+ // a chamada com imagem vai para ele em vez de perder a imagem)
13
+ // SAGADECK_SEARCH_MODEL modelo que busca na web (padrão: o "search" da configuração)
14
14
  // SAGADECK_LLM_TIMEOUT segundos por chamada; em streaming, segundos sem chegar nada (padrão: 180)
15
15
  // SAGADECK_LLM_FIRST_TIMEOUT em streaming, segundos até a 1ª palavra (o modelo pensa antes) (padrão: 600)
16
16
 
17
17
  import { currentUsage, recordUsage } from './usage.js';
18
+ import path from 'node:path';
19
+ import { pathToFileURL } from 'node:url';
20
+ import { iaFile, loadIA, keyOf, migrateModelrelay } from './ia-config.js';
18
21
  import {setTimeout as delay} from 'node:timers/promises';
19
22
 
20
23
  export class LLMError extends Error {
@@ -28,35 +31,67 @@ export class LLMError extends Error {
28
31
  }
29
32
  }
30
33
 
34
+ // De onde vem a IA: variáveis de ambiente (SAGADECK_LLM_URL…, valem por cima; é como os testes apontam para o LLM
35
+ // falso) ou a configuração desta máquina (~/.sagadeck/ia.json, src/ai/ia-config.js). Os papéis text, vision, image e
36
+ // search viram os modelos configurados; sem configuração nenhuma, a IA fica desligada (o Studio usa as regras locais).
37
+ let migrated = false;
31
38
  export function llmConfig(env = process.env) {
39
+ const fromEnv = !!env.SAGADECK_LLM_URL;
40
+ if (!fromEnv && !migrated) { migrated = true; try { migrateModelrelay({ env, file: iaFile(env) }); } catch { /* segue sem */ } }
41
+ const ia = fromEnv ? null : loadIA(iaFile(env));
42
+ const m = ia?.models || {};
32
43
  return {
33
- url: (env.SAGADECK_LLM_URL || "http://127.0.0.1:8765/v1").replace(/\/+$/, ""),
34
- key: env.SAGADECK_LLM_KEY || "",
35
- textModel: env.SAGADECK_TEXT_MODEL || "text",
36
- imageModel: env.SAGADECK_IMAGE_MODEL || "image",
37
- visionModel: env.SAGADECK_VISION_MODEL || "vision",
44
+ url: (env.SAGADECK_LLM_URL || ia?.url || "").replace(/\/+$/, ""),
45
+ key: env.SAGADECK_LLM_KEY || (ia ? keyOf(ia, env) : ""),
46
+ textModel: env.SAGADECK_TEXT_MODEL || m.text || "text",
47
+ imageModel: env.SAGADECK_IMAGE_MODEL || m.image || "image",
48
+ visionModel: env.SAGADECK_VISION_MODEL || m.vision || m.text || "vision",
49
+ // busca na web pelo modelo (no OpenRouter, um modelo ":online"); sem ele, a pesquisa usa os buscadores diretos
50
+ searchModel: env.SAGADECK_SEARCH_MODEL || m.search || (fromEnv ? "search" : ""),
51
+ headers: ia?.headers || {},
52
+ adaptador: ia?.adaptador || "",
53
+ source: fromEnv ? "variável" : ia ? "arquivo" : "nenhuma",
38
54
  timeoutMs: Number(env.SAGADECK_LLM_TIMEOUT || 180) * 1000,
39
55
  firstTimeoutMs: Number(env.SAGADECK_LLM_FIRST_TIMEOUT || 600) * 1000,
40
- // o modelrelay escolhe os modelos deste app em [apps.sagadeck.models] (o resto vem de [models])
41
- app: env.SAGADECK_APP || "sagadeck",
42
56
  };
43
57
  }
58
+ export const llmConfigured = (cfg = llmConfig()) => !!(cfg.url || cfg.adaptador);
59
+
60
+ // o papel (text, vision, image, search) vira o modelo configurado; outro nome passa como veio
61
+ export function resolveModel(cfg, name) {
62
+ const roles = { text: cfg.textModel, vision: cfg.visionModel, image: cfg.imageModel, search: cfg.searchModel };
63
+ return roles[name] || name;
64
+ }
44
65
 
45
66
  function headers(cfg) {
46
- const h = { "Content-Type": "application/json", "X-Modelrelay-App": cfg.app || "sagadeck" };
67
+ const h = { "Content-Type": "application/json", ...(cfg.headers || {}) };
47
68
  if (cfg.key) h.Authorization = `Bearer ${cfg.key}`;
48
69
  return h;
49
70
  }
50
71
 
72
+ // Adaptador (opcional, da pessoa): um .mjs cujo default é (url, init) => Response. Serve para o que não é compatível
73
+ // com a API da OpenAI (outro jeito de autenticar, outro endereço) sem mexer no sagadeck. Sem ele, o fetch normal.
74
+ const adapters = new Map();
75
+ async function send(cfg, url, init) {
76
+ if (!cfg.adaptador) return fetch(url, init);
77
+ if (!adapters.has(cfg.adaptador)) adapters.set(cfg.adaptador, import(pathToFileURL(path.resolve(cfg.adaptador)).href).then((mod) => mod.default));
78
+ const fn = await adapters.get(cfg.adaptador);
79
+ if (typeof fn !== "function") throw new LLMError(`O adaptador ${cfg.adaptador} não exporta uma função padrão (url, init) => Response.`);
80
+ return fn(url, init);
81
+ }
82
+ const notConfigured = () => new LLMError("A IA não está configurada: no Studio, abra Configurar IA (ou crie ~/.sagadeck/ia.json).", { code: "AI_NOT_CONFIGURED" });
83
+ const howToFix = "Confira o provedor e a chave em Configurar IA, no Studio.";
84
+
51
85
  // Disponibilidade com cache curto, para o Studio decidir entre LLM e as regras determinísticas.
52
86
  let availability = { at: 0, ok: false, url: "" };
53
87
 
54
88
  export async function llmAvailable({ force = false } = {}) {
55
89
  const cfg = llmConfig();
90
+ if (!llmConfigured(cfg)) return false;
56
91
  if (!force && availability.url === cfg.url && Date.now() - availability.at < 30_000) return availability.ok;
57
92
  let ok = false;
58
93
  try {
59
- const res = await fetch(`${cfg.url}/models`, { headers: headers(cfg), signal: AbortSignal.timeout(2500) });
94
+ const res = await send(cfg, `${cfg.url}/models`, { headers: headers(cfg), signal: AbortSignal.timeout(2500) });
60
95
  ok = res.ok;
61
96
  } catch {
62
97
  ok = false;
@@ -106,13 +141,13 @@ export async function chat(messages, opts = {}) {
106
141
 
107
142
  async function chatUnmetered(messages, opts = {}) {
108
143
  const cfg = opts.cfg || llmConfig();
109
- const key = `${cfg.url}|${cfg.app}|${opts.model || cfg.textModel}`;
144
+ const key = `${cfg.url}|${opts.model || cfg.textModel}`;
110
145
  // o modelo de texto não enxerga: a chamada com imagem vai para o modelo de visão (se o relay tiver um)
111
146
  const viaVision = async () => {
112
147
  const vision = cfg.visionModel;
113
- if (!vision || vision === (opts.model || cfg.textModel) || noVision.has(`${cfg.url}|${cfg.app}|${vision}`)) return null;
148
+ if (!vision || vision === (opts.model || cfg.textModel) || noVision.has(`${cfg.url}|${vision}`)) return null;
114
149
  try { return { ...(await chatOnce(messages, { ...opts, model: vision, cfg })), visionRouted: vision }; }
115
- catch (e) { if (refusesImages(e) || e.status === 400 || e.status === 404) { noVision.add(`${cfg.url}|${cfg.app}|${vision}`); return null; } throw e; }
150
+ catch (e) { if (refusesImages(e) || e.status === 400 || e.status === 404) { noVision.add(`${cfg.url}|${vision}`); return null; } throw e; }
116
151
  };
117
152
  if (hasImageParts(messages) && noVision.has(key)) {
118
153
  return (await viaVision()) || { ...(await chatOnce(withoutImages(messages), { ...opts, cfg })), imagesDropped: true };
@@ -147,7 +182,8 @@ async function chatOnce(messages, opts = {}) {
147
182
  }
148
183
 
149
184
  async function chatOnceRaw(messages, { model, temperature, maxTokens, onDelta, signal, reasoningOff, cfg = llmConfig() } = {}) {
150
- const body = { model: model || cfg.textModel, messages };
185
+ if (!llmConfigured(cfg)) throw notConfigured();
186
+ const body = { model: resolveModel(cfg, model || cfg.textModel), messages };
151
187
  if (reasoningOff) body.reasoning = { enabled: false };
152
188
  if (temperature !== undefined) body.temperature = temperature;
153
189
  if (maxTokens) body.max_tokens = maxTokens;
@@ -155,7 +191,7 @@ async function chatOnceRaw(messages, { model, temperature, maxTokens, onDelta, s
155
191
 
156
192
  let res;
157
193
  try {
158
- res = await fetch(`${cfg.url}/chat/completions`, {
194
+ res = await send(cfg, `${cfg.url}/chat/completions`, {
159
195
  method: "POST",
160
196
  headers: headers(cfg),
161
197
  body: JSON.stringify(body),
@@ -168,7 +204,7 @@ async function chatOnceRaw(messages, { model, temperature, maxTokens, onDelta, s
168
204
  if (e.cause?.code === "UND_ERR_HEADERS_TIMEOUT") return chatStream(body, () => {}, cfg, signal);
169
205
  const why = e.name === "TimeoutError" ? `sem resposta em ${cfg.timeoutMs / 1000}s` : e.message;
170
206
  throw new LLMError(`Não consegui falar com o LLM em ${cfg.url} (${why}). ` +
171
- "Rode `modelrelay serve` ou ajuste SAGADECK_LLM_URL.", { cause: e });
207
+ howToFix, { cause: e });
172
208
  }
173
209
 
174
210
  const raw = await res.text();
@@ -213,7 +249,7 @@ async function chatStream(body, onDelta, cfg, signal) {
213
249
  async function readStream(body, onDelta, cfg, signal, sig, alive, wrote = () => {}, noTextFor = () => 0) {
214
250
  let res;
215
251
  try {
216
- res = await fetch(`${cfg.url}/chat/completions`, {
252
+ res = await send(cfg, `${cfg.url}/chat/completions`, {
217
253
  method: "POST",
218
254
  headers: headers(cfg),
219
255
  body: JSON.stringify({ ...body, stream: true, stream_options: { include_usage: true } }),
@@ -223,7 +259,7 @@ async function readStream(body, onDelta, cfg, signal, sig, alive, wrote = () =>
223
259
  if (signal?.aborted) throw new LLMError("Parado a pedido.", { cause: e, aborted: true });
224
260
  const why = e.name === "TimeoutError" ? `não começou a responder em ${(cfg.firstTimeoutMs ?? cfg.timeoutMs) / 1000} s` : e.message;
225
261
  throw new LLMError(`Não consegui falar com o LLM em ${cfg.url} (${why}). ` +
226
- "Rode `modelrelay serve` ou ajuste SAGADECK_LLM_URL.", { cause: e });
262
+ howToFix, { cause: e });
227
263
  }
228
264
  alive(); // começou: daqui em diante, o limite é ficar sem chegar nada
229
265
  if (!res.ok) {
@@ -0,0 +1,23 @@
1
+ // Quadro de etapas da geração: cada aviso de progresso diz em que etapa está ("Leitura crítica: …") e, quando duas
2
+ // etapas correm juntas (o inventário do documento enquanto a crítica lê o texto), mostra as duas. Antes a tela
3
+ // ficava minutos em "Pensando…" sem dizer pensando em quê.
4
+ export function stageBoard(onEvent = () => {}) {
5
+ const active = new Map(), done = [];
6
+ const send = (extra = {}) => onEvent({ ...extra, stages: [...active.keys()], done: [...done],
7
+ text: [...active].map(([label, text]) => (text ? `${label}: ${text}` : label)).join(' · ') });
8
+ return {
9
+ // roda fn(emit) como a etapa `label`; emit aceita texto ou o evento { phase, text, chars, preview }
10
+ async run(label, fn) {
11
+ active.set(label, '');
12
+ send({ phase: 'stage' });
13
+ const emit = (ev) => {
14
+ if (!active.has(label)) return;
15
+ const obj = ev && typeof ev === 'object' ? ev : { phase: 'step', text: ev };
16
+ active.set(label, String(obj.text || ''));
17
+ send({ ...obj, phase: obj.phase || 'step' });
18
+ };
19
+ try { return await fn(emit); }
20
+ finally { active.delete(label); done.push(label); if (active.size) send({ phase: 'stage' }); }
21
+ },
22
+ };
23
+ }
package/src/ai/quality.js CHANGED
@@ -11,9 +11,36 @@ const oneLinerText = s => s.text || s.quote || (Array.isArray(s.lines) ? s.lines
11
11
 
12
12
  // Conferência de texto sem visão (determinística, roda sempre): frase longa em layout de impacto vira tela de uma
13
13
  // frase gigante; uma apresentação feita de frases soltas não explica nada. Os achados voltam para a correção da IA.
14
- export function auditText(spec, indices) {
14
+ // Interação de plateia (enquete, votação, quiz, cronômetro): em apresentação acadêmica não entra sem pedido.
15
+ export const votingSlide = (s) => s.layout === 'poll' || (s.layout === 'question' && Array.isArray(s.options) && s.options.length) || s.poll != null || s.timer != null;
16
+ const ASKED_INTERACTION = /vota|votação|enquete|quiz|interativ|dinâmica|gamific|perguntas? (?:para|à) plateia/i;
17
+ // marcação que sobrou crua no texto visível (campo que não passa pela marcação): "^^≠^^", "==x==", "**x**"
18
+ const RAW_MARKUP = /\^\^[^^\n]{1,40}\^\^|==[^=\n]{1,60}==|\*\*[^*\n]{1,60}\*\*/;
19
+ const CODE_LAYOUTS = new Set(['code', 'codewalk', 'codelab', 'api', 'algo', 'terminals', 'duel', 'turns', 'playground']);
20
+
21
+ export function auditText(spec, indices, { briefing = '', render = null } = {}) {
15
22
  const issues = [];
16
23
  const slides = spec.slides || [];
24
+ // deck feito de um artigo (autoria decidida) é acadêmico: votação só se o pedido quis interação
25
+ const academic = !!spec.context?.autoria || /congresso|simp[óo]sio|confer[êe]ncia|defesa|banca|journal club|semin[áa]rio/i.test(`${briefing} ${JSON.stringify(spec.context || {})}`);
26
+ if (academic && !ASKED_INTERACTION.test(briefing)) for (const index of indices) {
27
+ const s = slides[index];
28
+ if (s && votingSlide(s)) issues.push({ slide: index + 1, text: 'Votação/enquete numa apresentação acadêmica que não pediu interação. Tire o mecanismo de voto (options, timer, poll): a pergunta para discussão vira slide de texto (question sem options, statement ou split) ou vai nas notes.' });
29
+ }
30
+ // slide repetido (mesmo layout e mesmo título de outro): duas capas, o mesmo resultado em dois slides seguidos
31
+ const sig = (s) => `${s?.layout}|${String(s?.title || s?.text || s?.question || '').replace(/[=*^_~`]/g, '').trim().toLowerCase()}`;
32
+ for (const index of indices) {
33
+ const s = slides[index]; if (!s || !(s.title || s.text || s.question)) continue;
34
+ const first = slides.findIndex((o) => sig(o) === sig(s));
35
+ if (first >= 0 && first < index) issues.push({ slide: index + 1, text: `Slide repetido: mesmo layout e mesmo título do slide ${first + 1}. Tire a cópia ou faça este slide dizer outra coisa.` });
36
+ }
37
+ if (render) for (const index of indices) {
38
+ const s = slides[index]; if (!s || CODE_LAYOUTS.has(s.layout)) continue;
39
+ let visible = '';
40
+ try { visible = render(s, index).replace(/<(code|pre|script|style)[\s\S]*?<\/\1>/gi, ' ').replace(/<[^>]+>/g, ' '); } catch { continue; }
41
+ const m = RAW_MARKUP.exec(visible);
42
+ if (m) issues.push({ slide: index + 1, text: `Marcação aparecendo crua na tela ("${m[0]}"): esse campo não aceita marcação. Escreva o texto sem ^^, == ou ** nesse campo.` });
43
+ }
17
44
  for (const index of indices) {
18
45
  const s = slides[index]; if (!s || !ONE_LINER.has(s.layout)) continue;
19
46
  const n = words(oneLinerText(s));
@@ -38,7 +65,8 @@ export function auditText(spec, indices) {
38
65
  export async function reviewExperience(spec, indices, { snapshot, complete = chat, onProgress, signal, briefing = '' } = {}) {
39
66
  const issues = [], unchecked = [], failures = [];
40
67
  const selected = [...new Set(indices)].filter(index => spec.slides[index]);
41
- issues.push(...auditText(spec, selected));
68
+ const { renderSlide } = await import('../build.js');
69
+ issues.push(...auditText(spec, selected, { briefing, render: (s, i) => renderSlide(s, i, spec).html }));
42
70
  for (const [position, index] of selected.entries()) {
43
71
  signal?.throwIfAborted();
44
72
  const slide = spec.slides[index];
package/src/api-client.js CHANGED
@@ -14,6 +14,7 @@
14
14
  // ttl_minutes: 30
15
15
  // ca: "C:/certs/empresa.pem" # certificado da empresa (proxy/inspeção TLS)
16
16
  import fs from "node:fs";
17
+ import { renameRetry } from "./fs-retry.js";
17
18
  import os from "node:os";
18
19
  import path from "node:path";
19
20
  import http from "node:http";
@@ -187,7 +188,7 @@ export class ApiEnvironments {
187
188
  fs.mkdirSync(path.dirname(this.file), { recursive: true });
188
189
  const tmp = `${this.file}.tmp-${process.pid}`;
189
190
  fs.writeFileSync(tmp, String(doc), { encoding: "utf8", mode: 0o600 });
190
- fs.renameSync(tmp, this.file);
191
+ renameRetry(tmp, this.file);
191
192
  }
192
193
 
193
194
  // Ambiente de exemplo (embutido) editado pela pessoa: vira um ambiente dela, cópia completa no arquivo (o do arquivo
package/src/build.js CHANGED
@@ -11,6 +11,7 @@ import YAML from "yaml";
11
11
  import { resolveTheme, themeCSS, scopedThemeCSS } from "./themes.js";
12
12
  import { identityFor, identitiesFile } from "./identity.js";
13
13
  import { LAYOUTS, SCENES, fullAsFigure } from "./layouts.js";
14
+ import { setDeckLocale } from "./locale.js";
14
15
  import { applyVisualEdits } from "./visual-edits.js";
15
16
  import { el, imageSrc } from "./elements.js";
16
17
  import { iconSVG } from "./figures/icons.js";
@@ -127,6 +128,7 @@ export function navWarnings(slides = []) {
127
128
 
128
129
  export function buildHTML(rawSpec, opts = {}) {
129
130
  const spec = normalizeSpec(meetingSpec(rawSpec,opts.audience));
131
+ setDeckLocale(spec.lang); // números no idioma do deck (src/locale.js)
130
132
  const theme = resolveTheme(spec.theme, spec.palette, identityOf(spec));
131
133
  const warnings = [];
132
134
  if (spec.identity && !theme.identity) warnings.push(`identidade "${spec.identity}" não está em ${identitiesFile()} nesta máquina: a apresentação saiu com as fontes do tema`);
@@ -307,6 +309,7 @@ function slideShell({ s, i, spec, theme, ctx, layout, tone, inner: innerIn, curr
307
309
  }
308
310
 
309
311
  export function renderSlide(raw, i = 0, spec = {}) {
312
+ setDeckLocale(spec.lang);
310
313
  const deckTheme = resolveTheme(spec.theme, spec.palette, identityOf(spec));
311
314
  const s = { ...(spec.defaults || {}), ...raw };
312
315
  const theme = slideTheme(s, deckTheme, spec);
@@ -1,9 +1,10 @@
1
1
  import './runtime/decision-lab.js';
2
2
  import { esc } from './markup.js';
3
+ import { formatNumber } from './locale.js';
3
4
  export const calculateDecisionLab = globalThis.SagaDecisionLab.calculate;
4
5
  export function decisionLabHTML(input = {}) {
5
6
  const v = globalThis.SagaDecisionLab.normalize(input), r = calculateDecisionLab(v);
6
- const fmt = n => n.toLocaleString('pt-BR', { maximumFractionDigits: 1 });
7
+ const fmt = n => formatNumber(n, { maximumFractionDigits: 1 });
7
8
  const controls = [
8
9
  ['errorRate', 'Erros da automação (%)', 100, .1],
9
10
  ['reviewRate', 'Casos revisados (%)', 100, 1],
package/src/deck-file.js CHANGED
@@ -8,6 +8,7 @@
8
8
  // 3. o texto final é relido e comparado com o deck: se não bater, grava o deck inteiro (correção primeiro);
9
9
  // 4. a troca é atômica: escreve num temporário ao lado e renomeia (nunca fica um arquivo pela metade).
10
10
  import fs from "node:fs";
11
+ import { renameRetry } from "./fs-retry.js";
11
12
  import path from "node:path";
12
13
  import { isDeepStrictEqual } from "node:util";
13
14
  import YAML from "yaml";
@@ -114,6 +115,6 @@ export function writeDeckFile(file, spec) {
114
115
  if (text === current) return false;
115
116
  const tmp = path.join(path.dirname(file), `.${path.basename(file)}.${process.pid}.${Date.now()}.tmp`);
116
117
  fs.writeFileSync(tmp, text, "utf8");
117
- try { fs.renameSync(tmp, file); } catch (e) { fs.rmSync(tmp, { force: true }); throw e; }
118
+ try { renameRetry(tmp, file); } catch (e) { fs.rmSync(tmp, { force: true }); throw e; }
118
119
  return true;
119
120
  }
package/src/elements.js CHANGED
@@ -6,6 +6,7 @@ import { videoPlayer } from './video-player.js';
6
6
  import { tableHTML } from "./table.js";
7
7
  import { qrSVG } from "./figures/qr.js";
8
8
  import katex from "katex";
9
+ import { formatNumber, decimalSeparator } from "./locale.js";
9
10
  import fs from "node:fs";
10
11
  import path from "node:path";
11
12
  import { md, esc } from "./markup.js";
@@ -267,7 +268,7 @@ export function el(e, ctx, w, h) {
267
268
  if (e.badge) return `<div${attrs(e, "badge f-label")}>${md(e.badge)}</div>`;
268
269
  if (e.aviso) return aviso(e);
269
270
  if (e.video) return video(e, ctx);
270
- if (e.motion) return `<div${attrs(e, "motion")}>${motionHTML(e.motion,ctx?.theme?.colors)}</div>`;
271
+ if (e.motion) return `<div${attrs(e, "motion")}>${motionHTML(e.motion,ctx?.theme?.colors,{still:ctx?.spec?.motion==='none'})}</div>`;
271
272
  if (e.widget) return `<div${attrs(e, "widget")} data-widget="${esc(e.widget)}" data-opts="${esc(JSON.stringify(e))}"></div>`;
272
273
  if (e.html) return `<div${attrs(e, "raw")}>${e.html}</div>`;
273
274
  if (e.spacer != null) return `<div class="spacer" style="flex:${e.spacer === true ? 1 : 0} 0 ${px(e.spacer === true ? 0 : e.spacer)}"></div>`;
@@ -394,7 +395,13 @@ export function parseCounterValue(raw) {
394
395
  const m = /^(\D*?)(-?\d[\d.,]*)(.*)$/s.exec(String(raw ?? "").trim());
395
396
  if (!m) return null;
396
397
  let num = m[2].replace(/[.,]$/, ""), dec = 0;
397
- if (num.includes(",")) { dec = num.split(",").pop().length; num = num.replace(/\./g, "").replace(",", "."); }
398
+ if (decimalSeparator() === ".") {
399
+ // deck em inglês: vírgula é milhar ("1,403"), ponto é decimal ("37.67")
400
+ num = num.replace(/,(?=\d{3}(?:\D|$))/g, "");
401
+ if (num.includes(".")) dec = num.split(".").pop().length;
402
+ else if (num.includes(",")) { dec = num.split(",").pop().length; num = num.replace(",", "."); }
403
+ }
404
+ else if (num.includes(",")) { dec = num.split(",").pop().length; num = num.replace(/\./g, "").replace(",", "."); }
398
405
  else if (/^-?\d{1,3}(\.\d{3})+$/.test(num)) num = num.replace(/\./g, "");
399
406
  else if (num.includes(".")) dec = num.split(".").pop().length;
400
407
  const value = Number(num);
@@ -407,7 +414,7 @@ export function counter(e) {
407
414
  e = { ...e, prefix: unicodeScripts(`${e.prefix ?? ""}${parsed.prefix ?? ""}`), suffix: unicodeScripts(`${parsed.suffix ?? ""}${e.suffix ?? ""}`), decimals: e.decimals ?? (typeof e.counter === "number" ? undefined : parsed.dec) };
408
415
  const v = parsed.value;
409
416
  const dec = e.decimals ?? (v % 1 ? 1 : 0);
410
- const shown = v.toLocaleString("pt-BR", { minimumFractionDigits: dec, maximumFractionDigits: dec });
417
+ const shown = formatNumber(v, { minimumFractionDigits: dec, maximumFractionDigits: dec }); // no idioma do deck
411
418
  const size = e.size || SIZES[e.as || "number"];
412
419
  return `<div${attrs(e, `t f-display r-number counter pl`, `font-size:${size}px;`)}${e.fit ? " data-fit" : ""} data-to="${v}" data-from="${e.from ?? 0}" data-dec="${dec}" data-prefix="${esc(e.prefix || "")}" data-suffix="${esc(e.suffix || "")}"><span class="cv">${esc(e.prefix || "")}${shown}${esc(e.suffix || "")}</span></div>`;
413
420
  }
@@ -12,6 +12,7 @@
12
12
  // Cores: por padrão usam o tom do slide (--cb base, --ca destaque, --s1..--s5 séries).
13
13
  // Qualquer item aceita color: hi | em | fg | muted | "FF0000".
14
14
  import { esc } from "../markup.js";
15
+ import { formatNumber } from "../locale.js";
15
16
  import { iconSVG } from "./icons.js";
16
17
  import { humanBody } from "./pictos.js";
17
18
 
@@ -24,8 +25,10 @@ const cvar = (c, d) => {
24
25
  };
25
26
  const fmt = (v, o) => {
26
27
  if (v == null || isNaN(v)) return "";
27
- const d = o.decimals ?? (Math.abs(v) < 10 && v % 1 ? 1 : 0);
28
- const s = Number(v).toLocaleString("pt-BR", { minimumFractionDigits: d, maximumFractionDigits: d });
28
+ // sem decimals pedido: as casas do próprio valor, até 2 (0.93 aparecia "0.9" com a regra antiga de 1 casa
29
+ // abaixo de 10, e 37.67 aparecia "38": num gráfico de paper, a precisão relatada é dado)
30
+ const d = o.decimals ?? Math.min(2, (String(Number(v)).split(".")[1] || "").length);
31
+ const s = formatNumber(v, { minimumFractionDigits: d, maximumFractionDigits: d }); // no idioma do deck (lang)
29
32
  return `${o.prefix || ""}${s}${o.suffix || ""}`;
30
33
  };
31
34
  const norm = (o) => {
@@ -198,8 +201,8 @@ export function niceTicks(min, max, want = 5) {
198
201
  }
199
202
  function line(o, W, H) {
200
203
  const series = o.series || [{ values: o.values }];
201
- // rótulo que veio como número (0.5): com vírgula, como o resto do gráfico
202
- const labels = (o.labels || series[0].values.map((_, i) => String(i + 1))).map((l) => (typeof l === "number" ? l.toLocaleString("pt-BR", { useGrouping: false }) : l));
204
+ // rótulo que veio como número (0.5): no formato do resto do gráfico (vírgula em pt-BR, ponto em inglês)
205
+ const labels = (o.labels || series[0].values.map((_, i) => String(i + 1))).map((l) => (typeof l === "number" ? formatNumber(l, { useGrouping: false }) : l));
203
206
  const all = series.flatMap((s) => s.values).filter((v) => v != null);
204
207
  const min = o.min ?? Math.min(0, ...all), max = o.max ?? Math.max(...all) * 1.1;
205
208
  // nomes no fim das linhas, a não ser que se encostem (as linhas terminam juntas) ou o slide peça legenda: aí, legenda em cima
@@ -82,6 +82,20 @@ export function spectrum(o = {}) {
82
82
  return wrap([0, 0, W, 190], g, "spectrum");
83
83
  }
84
84
 
85
+ // Tamanho da letra que cabe na caixa (largura × altura): simula a quebra por palavras e desce de 36 até 18 px.
86
+ // Com 36 px fixos, "Low-lying areas near the drainage network" pedia 4 linhas e vazava por baixo da caixa.
87
+ export function boxFontSize(text, w, h, { max = 36, min = 18, lh = 1.15, em = 0.56 } = {}) {
88
+ const words = String(text || "").split(/\s+/).filter(Boolean);
89
+ for (let fs = max; fs > min; fs -= 2) {
90
+ const cpl = Math.max(1, Math.floor(w / (fs * em)));
91
+ if (words.some((wd) => wd.length > cpl)) continue;
92
+ let lines = 1, len = 0;
93
+ for (const wd of words) { if (len && len + 1 + wd.length > cpl) { lines++; len = wd.length; } else len += (len ? 1 : 0) + wd.length; }
94
+ if (lines * fs * lh <= h) return fs;
95
+ }
96
+ return min;
97
+ }
98
+
85
99
  export function flow(o = {}) {
86
100
  const steps = o.steps || [];
87
101
  const n = steps.length, bw = 300, gap = 90, bh = 150, vgap = 130;
@@ -90,7 +104,7 @@ export function flow(o = {}) {
90
104
  const id = `ar${++uid}`;
91
105
  const box = (x, y, s, hl) =>
92
106
  `<rect x="${x}" y="${y}" width="${bw}" height="${bh}" rx="18" style="fill:var(${hl ? "--hi" : "--surface"});stroke:var(--fg)" stroke-width="${hl ? 0 : 4}"/>` +
93
- `<foreignObject x="${x + 16}" y="${y + 10}" width="${bw - 32}" height="${bh - 20}"><div xmlns="http://www.w3.org/1999/xhtml" class="f-heading" style="height:100%;display:flex;align-items:center;justify-content:center;text-align:center;font-size:36px;color:var(${hl ? "--on-hi" : "--fg"})">${esc(s)}</div></foreignObject>`;
107
+ `<foreignObject x="${x + 16}" y="${y + 10}" width="${bw - 32}" height="${bh - 20}"><div xmlns="http://www.w3.org/1999/xhtml" class="f-heading" style="height:100%;display:flex;align-items:center;justify-content:center;text-align:center;line-height:1.15;font-size:${boxFontSize(s, bw - 32, bh - 20)}px;color:var(${hl ? "--on-hi" : "--fg"})">${esc(s)}</div></foreignObject>`;
94
108
  const arrow = (x1, y1, x2, y2) =>
95
109
  `<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" style="stroke:var(--fg)" stroke-width="8" marker-end="url(#${id})"/>`;
96
110
  let g = arrowDefs(id);
@@ -1,6 +1,7 @@
1
1
  // Mapa do Brasil por UF em grade (cartograma de ladrilhos): cada estado é um quadrado na posição aproximada dele.
2
2
  // Sem geodados de fora (nada pede coisa à internet) e legível em slide: a cor diz o valor, o rótulo diz a UF.
3
3
  import { esc } from "../markup.js";
4
+ import { formatNumber } from "../locale.js";
4
5
 
5
6
  // [coluna, linha] de cada UF, do norte ao sul, do oeste ao leste
6
7
  export const UF_GRID = {
@@ -14,7 +15,7 @@ export const UF_GRID = {
14
15
  RS: [1, 7],
15
16
  };
16
17
 
17
- const fmt = (v) => (Number.isFinite(v) ? v.toLocaleString("pt-BR", { maximumFractionDigits: 1 }) : "");
18
+ const fmt = (v) => (Number.isFinite(v) ? formatNumber(v, { maximumFractionDigits: 1 }) : "");
18
19
 
19
20
  // o: { ufmap: { SP: 120, RJ: 80, … }, suffix, prefix, highlight: [SP], legend: "texto", showValues }
20
21
  export function ufmap(o, warnings) {
@@ -0,0 +1,19 @@
1
+ // Renomear tolerante a arquivo travado. A biblioteca mora em Documentos, que no Windows costuma estar no OneDrive:
2
+ // enquanto sincroniza (e também o antivírus ou o indexador), o arquivo fica preso por um instante e o rename falha
3
+ // com EPERM/EBUSY/EACCES. Em vez de perder o salvamento da pessoa, tenta de novo com espera curta e crescente
4
+ // (~1 s no total). Qualquer outro erro (destino inexistente, disco cheio…) sobe na hora.
5
+ import fs from "node:fs";
6
+
7
+ const LOCKED = new Set(["EPERM", "EBUSY", "EACCES"]);
8
+ export const RENAME_DELAYS = [20, 40, 80, 150, 250, 450];
9
+
10
+ const pause = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
11
+
12
+ export function renameRetry(from, to, { delays = RENAME_DELAYS } = {}) {
13
+ for (let i = 0; ; i++) {
14
+ try { return fs.renameSync(from, to); } catch (e) {
15
+ if (!LOCKED.has(e?.code) || i >= delays.length) throw e;
16
+ pause(delays[i]);
17
+ }
18
+ }
19
+ }
package/src/layouts.js CHANGED
@@ -415,7 +415,7 @@ export const LAYOUTS = {
415
415
  ${c.title ? text(c.title, "h3", { class: "cp-title" }) : ""}
416
416
  ${c.text ? text(c.text, "body", { class: "cp-text" }) : ""}
417
417
  ${c.items ? list({ list: c.items, size: 32 }) : ""}</div>`;
418
- return `<div class="L-compare">${head(s)}<div class="cp-row">${col(s.left, "l", 0)}<div${attrs({ step: s.right?.step ?? build(s, 1) }, "cp-vs t f-display")}>${esc(s.vs ?? "×")}</div>${col(s.right, "r", 1)}</div>
418
+ return `<div class="L-compare">${head(s)}<div class="cp-row">${col(s.left, "l", 0)}<div${attrs({ step: s.right?.step ?? build(s, 1) }, "cp-vs t f-display")}>${md(String(s.vs ?? "×"))}</div>${col(s.right, "r", 1)}</div>
419
419
  ${s.after ? text(s.after, "h3", { class: "cp-after", step: s.afterStep ?? (s.build ? 3 : 1), face: "quote", size: 50 }) : ""}</div>${src(s)}${add(s, ctx)}`;
420
420
  },
421
421