g360-cli 1.16.0 → 1.17.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.
@@ -1,210 +1,343 @@
1
1
  /**
2
- * Layouts reutilizables para slides G360.
3
- * Cada layout recibe (slide, theme) y retorna el slide modificado.
2
+ * Layouts reutilizables para slides G360 (formato 16:9 widescreen).
3
+ * Componentes portados y alineados al manual de ventas-pulse:
4
+ * portada con banda, encabezado de seccion con regla, tarjetas suaves,
5
+ * marco de captura tipo telefono con encaje proporcional, flujo numerado,
6
+ * limites con barra ambar, checklist con checks y KPIs.
7
+ *
8
+ * Cada layout recibe (slide, data, theme) y retorna el slide modificado.
4
9
  */
10
+ import fs from 'fs';
11
+ import { getImageSize } from '../scripts/image-size.js';
12
+
13
+ function baseTextOpts(theme) {
14
+ return { fontFace: theme.typo?.font || 'Inter' };
15
+ }
5
16
 
6
17
  /**
7
- * Cover slide — portada de la app
18
+ * Bloque de texto multi-parrafo.
19
+ * paragraphs: [{ text, size, bold, color, align, spaceAfter }]
8
20
  */
9
- export function coverSlide(slide, data, theme) {
10
- const { pptx, colors } = theme;
11
- slide.addText(data.appName || 'Mi App G360', [0.5, 1.5, 9, 1.2], {
12
- fontSize: 44, bold: true, color: colors.text, fontFace: 'Inter',
13
- align: 'center', margin: 0,
21
+ export function textBlock(slide, { x, y, w, h = 0.5, paragraphs }, theme) {
22
+ const opts = baseTextOpts(theme);
23
+ const runs = paragraphs.map((p, i) => ({
24
+ text: p.text,
25
+ options: {
26
+ fontSize: p.size ?? theme.typo.sizes.body,
27
+ bold: p.bold ?? false,
28
+ color: p.color ?? theme.colors.text,
29
+ align: p.align ?? 'left',
30
+ paraSpaceAfter: p.spaceAfter ?? 6,
31
+ breakLine: true,
32
+ ...opts,
33
+ },
34
+ }));
35
+ slide.addText(runs, { x, y, w, h, ...opts });
36
+ return slide;
37
+ }
38
+
39
+ /**
40
+ * Encabezado de seccion + footer con paginado (estilo ventas-pulse).
41
+ */
42
+ export function addSectionHeader(slide, title, theme, pageNum = null, total = null) {
43
+ const { colors, typo, spacing } = theme;
44
+ slide.addText(title, {
45
+ x: spacing.marginX, y: spacing.sectionTitleY,
46
+ w: spacing.contentWidth, h: 0.5,
47
+ fontSize: typo.sizes.sectionTitle, bold: true,
48
+ color: colors.accentDark, ...baseTextOpts(theme),
14
49
  });
15
- slide.addText(data.description || 'Aplicacion de gestion y monitoreo', [0.5, 3.0, 9, 0.8], {
16
- fontSize: 20, color: colors.textLight, fontFace: 'Inter',
17
- align: 'center', margin: 0,
50
+ slide.addShape('rect', {
51
+ x: spacing.marginX, y: spacing.sectionRuleY,
52
+ w: spacing.contentWidth, h: 0.035,
53
+ fill: { color: colors.accent }, line: { type: 'none' },
18
54
  });
19
- if (data.version) {
20
- slide.addText(`v${data.version}`, [0.5, 4.0, 9, 0.5], {
21
- fontSize: 14, color: colors.accent, fontFace: 'Inter',
22
- align: 'center', margin: 0,
23
- });
24
- }
25
- if (data.brand && data.brand === 'cipsa') {
26
- slide.addText('CIPSA', [0.5, 10.5, 9, 0.5], {
27
- fontSize: 12, color: colors.textLight, fontFace: 'Inter',
28
- align: 'center', margin: 0,
29
- });
30
- }
31
- // Linea decorativa inferior
32
- slide.addShape(pptx.ShapeType.rect, [0, 13.5, 10, 0.05], {
33
- fill: { color: colors.accent }, line: { color: colors.accent },
55
+ if (pageNum !== null) addFooter(slide, theme, pageNum, total);
56
+ return slide;
57
+ }
58
+
59
+ /**
60
+ * Footer "powered by G360 · n/total".
61
+ */
62
+ export function addFooter(slide, theme, slideNum, totalSlides) {
63
+ const { colors, typo, spacing } = theme;
64
+ slide.addText(`powered by G360 · ${slideNum}/${totalSlides}`, {
65
+ x: spacing.page.width - 3.2, y: spacing.footerY,
66
+ w: 3.0, h: 0.28,
67
+ fontSize: typo.sizes.footer, color: colors.textMuted,
68
+ align: 'right', ...baseTextOpts(theme),
34
69
  });
35
70
  return slide;
36
71
  }
37
72
 
38
73
  /**
39
- * Screenshot slide con placeholder flexible para imagen
40
- * data: { title, description, screenshotPath?, screenshotIndex? }
74
+ * Tarjeta suave con titulo y cuerpo (componente tarjeta de ventas-pulse).
41
75
  */
42
- export function screenshotSlide(slide, data, theme) {
43
- const { colors } = theme;
44
- slide.addText(data.title || 'Captura de Pantalla', [0.5, 0.3, 9, 0.5], {
45
- fontSize: 18, bold: true, color: colors.text, fontFace: 'Inter',
76
+ export function card(slide, { x, y, w, h, title, body = [], titleColor = null }, theme) {
77
+ const { colors, typo } = theme;
78
+ slide.addShape('roundRect', {
79
+ x, y, w, h, rectRadius: theme.spacing.radius,
80
+ fill: { color: colors.accentSoft },
81
+ line: { color: colors.accent, width: 1 },
46
82
  });
47
- if (data.description) {
48
- slide.addText(data.description, [0.5, 0.85, 9, 0.4], {
49
- fontSize: 12, color: colors.textLight, fontFace: 'Inter',
83
+ const runs = [
84
+ {
85
+ text: title,
86
+ options: {
87
+ fontSize: typo.sizes.cardTitle, bold: true,
88
+ color: titleColor ?? colors.accentDark,
89
+ paraSpaceAfter: 3, breakLine: true,
90
+ },
91
+ },
92
+ ...body.map((line) => ({
93
+ text: line,
94
+ options: {
95
+ fontSize: typo.sizes.cardBody,
96
+ color: colors.text,
97
+ paraSpaceAfter: 2, breakLine: true,
98
+ },
99
+ })),
100
+ ];
101
+ slide.addText(runs, {
102
+ x: x + 0.15, y: y + 0.08, w: w - 0.3, h: h - 0.16,
103
+ valign: 'top', ...baseTextOpts(theme),
104
+ });
105
+ return slide;
106
+ }
107
+
108
+ /**
109
+ * Marco de captura estilo telefono. Coloca la imagen con encaje
110
+ * proporcional (contain, nunca distorsiona); si no existe el archivo,
111
+ * dibuja un placeholder punteado con la instruccion de captura.
112
+ */
113
+ export function phoneShot(slide, { x, y, w, h, imagePath, label }, theme) {
114
+ const { colors, typo } = theme;
115
+ // Marco exterior
116
+ slide.addShape('roundRect', {
117
+ x: x - 0.07, y: y - 0.07, w: w + 0.14, h: h + 0.14,
118
+ rectRadius: theme.spacing.radius,
119
+ fill: { color: colors.text },
120
+ line: { color: colors.accentDark, width: 1.75 },
121
+ });
122
+ const exists = imagePath && fs.existsSync(imagePath);
123
+ if (exists) {
124
+ const dim = getImageSize(imagePath);
125
+ let dw = w, dh = h;
126
+ if (dim && dim.width && dim.height) {
127
+ const ir = dim.width / dim.height;
128
+ const fr = w / h;
129
+ if (ir > fr) { dh = w / ir; } else { dw = h * ir; }
130
+ }
131
+ slide.addImage({
132
+ path: imagePath,
133
+ x: x + (w - dw) / 2,
134
+ y: y + (h - dh) / 2,
135
+ w: dw, h: dh,
50
136
  });
51
- }
52
- // Area de imagen (80% ancho, centro)
53
- const imgX = 0.5, imgY = 1.4, imgW = 9, imgH = 9.5;
54
- if (data.screenshotPath) {
55
- slide.addImage({ path: data.screenshotPath, x: imgX, y: imgY, w: imgW, h: imgH });
56
137
  } else {
57
- // Placeholder con borde punteado
58
- slide.addShape('rect', [imgX, imgY, imgW, imgH], {
138
+ slide.addShape('roundRect', {
139
+ x, y, w, h, rectRadius: theme.spacing.radius,
59
140
  fill: { color: colors.surface },
60
- line: { color: colors.border, width: 2, dashType: 'dash' },
61
- });
62
- slide.addText('📷 Insertar screenshot aquí', [imgX, imgY + 4, imgW, 1], {
63
- fontSize: 16, color: colors.textLight, fontFace: 'Inter',
64
- align: 'center', valign: 'middle',
141
+ line: { color: colors.accent, width: 1, dashType: 'dash' },
65
142
  });
143
+ slide.addText(
144
+ [
145
+ { text: 'CAPTURA PENDIENTE', options: { fontSize: typo.sizes.cardBody, bold: true, color: colors.textMuted, paraSpaceAfter: 4, breakLine: true } },
146
+ { text: label || 'Screenshot de la app', options: { fontSize: typo.sizes.cardBody, color: colors.text, paraSpaceAfter: 4, breakLine: true } },
147
+ ...(imagePath
148
+ ? [{ text: `Guardar como: ${imagePath.split(/[\\/]/).slice(-2).join('/')}`, options: { fontSize: 8, color: colors.textMuted, breakLine: true } }]
149
+ : []),
150
+ ],
151
+ { x: x + 0.12, y, w: w - 0.24, h, align: 'center', valign: 'middle', ...baseTextOpts(theme) },
152
+ );
66
153
  }
67
154
  return slide;
68
155
  }
69
156
 
70
157
  /**
71
- * Feature slide icono + titulo + descripcion
158
+ * Slide de feature: descripcion + bullets.
72
159
  */
73
160
  export function featureSlide(slide, data, theme) {
74
- const { colors } = theme;
75
- slide.addText(data.title || 'Funcionalidad', [0.5, 0.3, 9, 0.6], {
76
- fontSize: 22, bold: true, color: colors.text, fontFace: 'Inter',
77
- });
78
- // description como string unico (PptxGenJS no acepta array de strings plano)
79
- const desc = data.description || '';
80
- slide.addText(desc, [0.5, 1.0, 9, 1.5], { fontSize: 14, color: colors.text, fontFace: 'Inter' });
81
- // bullets
82
- if (data.bullets && Array.isArray(data.bullets) && data.bullets.length > 0) {
83
- const bulletTexts = data.bullets.map(b => ({ text: b, options: { bullet: true } }));
84
- slide.addText(bulletTexts, [0.5, 2.6, 9, data.bullets.length * 0.4], {
85
- fontSize: 13, color: colors.text, fontFace: 'Inter',
161
+ const { colors, typo, spacing } = theme;
162
+ slide.addText(data.title || 'Funcionalidad', {
163
+ x: spacing.marginX, y: 1.05, w: spacing.contentWidth, h: 0.6,
164
+ fontSize: 15, bold: true, color: colors.text, ...baseTextOpts(theme),
165
+ });
166
+ if (data.description) {
167
+ slide.addText(data.description, {
168
+ x: spacing.marginX, y: 1.65, w: spacing.contentWidth, h: 1.1,
169
+ fontSize: typo.sizes.body, color: colors.text, valign: 'top', ...baseTextOpts(theme),
170
+ });
171
+ }
172
+ if (Array.isArray(data.bullets) && data.bullets.length > 0) {
173
+ const runs = data.bullets.map((b) => ({
174
+ text: b,
175
+ options: {
176
+ bullet: { code: '2022', indent: 12 },
177
+ fontSize: typo.sizes.bodySmall, color: colors.text,
178
+ paraSpaceAfter: 5, breakLine: true,
179
+ },
180
+ }));
181
+ slide.addText(runs, {
182
+ x: spacing.marginX, y: 2.8, w: spacing.contentWidth, h: 3.5,
183
+ valign: 'top', ...baseTextOpts(theme),
86
184
  });
87
185
  }
88
186
  return slide;
89
187
  }
90
188
 
91
189
  /**
92
- * Workflow slide pasos numerados con flechas
190
+ * Flujo de trabajo: pasos con circulo numerado + titulo y descripcion en linea.
93
191
  */
94
192
  export function workflowSlide(slide, data, theme) {
95
- const { colors } = theme;
96
- slide.addText(data.title || 'Flujo de trabajo', [0.5, 0.3, 9, 0.5], {
97
- fontSize: 18, bold: true, color: colors.text, fontFace: 'Inter',
98
- });
193
+ const { colors, typo, spacing } = theme;
99
194
  const steps = data.steps || [];
100
- const stepHeight = 1.8;
101
- const startY = 1.0;
195
+ const usableH = spacing.footerY - spacing.contentTopY - 0.15;
196
+ const rowH = Math.min(1.0, steps.length > 0 ? usableH / steps.length : 1.0);
197
+ let y = spacing.contentTopY;
102
198
  steps.forEach((step, i) => {
103
- const y = startY + i * stepHeight;
104
- slide.addText(String(i + 1), [0.5, y, 0.5, 0.4], {
105
- fontSize: 16, bold: true, color: colors.accent, fontFace: 'Inter',
199
+ slide.addShape('ellipse', {
200
+ x: spacing.marginX, y: y + 0.03, w: 0.44, h: 0.44,
201
+ fill: { color: colors.accent }, line: { type: 'none' },
106
202
  });
107
- slide.addText(step.title || '', [1.2, y, 3.5, 0.4], {
108
- fontSize: 14, bold: true, color: colors.text, fontFace: 'Inter',
203
+ slide.addText(String(i + 1), {
204
+ x: spacing.marginX, y: y + 0.03, w: 0.44, h: 0.44,
205
+ fontSize: typo.sizes.stepNumber, bold: true, color: colors.onAccent,
206
+ align: 'center', valign: 'middle', ...baseTextOpts(theme),
109
207
  });
110
- slide.addText(step.desc || '', [1.2, y + 0.4, 8, 0.6], {
111
- fontSize: 12, color: colors.textLight, fontFace: 'Inter',
112
- });
113
- if (i < steps.length - 1) {
114
- slide.addText('↓', [0.6, y + 0.85, 0.4, 0.4], {
115
- fontSize: 18, color: colors.accent, fontFace: 'Inter',
116
- });
117
- }
208
+ slide.addText(
209
+ [
210
+ { text: `${step.title || ''} `, options: { fontSize: typo.sizes.stepTitle, bold: true, color: colors.accentDark, breakLine: false } },
211
+ { text: step.desc || '', options: { fontSize: typo.sizes.stepBody, color: colors.text, breakLine: true } },
212
+ ],
213
+ { x: spacing.marginX + 0.65, y, w: data.textWidth ?? spacing.contentWidth - 0.65, h: rowH, valign: 'middle', ...baseTextOpts(theme) },
214
+ );
215
+ y += rowH;
118
216
  });
119
217
  return slide;
120
218
  }
121
219
 
122
220
  /**
123
- * Architecture slide diagrama por capas (core/ui/data)
221
+ * Limites conocidos: items con barra ambar (advertencias).
222
+ * items: [{ title, desc }]
124
223
  */
125
- export function architectureSlide(slide, data, theme) {
126
- const { colors } = theme;
127
- slide.addText(data.title || 'Arquitectura', [0.5, 0.3, 9, 0.5], {
128
- fontSize: 18, bold: true, color: colors.text, fontFace: 'Inter',
129
- });
130
- const layers = data.layers || [
131
- { name: 'UI', desc: 'Dashboard, KPIs, Modals', color: colors.accent },
132
- { name: 'Core', desc: 'Processor, Downloader, Models', color: colors.info },
133
- { name: 'Data', desc: 'API S1, Catalogo, Cache', color: colors.violet },
134
- ];
135
- const layerH = 2.2;
136
- const startY = 1.0;
137
- layers.forEach((layer, i) => {
138
- const y = startY + i * (layerH + 0.2);
139
- slide.addShape('rect', [0.5, y, 9, layerH], {
140
- fill: { color: layer.color + '20' },
141
- line: { color: layer.color, width: 1.5 },
142
- });
143
- slide.addText(layer.name, [0.7, y + 0.3, 2.5, 0.5], {
144
- fontSize: 16, bold: true, color: layer.color, fontFace: 'Inter',
145
- });
146
- slide.addText(layer.desc, [3.3, y + 0.35, 6, 0.4], {
147
- fontSize: 13, color: colors.text, fontFace: 'Inter',
224
+ export function limitsSlide(slide, data, theme) {
225
+ const { colors, typo, spacing } = theme;
226
+ const items = data.items || [];
227
+ const cols = 2;
228
+ const colW = (spacing.contentWidth - spacing.cardGap) / cols;
229
+ const rowH = 1.75;
230
+ items.forEach((item, i) => {
231
+ const col = i % cols;
232
+ const row = Math.floor(i / cols);
233
+ const x = spacing.marginX + col * (colW + spacing.cardGap);
234
+ const y = spacing.contentTopY + row * rowH;
235
+ slide.addShape('rect', {
236
+ x, y: y + 0.05, w: 0.07, h: rowH - 0.25,
237
+ fill: { color: colors.warning }, line: { type: 'none' },
148
238
  });
149
- if (i < layers.length - 1) {
150
- slide.addText('▼', [4.5, y + layerH + 0.05, 0.5, 0.2], {
151
- fontSize: 10, color: colors.textLight, fontFace: 'Inter',
152
- });
153
- }
239
+ slide.addText(
240
+ [
241
+ { text: item.title, options: { fontSize: 11.5, bold: true, color: colors.warning, paraSpaceAfter: 3, breakLine: true } },
242
+ { text: item.desc, options: { fontSize: typo.sizes.bodySmall, color: colors.text, breakLine: true } },
243
+ ],
244
+ { x: x + 0.25, y, w: colW - 0.25, h: rowH - 0.1, valign: 'top', ...baseTextOpts(theme) },
245
+ );
154
246
  });
155
247
  return slide;
156
248
  }
157
249
 
158
250
  /**
159
- * Checklist slide buenas practicas / resumen
251
+ * Checklist con checks verdes (buenas practicas / resumen).
160
252
  */
161
253
  export function checklistSlide(slide, data, theme) {
162
- const { colors } = theme;
163
- slide.addText(data.title || 'Resumen', [0.5, 0.3, 9, 0.5], {
164
- fontSize: 18, bold: true, color: colors.text, fontFace: 'Inter',
165
- });
254
+ const { colors, typo, spacing } = theme;
166
255
  const items = data.items || [];
256
+ const startY = data.startY ?? spacing.contentTopY;
257
+ const rowH = data.rowH ?? 0.56;
167
258
  items.forEach((item, i) => {
168
- const y = 0.9 + i * 0.55;
259
+ const y = startY + i * rowH;
169
260
  const checked = item.checked !== false;
170
- slide.addText(checked ? '✓' : '○', [0.5, y, 0.4, 0.4], {
171
- fontSize: 14, color: checked ? colors.success : colors.textLight, fontFace: 'Arial',
172
- });
173
- slide.addText(item.text || '', [1.0, y, 8.5, 0.4], {
174
- fontSize: 13, color: colors.text, fontFace: 'Inter',
261
+ const runs = [
262
+ {
263
+ text: checked ? '✓ ' : '○ ',
264
+ options: { fontSize: typo.sizes.body + 0.5, bold: true, color: checked ? colors.accent : colors.textMuted, breakLine: false },
265
+ },
266
+ {
267
+ text: typeof item === 'string' ? item : item.text || '',
268
+ options: { fontSize: typo.sizes.body + 0.5, color: colors.text, breakLine: true },
269
+ },
270
+ ];
271
+ slide.addText(runs, {
272
+ x: 1.0, y, w: spacing.contentWidth - 0.4, h: rowH, valign: 'middle', ...baseTextOpts(theme),
175
273
  });
176
274
  });
177
275
  return slide;
178
276
  }
179
277
 
180
278
  /**
181
- * KPI / metrics slide para dashboards
279
+ * Tabla de modulos: filas alternadas con nombre, ruta y descripcion.
280
+ * rows: [{ name, route, desc }]
182
281
  */
183
- export function kpiSlide(slide, data, theme) {
184
- const { colors } = theme;
185
- slide.addText(data.title || 'Indicadores clave', [0.5, 0.3, 9, 0.5], {
186
- fontSize: 18, bold: true, color: colors.text, fontFace: 'Inter',
282
+ export function modulesTable(slide, { rows, y = null, w = null }, theme) {
283
+ const { colors, typo, spacing } = theme;
284
+ const width = w ?? spacing.contentWidth - 4.35; // deja sitio a capturas laterales
285
+ let yy = y ?? spacing.contentTopY;
286
+ const rowH = 0.98;
287
+ rows.forEach((row, i) => {
288
+ slide.addShape('roundRect', {
289
+ x: spacing.marginX, y: yy, w: width, h: rowH, rectRadius: theme.spacing.radius,
290
+ fill: { color: i % 2 === 0 ? colors.accentSoft : colors.bg },
291
+ line: { color: colors.accent, width: 0.75 },
292
+ });
293
+ slide.addText(row.name, {
294
+ x: spacing.marginX + 0.2, y: yy + 0.1, w: 1.55, h: 0.4,
295
+ fontSize: 12.5, bold: true, color: colors.accentDark, ...baseTextOpts(theme),
296
+ });
297
+ if (row.route) {
298
+ slide.addText(row.route, {
299
+ x: spacing.marginX + 1.75, y: yy + 0.13, w: 1.8, h: 0.4,
300
+ fontSize: typo.sizes.cardBody, color: colors.textMuted, ...baseTextOpts(theme),
301
+ });
302
+ }
303
+ slide.addText(row.desc, {
304
+ x: spacing.marginX + 3.6, y: yy + 0.09, w: width - 3.8, h: rowH - 0.13,
305
+ fontSize: typo.sizes.cardBody, color: colors.text, valign: 'top', ...baseTextOpts(theme),
306
+ });
307
+ yy += rowH + 0.14;
187
308
  });
309
+ return yy;
310
+ }
311
+
312
+ /**
313
+ * KPI cards (dashboard).
314
+ * kpis: [{ label, value, sub, color }]
315
+ */
316
+ export function kpiSlide(slide, data, theme) {
317
+ const { colors, typo, spacing } = theme;
188
318
  const kpis = data.kpis || [];
189
- const cols = Math.min(kpis.length, 4);
190
- const colW = 9 / cols;
319
+ const cols = Math.min(kpis.length, 4) || 1;
320
+ const colW = (spacing.contentWidth - spacing.cardGap * (cols - 1)) / cols;
191
321
  kpis.forEach((kpi, i) => {
192
- const x = 0.5 + i * colW + colW * 0.1;
193
- const w = colW * 0.8;
194
- slide.addShape('rect', [x, 1.0, w, 2.5], {
195
- fill: { color: (kpi.color || colors.accent) + '15' },
196
- line: { color: kpi.color || colors.accent, width: 1 },
322
+ const x = spacing.marginX + i * (colW + spacing.cardGap);
323
+ const color = kpi.color || colors.accent;
324
+ slide.addShape('roundRect', {
325
+ x, y: 1.2, w: colW, h: 2.5, rectRadius: theme.spacing.radius,
326
+ fill: { color: colors.surface }, line: { color, width: 1 },
197
327
  });
198
- slide.addText(kpi.value || '—', [x, 1.1, w, 0.9], {
199
- fontSize: 28, bold: true, color: kpi.color || colors.accent, fontFace: 'JetBrains Mono',
200
- align: 'center',
328
+ slide.addText(kpi.value || '—', {
329
+ x, y: 1.35, w: colW, h: 0.9,
330
+ fontSize: typo.sizes.kpiValue, bold: true, color,
331
+ align: 'center', fontFace: 'JetBrains Mono, Consolas, monospace',
201
332
  });
202
- slide.addText(kpi.label || '', [x, 2.0, w, 0.3], {
203
- fontSize: 11, color: colors.textLight, fontFace: 'Inter', align: 'center',
333
+ slide.addText(kpi.label || '', {
334
+ x, y: 2.3, w: colW, h: 0.35,
335
+ fontSize: typo.sizes.kpiLabel, color: colors.textMuted, align: 'center', ...baseTextOpts(theme),
204
336
  });
205
337
  if (kpi.sub) {
206
- slide.addText(kpi.sub, [x, 2.35, w, 0.3], {
207
- fontSize: 10, color: colors.textLight, fontFace: 'Inter', align: 'center',
338
+ slide.addText(kpi.sub, {
339
+ x, y: 2.65, w: colW, h: 0.3,
340
+ fontSize: 10, color: colors.textMuted, align: 'center', ...baseTextOpts(theme),
208
341
  });
209
342
  }
210
343
  });
@@ -212,28 +345,97 @@ export function kpiSlide(slide, data, theme) {
212
345
  }
213
346
 
214
347
  /**
215
- * Footer con branding G360
348
+ * Arquitectura: capas apiladas con color por rol.
349
+ * layers: [{ name, desc, color }]
216
350
  */
217
- export function addFooter(slide, theme, slideNum, totalSlides) {
218
- const { colors } = theme;
219
- slide.addText('powered by G360', [7.5, 13.7, 2.2, 0.3], {
220
- fontSize: 10, color: colors.textLight, fontFace: 'Inter', align: 'right',
221
- });
222
- slide.addText(`${slideNum} / ${totalSlides}`, [8.5, 13.9, 1, 0.25], {
223
- fontSize: 9, color: colors.textLight, fontFace: 'Inter', align: 'right',
351
+ export function architectureSlide(slide, data, theme) {
352
+ const { colors, typo, spacing } = theme;
353
+ const layers = data.layers || [];
354
+ const layerH = 1.15;
355
+ let y = spacing.contentTopY + 0.1;
356
+ layers.forEach((layer, i) => {
357
+ const color = layer.color || colors.accent;
358
+ slide.addShape('roundRect', {
359
+ x: spacing.marginX, y, w: spacing.contentWidth, h: layerH,
360
+ rectRadius: theme.spacing.radius,
361
+ fill: { color: colors.surface }, line: { color, width: 1.5 },
362
+ });
363
+ slide.addText(layer.name, {
364
+ x: spacing.marginX + 0.25, y: y + 0.1, w: 2.5, h: layerH - 0.2,
365
+ fontSize: 15, bold: true, color, valign: 'middle', ...baseTextOpts(theme),
366
+ });
367
+ slide.addText(layer.desc, {
368
+ x: spacing.marginX + 3.0, y: y + 0.1, w: spacing.contentWidth - 3.2, h: layerH - 0.2,
369
+ fontSize: typo.sizes.bodySmall, color: colors.text, valign: 'middle', ...baseTextOpts(theme),
370
+ });
371
+ if (i < layers.length - 1) {
372
+ slide.addText('▼', {
373
+ x: spacing.page.width / 2 - 0.2, y: y + layerH + 0.01, w: 0.4, h: 0.24,
374
+ fontSize: 10, color: colors.textMuted, align: 'center', ...baseTextOpts(theme),
375
+ });
376
+ }
377
+ y += layerH + 0.32;
224
378
  });
379
+ return slide;
225
380
  }
226
381
 
227
382
  /**
228
- * Helper: agregar seccion header comun
383
+ * Portada: titulo grande centrado, banda de acento, subtitulo y meta.
229
384
  */
230
- export function addSectionHeader(slide, section, theme) {
231
- const { colors } = theme;
232
- slide.addText(section, [0.5, 0.15, 9, 0.35], {
233
- fontSize: 10, bold: true, color: colors.accent, fontFace: 'Inter',
234
- charSpacing: 2,
385
+ export function coverSlide(slide, data, theme) {
386
+ const { colors, typo, spacing } = theme;
387
+ const W = spacing.page.width;
388
+ slide.addText(data.appName || 'Mi App G360', {
389
+ x: 1.0, y: 1.5, w: W - 2.0, h: 1.3,
390
+ fontSize: typo.sizes.coverTitle, bold: true, color: colors.coverTitle,
391
+ align: 'center', valign: 'middle', ...baseTextOpts(theme),
392
+ });
393
+ slide.addShape('rect', {
394
+ x: 0, y: 3.15, w: W, h: 0.055,
395
+ fill: { color: colors.accent }, line: { type: 'none' },
235
396
  });
236
- slide.addShape('rect', [0.5, 0.52, 9, 0.02], {
237
- fill: { color: colors.accent }, line: { color: colors.accent },
397
+ const sub = slide.addText(
398
+ [
399
+ { text: data.description || 'Documentacion de uso y funcionalidades', options: { fontSize: typo.sizes.coverSubtitle, color: colors.text, paraSpaceAfter: 3, breakLine: true } },
400
+ { text: data.tagline || '', options: { fontSize: typo.sizes.coverMeta, color: colors.textMuted, breakLine: true } },
401
+ ],
402
+ { x: 1.5, y: 3.5, w: W - 3.0, h: 1.2, align: 'center', ...baseTextOpts(theme) },
403
+ );
404
+ const meta = [
405
+ data.version ? `Manual de uso · v${data.version}` : 'Manual de uso',
406
+ 'powered by G360',
407
+ ].filter(Boolean).join(' · ');
408
+ slide.addText(meta, {
409
+ x: 1.0, y: 5.4, w: W - 2.0, h: 0.9,
410
+ fontSize: 13, bold: false, color: colors.textMuted,
411
+ align: 'center', ...baseTextOpts(theme),
238
412
  });
413
+ const logo = typeof theme.logo === 'function' ? theme.logo() : null;
414
+ if (logo) {
415
+ slide.addImage({ path: logo.path, x: W / 2 - logo.w / 2, y: 6.35, w: logo.w, h: logo.h });
416
+ }
417
+ return slide;
418
+ }
419
+
420
+ // ===== Compat: nombres anteriores usados por templates =====
421
+ export { card as tarjeta, phoneShot as screenshotFrame };
422
+
423
+ /** screenshotSlide de compat: titulo + descripcion + marco */
424
+ export function screenshotSlide(slide, data, theme) {
425
+ const { colors, typo, spacing } = theme;
426
+ slide.addText(data.title || 'Captura de Pantalla', {
427
+ x: spacing.marginX, y: 1.05, w: spacing.contentWidth, h: 0.6,
428
+ fontSize: 15, bold: true, color: colors.text, ...baseTextOpts(theme),
429
+ });
430
+ if (data.description) {
431
+ slide.addText(data.description, {
432
+ x: spacing.marginX, y: 1.65, w: spacing.contentWidth, h: 0.75,
433
+ fontSize: typo.sizes.bodySmall, color: colors.textMuted, valign: 'top', ...baseTextOpts(theme),
434
+ });
435
+ }
436
+ return phoneShot(slide, {
437
+ x: spacing.marginX + 1.2, y: 2.4, w: spacing.contentWidth - 2.4, h: 4.3,
438
+ imagePath: data.screenshotPath,
439
+ label: data.title || 'Screenshot de la app',
440
+ }, theme);
239
441
  }
@@ -1,14 +1,19 @@
1
1
  /**
2
- * Layouts adicionales re-exportados desde base.js para compatibilidad.
2
+ * Layouts re-exportados desde base.js para compatibilidad.
3
3
  */
4
4
  export {
5
5
  coverSlide,
6
6
  screenshotSlide,
7
+ screenshotFrame,
7
8
  featureSlide,
8
9
  workflowSlide,
9
10
  architectureSlide,
10
11
  checklistSlide,
11
12
  kpiSlide,
13
+ limitsSlide,
14
+ modulesTable,
15
+ card,
16
+ textBlock,
12
17
  addFooter,
13
18
  addSectionHeader,
14
19
  } from './base.js';