@cyber-dash-tech/revela 0.18.16 → 0.19.1

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 (98) hide show
  1. package/README.md +45 -31
  2. package/README.zh-CN.md +45 -31
  3. package/assets/img/lucent-01.jpg +0 -0
  4. package/assets/img/lucent-02.jpg +0 -0
  5. package/assets/img/lucent-03.jpg +0 -0
  6. package/assets/img/lucent-dark-01.jpg +0 -0
  7. package/assets/img/lucent-dark-02.jpg +0 -0
  8. package/assets/img/lucent-dark-03.jpg +0 -0
  9. package/assets/img/monet-01.jpg +0 -0
  10. package/assets/img/monet-02.jpg +0 -0
  11. package/assets/img/monet-03.jpg +0 -0
  12. package/assets/img/starter-01.jpg +0 -0
  13. package/assets/img/starter-02.jpg +0 -0
  14. package/assets/img/starter-03.jpg +0 -0
  15. package/assets/img/summit-01.jpg +0 -0
  16. package/assets/img/summit-02.jpg +0 -0
  17. package/assets/img/summit-03.jpg +0 -0
  18. package/designs/lucent/DESIGN.md +76 -0
  19. package/designs/lucent/design.css +283 -0
  20. package/designs/lucent-dark/DESIGN.md +278 -0
  21. package/designs/lucent-dark/assets/card-lens.jpg +0 -0
  22. package/designs/lucent-dark/assets/closing-background.jpg +0 -0
  23. package/designs/lucent-dark/assets/cover-background.jpg +0 -0
  24. package/designs/lucent-dark/assets/report-visual.jpg +0 -0
  25. package/designs/lucent-dark/assets/soft-texture.jpg +0 -0
  26. package/designs/lucent-dark/assets/toc-orb.png +0 -0
  27. package/designs/lucent-dark/design.css +417 -0
  28. package/designs/monet/DESIGN.md +14 -0
  29. package/designs/monet/assets/card-lens.jpg +0 -0
  30. package/designs/monet/assets/closing-background.jpg +0 -0
  31. package/designs/monet/assets/cover-background.jpg +0 -0
  32. package/designs/monet/assets/report-visual.jpg +0 -0
  33. package/designs/monet/assets/soft-texture.jpg +0 -0
  34. package/designs/monet/assets/toc-orb.png +0 -0
  35. package/designs/monet/design.css +340 -0
  36. package/designs/starter/DESIGN.md +14 -0
  37. package/designs/starter/assets/card-lens.jpg +0 -0
  38. package/designs/starter/assets/closing-background.jpg +0 -0
  39. package/designs/starter/assets/cover-background.jpg +0 -0
  40. package/designs/starter/assets/report-visual.jpg +0 -0
  41. package/designs/starter/assets/soft-texture.jpg +0 -0
  42. package/designs/starter/assets/toc-orb.png +0 -0
  43. package/designs/starter/design.css +322 -0
  44. package/designs/summit/DESIGN.md +18 -0
  45. package/designs/summit/assets/card-lens.jpg +0 -0
  46. package/designs/summit/assets/closing-background.jpg +0 -0
  47. package/designs/summit/assets/cover-background.jpg +0 -0
  48. package/designs/summit/assets/report-visual.jpg +0 -0
  49. package/designs/summit/assets/soft-texture.jpg +0 -0
  50. package/designs/summit/assets/toc-orb.png +0 -0
  51. package/designs/summit/design.css +334 -0
  52. package/lib/commands/designs-new.ts +13 -25
  53. package/lib/commands/designs-preview.ts +3 -8
  54. package/lib/deck-html/foundation.ts +8 -8
  55. package/lib/design/designs.ts +317 -14
  56. package/lib/narrative-state/deck-plan-artifact.ts +40 -3
  57. package/lib/page-templates/built-in-preview.html +373 -0
  58. package/lib/page-templates/contracts.ts +2 -0
  59. package/lib/page-templates/css.ts +2 -0
  60. package/lib/page-templates/foundation.ts +41 -0
  61. package/lib/page-templates/index.ts +6 -0
  62. package/lib/page-templates/registry.ts +3 -0
  63. package/lib/page-templates/render.ts +1202 -0
  64. package/lib/page-templates/templates/agenda.ts +4 -0
  65. package/lib/page-templates/templates/chart-takeaways.ts +4 -0
  66. package/lib/page-templates/templates/claim-supporting-visual.ts +4 -0
  67. package/lib/page-templates/templates/closing.ts +4 -0
  68. package/lib/page-templates/templates/cover.ts +4 -0
  69. package/lib/page-templates/templates/executive-summary.ts +4 -0
  70. package/lib/page-templates/templates/index.ts +19 -0
  71. package/lib/page-templates/templates/key-message-evidence.ts +4 -0
  72. package/lib/page-templates/templates/metric-highlight.ts +4 -0
  73. package/lib/page-templates/templates/problem-context.ts +4 -0
  74. package/lib/page-templates/templates/process-steps.ts +4 -0
  75. package/lib/page-templates/templates/recommendation-decision.ts +4 -0
  76. package/lib/page-templates/templates/risks-tradeoffs.ts +4 -0
  77. package/lib/page-templates/templates/section-divider.ts +4 -0
  78. package/lib/page-templates/templates/shared.ts +11 -0
  79. package/lib/page-templates/templates/table-comparison.ts +4 -0
  80. package/lib/page-templates/templates/timeline-roadmap.ts +4 -0
  81. package/lib/page-templates/vocabulary.ts +158 -0
  82. package/lib/prompt-builder.ts +5 -5
  83. package/lib/qa/artifact.ts +66 -1
  84. package/lib/qa/compliance.ts +5 -1
  85. package/lib/runtime/index.ts +99 -3
  86. package/package.json +7 -15
  87. package/plugins/revela/.codex-plugin/plugin.json +1 -1
  88. package/plugins/revela/hooks/revela_guard.ts +35 -0
  89. package/plugins/revela/hooks/revela_post_write_notice.ts +4 -4
  90. package/plugins/revela/mcp/revela-server.ts +104 -6
  91. package/plugins/revela/skills/revela/SKILL.md +1 -1
  92. package/plugins/revela/skills/revela-design/SKILL.md +22 -16
  93. package/plugins/revela/skills/revela-helper/SKILL.md +1 -1
  94. package/plugins/revela/skills/revela-make-deck/SKILL.md +25 -16
  95. package/designs/lucent/preview.html +0 -529
  96. package/designs/monet/preview.html +0 -190
  97. package/designs/starter/preview.html +0 -335
  98. package/designs/summit/preview.html +0 -186
@@ -0,0 +1,1202 @@
1
+ import { existsSync, readFileSync, writeFileSync } from "fs"
2
+ import { isAbsolute, normalize, resolve } from "path"
3
+ import { getPageTemplateVocabulary } from "./vocabulary"
4
+
5
+ export type PageTemplateStatus = "metadata-only" | "renderable"
6
+
7
+ export interface PageTemplateField {
8
+ name: string
9
+ type: "string" | "string[]" | "items[]" | "metrics[]" | "milestones[]" | "rows[]" | "steps[]"
10
+ required?: boolean
11
+ description: string
12
+ }
13
+
14
+ export interface PageTemplateDefinition {
15
+ id: string
16
+ title: string
17
+ purpose: string
18
+ status: PageTemplateStatus
19
+ fields: PageTemplateField[]
20
+ contentRules: string[]
21
+ qaRules: string[]
22
+ }
23
+
24
+ export interface RenderTemplateSlideInput {
25
+ templateId: string
26
+ slideIndex: number
27
+ content: Record<string, any>
28
+ designName?: string
29
+ }
30
+
31
+ export interface RenderTemplateScaffoldInput {
32
+ templateId: string
33
+ slideIndex: number
34
+ seed?: Record<string, any>
35
+ designName?: string
36
+ }
37
+
38
+ export interface RenderTemplateSlideResult {
39
+ ok: true
40
+ templateId: string
41
+ slideIndex: number
42
+ designName: string
43
+ html: string
44
+ warnings: string[]
45
+ }
46
+
47
+ export interface RenderTemplateScaffoldResult extends RenderTemplateSlideResult {
48
+ scaffold: true
49
+ }
50
+
51
+ export interface AddTemplateSlideInput extends RenderTemplateSlideInput {
52
+ workspaceRoot: string
53
+ outputPath: string
54
+ }
55
+
56
+ export interface AddTemplateScaffoldInput extends RenderTemplateScaffoldInput {
57
+ workspaceRoot: string
58
+ outputPath: string
59
+ }
60
+
61
+ export interface AddTemplateSlideResult extends RenderTemplateSlideResult {
62
+ outputPath: string
63
+ inserted: boolean
64
+ }
65
+
66
+ export interface AddTemplateScaffoldResult extends RenderTemplateScaffoldResult {
67
+ outputPath: string
68
+ inserted: boolean
69
+ }
70
+
71
+ export interface PageTemplateContractIssue {
72
+ severity: "error" | "warning"
73
+ templateId: string
74
+ slideIndex?: number
75
+ message: string
76
+ }
77
+
78
+ export interface PageTemplateContractReport {
79
+ ok: boolean
80
+ issues: PageTemplateContractIssue[]
81
+ }
82
+
83
+ export interface BoundedTemplateEditInput {
84
+ beforeHtml: string
85
+ afterHtml: string
86
+ slideIndex: number
87
+ }
88
+
89
+ export interface BuiltInPreviewFixture {
90
+ templateId: string
91
+ content: Record<string, any>
92
+ }
93
+
94
+ const templates: PageTemplateDefinition[] = [
95
+ define("cover", "Cover", "Open the deck with one clear artifact title and context.", [
96
+ field("eyebrow", "string", "Small context label."),
97
+ field("title", "string", "Deck title.", true),
98
+ ], ["Use one dominant title.", "Keep source/evidence details out of the cover."], ["Has one h1.", "Uses hero structure."]),
99
+ define("section-divider", "Section Divider", "Mark a chapter transition with a short label and thesis.", [
100
+ field("eyebrow", "string", "Chapter label."),
101
+ field("title", "string", "Section title.", true),
102
+ ], ["Use between chapters only."], ["Has one h1.", "Counts as structural."]),
103
+ define("closing", "Closing", "End with the final decision, ask, or next action.", [
104
+ field("title", "string", "Closing line.", true),
105
+ ], ["Keep the close concise."], ["Has one h1.", "Uses closing/hero structure."]),
106
+ define("agenda", "Agenda / TOC", "Orient the audience to the deck flow.", [
107
+ field("title", "string", "Agenda title.", true),
108
+ field("items", "items[]", "Agenda items.", true),
109
+ ], ["Use 3-6 items."], ["Numbers are in DOM order."]),
110
+ define("executive-summary", "Executive Summary", "Compress the decision logic into a few takeaways.", [
111
+ field("title", "string", "Slide title.", true),
112
+ field("items", "items[]", "Summary takeaways.", true),
113
+ ], ["Use 3-4 takeaways.", "Each takeaway needs a short label and support line."], ["Contains summary cards."]),
114
+ define("problem-context", "Problem / Context", "Frame why the topic matters now.", [
115
+ field("title", "string", "Slide title.", true),
116
+ field("body", "string", "Context paragraph.", true),
117
+ field("items", "items[]", "Context bullets."),
118
+ ], ["Separate situation from implication."], ["Main message remains outside cards."]),
119
+ define("key-message-evidence", "Key Message + Evidence", "State a claim and show the supporting evidence items.", [
120
+ field("title", "string", "Claim title.", true),
121
+ field("body", "string", "Claim explanation.", true),
122
+ field("items", "items[]", "Evidence items.", true),
123
+ ], ["Evidence cards must not invent unsupported facts."], ["Has claim and evidence region."]),
124
+ define("claim-supporting-visual", "Claim + Supporting Visual", "Pair a claim with one visual or diagram placeholder.", [
125
+ field("title", "string", "Claim title.", true),
126
+ field("body", "string", "Claim explanation.", true),
127
+ field("visualTitle", "string", "Visual label."),
128
+ field("items", "items[]", "Visual callouts."),
129
+ ], ["Use for one visual argument, not many unrelated facts."], ["Visual region is present."]),
130
+ define("metric-highlight", "Metric Highlight", "Let one or more metrics carry the page.", [
131
+ field("title", "string", "Slide title.", true),
132
+ field("metrics", "metrics[]", "Metric cards.", true),
133
+ field("body", "string", "Interpretation."),
134
+ field("insightTitle", "string", "Insight panel title."),
135
+ field("insightBody", "string", "Metric interpretation, reading note, or caveat."),
136
+ field("insightIcon", "string", "Lucide icon name for the insight title."),
137
+ field("insightPosition", "string", "top or bottom insight panel placement."),
138
+ ], ["Every number needs an interpretation line."], ["Metric values are not hidden in body copy."]),
139
+ define("chart-takeaways", "Chart + Takeaways", "Reserve space for a chart and explain what to read from it.", [
140
+ field("title", "string", "Slide title.", true),
141
+ field("chartTitle", "string", "Chart title."),
142
+ field("takeawaysTitle", "string", "Title for the interpretation text panel."),
143
+ field("items", "items[]", "Takeaways.", true),
144
+ ], ["Chart area must be explicit and bounded."], ["Chart panel and takeaways both exist."]),
145
+ define("table-comparison", "Table / Comparison", "Compare options, segments, or facts in a structured table.", [
146
+ field("title", "string", "Slide title.", true),
147
+ field("columns", "string[]", "Column labels.", true),
148
+ field("rows", "rows[]", "Table rows.", true),
149
+ field("insightTitle", "string", "Insight panel title."),
150
+ field("insightBody", "string", "Interpretation, reading note, or caveat below the table."),
151
+ field("insightIcon", "string", "Lucide icon name for the insight title."),
152
+ ], ["Keep rows scannable.", "Do not use a table for pure prose."], ["Table has headers and body rows."]),
153
+ define("timeline-roadmap", "Timeline / Roadmap", "Show dated phases, milestones, or journey steps.", [
154
+ field("title", "string", "Slide title.", true),
155
+ field("orientation", "string", "horizontal or vertical."),
156
+ field("milestones", "milestones[]", "Timeline milestones.", true),
157
+ field("insightTitle", "string", "Side panel title."),
158
+ field("insightBody", "string", "Timeline interpretation, so-what, or caveat."),
159
+ field("insightSide", "string", "left or right side panel placement."),
160
+ ], ["Use 3-6 milestones.", "Horizontal timelines use card copy above the axis and year labels below it.", "Each dot belongs to the same DOM item as its copy."], ["Timeline root exists.", "Every milestone has dot and copy.", "Dot and copy are sibling anchors inside one timeline item."]),
161
+ define("process-steps", "Process / Steps", "Show a short ordered process or execution sequence.", [
162
+ field("title", "string", "Slide title.", true),
163
+ field("steps", "steps[]", "Ordered steps.", true),
164
+ ], ["Use 3-5 steps.", "Each step starts with an action."], ["Steps are numbered in DOM order."]),
165
+ define("recommendation-decision", "Recommendation / Decision / Ask", "Make the requested decision and explain rationale and next steps.", [
166
+ field("title", "string", "Slide title.", true),
167
+ field("recommendation", "string", "Recommended action.", true),
168
+ field("image", "string", "Optional image card path for the recommendation panel."),
169
+ field("imageAlt", "string", "Optional image alt text."),
170
+ field("imageCaption", "string", "Optional image caption."),
171
+ field("items", "items[]", "Rationale points."),
172
+ field("steps", "steps[]", "Next steps."),
173
+ ], ["State the ask plainly.", "Separate rationale from next steps."], ["Recommendation panel exists.", "Next steps are ordered."]),
174
+ define("risks-tradeoffs", "Risks / Caveats / Tradeoffs", "Keep limitations and tradeoffs visible.", [
175
+ field("title", "string", "Slide title.", true),
176
+ field("items", "items[]", "Risks or caveats.", true),
177
+ ], ["Name uncertainty instead of hiding it."], ["Contains risk/tradeoff cards."]),
178
+ ]
179
+
180
+ export function listPageTemplates(): { ok: true; templates: PageTemplateDefinition[] } {
181
+ return {
182
+ ok: true,
183
+ templates: templates.map((template) => ({
184
+ ...template,
185
+ vocabulary: getPageTemplateVocabulary(template.id),
186
+ } as PageTemplateDefinition & { vocabulary: ReturnType<typeof getPageTemplateVocabulary> })),
187
+ }
188
+ }
189
+
190
+ export function renderTemplateSlide(input: RenderTemplateSlideInput): RenderTemplateSlideResult {
191
+ const template = getPageTemplate(input.templateId)
192
+ const slideIndex = positiveIndex(input.slideIndex)
193
+ const content = input.content ?? {}
194
+ const designName = input.designName || "lucent"
195
+ const warnings = validateRequiredFields(template, content)
196
+ const html = renderSlideShell({
197
+ template,
198
+ slideIndex,
199
+ designName,
200
+ title: stringValue(content.title) || template.title,
201
+ body: renderBody(template.id, content),
202
+ })
203
+ return { ok: true, templateId: template.id, slideIndex, designName, html, warnings }
204
+ }
205
+
206
+ export function builtInPreviewFixtures(): BuiltInPreviewFixture[] {
207
+ return [
208
+ fixture("cover", { title: "cover" }),
209
+ fixture("section-divider", { title: "section-divider" }),
210
+ fixture("closing", { title: "closing" }),
211
+ fixture("agenda", {
212
+ title: "agenda",
213
+ items: [
214
+ { label: "Frame the decision" },
215
+ { label: "Show the evidence" },
216
+ { label: "Compare options" },
217
+ { label: "Close with action" },
218
+ ],
219
+ }),
220
+ fixture("executive-summary", {
221
+ title: "executive-summary",
222
+ items: [
223
+ { label: "Decision is ready", description: "The facts support moving from discussion to selection without adding another analysis cycle." },
224
+ { label: "Risk is bounded", description: "Known caveats are visible, named, and can be managed through rollout gates." },
225
+ { label: "Next step is narrow", description: "A pilot decision creates more learning without overcommitting capital or team capacity." },
226
+ ],
227
+ }),
228
+ fixture("problem-context", {
229
+ title: "problem-context",
230
+ body: "Use this template when the audience needs the situation, tension, and implication before seeing recommendations.",
231
+ items: [
232
+ { label: "Situation", description: "A shift has changed the operating baseline." },
233
+ { label: "Tension", description: "Current process cannot absorb the new variance cleanly." },
234
+ { label: "Implication", description: "Delay increases rework and weakens decision confidence." },
235
+ ],
236
+ }),
237
+ fixture("key-message-evidence", {
238
+ title: "key-message-evidence",
239
+ body: "This key message stays large and readable, while the supporting evidence is separated into traceable slots for source-backed proof.",
240
+ items: [
241
+ { label: "Evidence 1", description: "The generated HTML separates the key-message panel from the evidence grid, so the claim cannot collapse into generic card content." },
242
+ { label: "Evidence 2", description: "Each evidence slot has a stable title and explanation area, giving the agent a predictable place for proof, caveat, or source-backed detail." },
243
+ { label: "Evidence 3", description: "QA can inspect the DOM contract before visual styling, which keeps template structure from depending on a design skin." },
244
+ ],
245
+ }),
246
+ fixture("claim-supporting-visual", {
247
+ title: "claim-supporting-visual",
248
+ claim: "A single visual should carry one argument.",
249
+ body: "The template reserves a stable visual region while keeping explanatory copy close enough to guide interpretation.",
250
+ items: [
251
+ { label: "Anchor", description: "State what the reader should inspect first." },
252
+ { label: "Callout", description: "Use short labels instead of a second paragraph." },
253
+ ],
254
+ }),
255
+ fixture("metric-highlight", {
256
+ title: "metric-highlight",
257
+ metrics: [
258
+ { value: "67%", label: "Adoption signal", description: "Primary number plus interpretation." },
259
+ { value: "3x", label: "Review speed", description: "Comparison is stated beside the metric." },
260
+ { value: "14d", label: "Pilot window", description: "Time bound keeps the ask concrete." },
261
+ ],
262
+ insightTitle: "Read the signal",
263
+ insightIcon: "scan-search",
264
+ insightBody: "Treat the metric row as the evidence surface, then use this panel to state the decision implication, caveat, or next reading step.",
265
+ }),
266
+ fixture("chart-takeaways", {
267
+ title: "chart-takeaways",
268
+ takeawaysTitle: "What to read",
269
+ items: [
270
+ { label: "Trend", description: "Call out the movement or comparison the chart is meant to prove, including the direction and the comparison baseline." },
271
+ { label: "Driver", description: "Name the likely reason without overclaiming; separate observed movement from the interpretation or hypothesis." },
272
+ { label: "Decision use", description: "Explain how the chart changes the recommendation, what threshold matters, and what follow-up evidence would reduce risk." },
273
+ ],
274
+ }),
275
+ fixture("table-comparison", {
276
+ title: "table-comparison",
277
+ columns: ["Layer", "Owns", "Agent task"],
278
+ rows: [
279
+ ["Template", "Structure and DOM contract", "Select the page pattern"],
280
+ ["Content", "Claim, evidence, caveat", "Fill the meaning"],
281
+ ["Design", "Color, type, surfaces", "Skin stable classes"],
282
+ ],
283
+ insightTitle: "Insight",
284
+ insightIcon: "lightbulb",
285
+ insightBody: "The template layer owns structure, while the design layer owns visual treatment. This keeps agent edits bounded without freezing the final look.",
286
+ }),
287
+ fixture("timeline-roadmap", {
288
+ title: "timeline-roadmap",
289
+ orientation: "horizontal",
290
+ milestones: [
291
+ { date: "2022", label: "Signal", description: "Map the baseline." },
292
+ { date: "2023", label: "Proof", description: "Validate the evidence threshold." },
293
+ { date: "2024", label: "Inflection", description: "Use the pivotal moment to frame the shift." },
294
+ { date: "2025", label: "Scale", description: "Use a taller card for the highlighted milestone.", highlight: true },
295
+ { date: "2026", label: "Decision", description: "Commit to the next path." },
296
+ ],
297
+ }),
298
+ fixture("timeline-roadmap", {
299
+ title: "timeline-roadmap-vertical",
300
+ orientation: "vertical",
301
+ insightTitle: "Reading the journey",
302
+ insightBody: "The timeline should show sequence and decision rhythm, while the side panel explains why the milestones matter.",
303
+ milestones: [
304
+ { date: "Mar 2019", label: "Launch", description: "Baseline mapping." },
305
+ { date: "Nov 2019", label: "Audit", description: "Evidence sprint." },
306
+ { date: "May 2020", label: "Scale", description: "Operating cadence." },
307
+ { date: "Feb 2021", label: "Review", description: "QA before export." },
308
+ ],
309
+ }),
310
+ fixture("process-steps", {
311
+ title: "process-steps",
312
+ steps: [
313
+ { label: "Choose", description: "Select the page template that matches the communication job." },
314
+ { label: "Fill", description: "Provide only the content fields the template needs." },
315
+ { label: "Style", description: "Let the active design control type, color, and surfaces." },
316
+ { label: "QA", description: "Run contract and visual checks before export." },
317
+ ],
318
+ }),
319
+ fixture("recommendation-decision", {
320
+ title: "recommendation-decision",
321
+ recommendation: "Adopt page templates as the structural layer, with designs remaining user-customizable.",
322
+ image: "./assets/card-lens.jpg",
323
+ imageAlt: "Lucent design asset",
324
+ imageCaption: "Design asset example",
325
+ items: [
326
+ { label: "Rationale", description: "This keeps generation reliable while leaving style expressive and replaceable." },
327
+ ],
328
+ steps: [
329
+ { label: "Pilot", description: "Use the built-in preview to tune every template." },
330
+ { label: "Validate", description: "Promote only contracts that pass QA and browser review." },
331
+ { label: "Ship", description: "Document the add-slide workflow for agents." },
332
+ ],
333
+ }),
334
+ fixture("risks-tradeoffs", {
335
+ title: "risks-tradeoffs",
336
+ items: [
337
+ { label: "Constraint", description: "Templates can over-constrain if they become decorative presets instead of communication jobs.", image: "./assets/report-visual.jpg", imageAlt: "Report visual", imageCaption: "Design asset example" },
338
+ { label: "Mitigation", description: "Keep bounded HTML edit flow after scaffold insertion so agents can improve the page directly." },
339
+ { label: "Tradeoff", description: "More structure improves QA, but only if template contracts stay small and semantic." },
340
+ ],
341
+ }),
342
+ ]
343
+ }
344
+
345
+ export function renderBuiltInPreviewHtml(): string {
346
+ const fixtures = builtInPreviewFixtures()
347
+ const slides = fixtures
348
+ .map((item, index) => renderTemplateSlide({
349
+ templateId: item.templateId,
350
+ slideIndex: index + 1,
351
+ designName: "built-in-preview",
352
+ content: {
353
+ eyebrow: previewEyebrow(index + 1, fixtures.length),
354
+ ...item.content,
355
+ },
356
+ }).html)
357
+ .join("\n")
358
+ const html = `<!DOCTYPE html>
359
+ <html lang="en">
360
+ <head>
361
+ <meta charset="UTF-8">
362
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
363
+ <title>Revela Page Templates</title>
364
+ <link rel="stylesheet" href="./design.css">
365
+ </head>
366
+ <body>
367
+ <!-- revela-slides:start -->
368
+ ${slides}
369
+ <!-- revela-slides:end -->
370
+ ${slidePresentationRuntime()}
371
+ </body>
372
+ </html>
373
+ `
374
+ return ensureInlineLucideRuntime(html)
375
+ }
376
+
377
+ export function addTemplateSlide(input: AddTemplateSlideInput): AddTemplateSlideResult {
378
+ const outputPath = normalizeOutputPath(input.outputPath)
379
+ const targetPath = resolve(input.workspaceRoot, outputPath)
380
+ if (!existsSync(targetPath)) throw new Error(`Deck HTML does not exist: ${outputPath}. Create the deck foundation before adding template slides.`)
381
+ const rendered = renderTemplateSlide(input)
382
+ const html = readFileSync(targetPath, "utf-8")
383
+ const markers = deckFoundationMarkers()
384
+ if (!html.includes(markers.start) || !html.includes(markers.end)) throw new Error(`Deck HTML is missing Revela slide markers: ${outputPath}`)
385
+ const withSlide = html.replace(markers.end, `${rendered.html}\n ${markers.end}`)
386
+ const next = rendered.html.includes("data-lucide=") ? ensureInlineLucideRuntime(withSlide) : withSlide
387
+ writeFileSync(targetPath, next, "utf-8")
388
+ return { ...rendered, outputPath, inserted: true }
389
+ }
390
+
391
+ export function renderTemplateScaffold(input: RenderTemplateScaffoldInput): RenderTemplateScaffoldResult {
392
+ const seed = scaffoldSeed(input.templateId, input.seed ?? {})
393
+ const rendered = renderTemplateSlide({
394
+ templateId: input.templateId,
395
+ slideIndex: input.slideIndex,
396
+ content: seed,
397
+ designName: input.designName,
398
+ })
399
+ return { ...rendered, scaffold: true }
400
+ }
401
+
402
+ export function addTemplateScaffold(input: AddTemplateScaffoldInput): AddTemplateScaffoldResult {
403
+ const outputPath = normalizeOutputPath(input.outputPath)
404
+ const targetPath = resolve(input.workspaceRoot, outputPath)
405
+ if (!existsSync(targetPath)) throw new Error(`Deck HTML does not exist: ${outputPath}. Create the deck foundation before adding template slides.`)
406
+ const rendered = renderTemplateScaffold(input)
407
+ const html = readFileSync(targetPath, "utf-8")
408
+ const markers = deckFoundationMarkers()
409
+ if (!html.includes(markers.start) || !html.includes(markers.end)) throw new Error(`Deck HTML is missing Revela slide markers: ${outputPath}`)
410
+ const withSlide = html.replace(markers.end, `${rendered.html}\n ${markers.end}`)
411
+ const next = rendered.html.includes("data-lucide=") ? ensureInlineLucideRuntime(withSlide) : withSlide
412
+ writeFileSync(targetPath, next, "utf-8")
413
+ return { ...rendered, outputPath, inserted: true }
414
+ }
415
+
416
+ function deckFoundationMarkers(): { start: string; end: string } {
417
+ return { start: "<!-- revela-slides:start -->", end: "<!-- revela-slides:end -->" }
418
+ }
419
+
420
+ function fixture(templateId: string, content: Record<string, any>): BuiltInPreviewFixture {
421
+ return { templateId, content }
422
+ }
423
+
424
+ function previewEyebrow(slideIndex: number, total: number): string {
425
+ const index = String(slideIndex).padStart(2, "0")
426
+ const count = String(total).padStart(2, "0")
427
+ return `Template ${index} / ${count}`
428
+ }
429
+
430
+ function normalizeOutputPath(outputPath: string): string {
431
+ const trimmed = String(outputPath || "").trim()
432
+ if (!trimmed) throw new Error("outputPath is required")
433
+ if (!trimmed.endsWith(".html")) throw new Error("Deck outputPath must end in .html")
434
+ if (isAbsolute(trimmed)) throw new Error("Deck outputPath must be workspace-relative")
435
+ const segments = trimmed.split(/[\\/]+/)
436
+ if (segments.includes("..")) throw new Error("Deck outputPath must not contain parent-directory traversal")
437
+ return normalize(trimmed).replace(/\\/g, "/")
438
+ }
439
+
440
+ export function templateDeckCss(input: { designName?: string; designAssetBasePath?: string } = {}): string {
441
+ const designName = input.designName || "lucent"
442
+ const assetBasePath = input.designAssetBasePath
443
+ const lucentCoverBackground = designName === "lucent" && assetBasePath ? cssUrl(`${assetBasePath}/cover-background.jpg`) : ""
444
+ const lucentClosingBackground = designName === "lucent" && assetBasePath ? cssUrl(`${assetBasePath}/closing-background.jpg`) : ""
445
+ const lucentCoverBackgroundCss = lucentCoverBackground ? `
446
+ .template-slide[data-design="lucent"][data-template="cover"] .slide-canvas {
447
+ background:
448
+ linear-gradient(90deg, rgba(7,17,31,0.82), rgba(7,17,31,0.42) 52%, rgba(7,17,31,0.24)),
449
+ url("${lucentCoverBackground}") center center / cover no-repeat;
450
+ }
451
+ .template-slide[data-design="lucent"][data-template="agenda"] .slide-canvas {
452
+ background:
453
+ linear-gradient(90deg, rgba(7,17,31,0.86), rgba(7,17,31,0.58) 52%, rgba(7,17,31,0.32)),
454
+ url("${lucentCoverBackground}") center center / cover no-repeat;
455
+ }
456
+ .template-slide[data-design="lucent"][data-template="section-divider"] .slide-canvas {
457
+ background:
458
+ linear-gradient(90deg, rgba(7,17,31,0.86), rgba(16,26,43,0.62) 58%, rgba(36,58,115,0.36)),
459
+ url("${lucentCoverBackground}") center center / cover no-repeat;
460
+ }` : ""
461
+ const lucentClosingBackgroundCss = lucentClosingBackground ? `
462
+ .template-slide[data-design="lucent"][data-template="closing"] .slide-canvas {
463
+ background:
464
+ linear-gradient(90deg, rgba(7,17,31,0.82), rgba(49,94,234,0.42) 58%, rgba(24,168,216,0.24)),
465
+ url("${lucentClosingBackground}") center center / cover no-repeat;
466
+ }` : ""
467
+ return `
468
+ * { box-sizing: border-box; }
469
+ html { scroll-snap-type: y mandatory; overflow-y: scroll; height: 100%; }
470
+ body { margin: 0; background: var(--bg-frame, #07111f); color: var(--text-primary, #101a2b); font-family: var(--font-body, Arial, sans-serif); -webkit-font-smoothing: antialiased; }
471
+ .slide { min-height: 100dvh; scroll-snap-align: start; display: flex; align-items: center; justify-content: center; overflow: hidden; background: var(--bg-frame, #07111f); }
472
+ .slide-canvas { width: 1920px; height: 1080px; flex-shrink: 0; transform-origin: center center; position: relative; overflow: hidden; }
473
+ .template-slide .slide-canvas {
474
+ background:
475
+ radial-gradient(circle at 82% 16%, rgba(49, 94, 234, 0.11), transparent 29%),
476
+ linear-gradient(135deg, var(--bg-page), var(--bg-page-alt));
477
+ color: var(--text-primary);
478
+ padding: 72px;
479
+ box-sizing: border-box;
480
+ }
481
+ .template-frame { width: 100%; height: 100%; display: flex; flex-direction: column; gap: 34px; }
482
+ .template-frame--catalog { gap: 26px; }
483
+ .template-eyebrow { margin: 0 0 14px; font-size: 16px; text-transform: uppercase; letter-spacing: 0.18em; color: var(--text-muted); font-weight: 700; }
484
+ .template-frame header { flex: 0 0 auto; padding-bottom: 8px; overflow: visible; }
485
+ .template-title { margin: 0; max-width: 1320px; font-family: var(--font-display); font-size: 62px; line-height: 1.22; color: var(--text-primary); padding-bottom: 6px; overflow: visible; }
486
+ .template-body { flex: 1; min-height: 0; }
487
+ .template-grid { display: grid; gap: 24px; height: 100%; }
488
+ .template-grid.cols-2 { grid-template-columns: 0.95fr 1.05fr; }
489
+ .template-grid.cols-3 { grid-template-columns: repeat(3, 1fr); }
490
+ .template-grid.cols-4 { grid-template-columns: repeat(4, 1fr); }
491
+ .template-chart-layout { grid-template-columns: 2fr 1fr; }
492
+ .template-card { background: rgba(255,255,255,0.82); border: 1px solid var(--line); border-radius: var(--surface-radius); padding: 28px; box-shadow: 0 18px 44px var(--shadow-soft); }
493
+ .template-card h2, .template-card h3 { margin: 0 0 12px; font-size: 28px; line-height: 1.32; padding-bottom: 4px; overflow: visible; }
494
+ .template-card p { margin: 10px 0; font-size: 21px; line-height: 1.42; color: var(--text-secondary); }
495
+ .template-key-message-panel { display: flex; flex-direction: column; justify-content: flex-start; gap: 24px; padding: 0; background: transparent; border-radius: 0; box-shadow: none; }
496
+ .template-key-message-kicker { margin: 0; max-width: 720px; font-size: 32px; line-height: 1.24; letter-spacing: 0.14em; text-transform: uppercase; color: var(--accent-primary); font-weight: 800; padding-bottom: 6px; overflow: visible; }
497
+ .template-key-message-panel p { margin: 0; max-width: 760px; font-size: 25px; line-height: 1.5; color: var(--text-secondary); }
498
+ .template-evidence-grid { display: grid; gap: 24px; min-height: 0; }
499
+ .template-evidence-card { min-height: 0; }
500
+ .template-claim-text-panel { min-height: 0; display: flex; flex-direction: column; justify-content: flex-start; align-items: flex-start; gap: 18px; padding: 0; background: transparent; border: 0; border-radius: 0; box-shadow: none; }
501
+ .template-claim-text-title { margin: 0; max-width: 760px; font-size: 31px; line-height: 1.26; color: var(--text-primary); padding-bottom: 4px; overflow: visible; }
502
+ .template-claim-text-body { margin: 0; max-width: 760px; font-size: 22px; line-height: 1.48; color: var(--text-secondary); }
503
+ .template-claim-text-panel .template-list { margin-top: 4px; }
504
+ .template-list { display: grid; gap: 18px; margin: 0; padding: 0; list-style: none; }
505
+ .template-list li { position: relative; padding-left: 24px; font-size: 24px; line-height: 1.38; color: var(--text-secondary); }
506
+ .template-list li::before { content: ""; position: absolute; left: 0; top: 14px; width: 7px; height: 7px; background: var(--accent-primary); }
507
+ .template-hero { margin: 0; max-width: none; justify-content: center; align-items: flex-start; }
508
+ .template-hero > [data-template-slot="hero"] { width: 100%; }
509
+ .template-hero header { padding-bottom: 0; }
510
+ .template-hero-title { font-size: 120px; line-height: 1.18; color: white; font-weight: 800; opacity: 0.8; padding: 12px 0 20px; max-width: 1320px; }
511
+ .template-hero .template-eyebrow { color: rgba(255,255,255,0.78); }
512
+ .template-hero--cover, .template-hero--section-divider { justify-content: center; align-items: flex-start; }
513
+ .template-hero--closing { justify-content: flex-end; align-items: flex-end; }
514
+ .template-hero--closing > [data-template-slot="hero"] { display: flex; justify-content: flex-end; text-align: right; }
515
+ .template-hero--closing .template-hero-title { max-width: 1120px; }
516
+ .template-slide[data-template="agenda"] .template-frame { display: grid; grid-template-rows: 1fr auto; gap: 28px; }
517
+ .template-slide[data-template="cover"] .slide-canvas,
518
+ .template-slide[data-template="section-divider"] .slide-canvas,
519
+ .template-slide[data-template="closing"] .slide-canvas {
520
+ background:
521
+ radial-gradient(circle at 80% 14%, rgba(24,168,216,0.32), transparent 28%),
522
+ linear-gradient(135deg, #07111f, #101a2b 62%, #243a73);
523
+ }
524
+ ${lucentCoverBackgroundCss}
525
+ .template-slide[data-template="closing"] .slide-canvas { background: linear-gradient(135deg, #07111f, #315eea 58%, #18a8d8); }
526
+ ${lucentClosingBackgroundCss}
527
+ .template-agenda-panel { height: 100%; min-height: 0; display: flex; overflow: hidden; color: white; }
528
+ .template-agenda-inner { width: 100%; display: grid; grid-template-columns: 37% minmax(0, 1fr); align-items: stretch; gap: 76px; }
529
+ .template-agenda-header { display: flex; flex-direction: column; min-height: 0; padding: 10px 0 0; }
530
+ .template-agenda-header .template-eyebrow { color: rgba(255,255,255,0.64); }
531
+ .template-agenda-header .template-title { max-width: 440px; font-size: 54px; line-height: 1.16; letter-spacing: 0; text-transform: uppercase; color: white; padding-bottom: 8px; overflow: visible; }
532
+ .template-agenda-footer { margin: auto 0 0; font-size: 13px; line-height: 1.4; letter-spacing: 0.12em; text-transform: uppercase; font-weight: 800; color: rgba(255,255,255,0.84); }
533
+ .template-agenda-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; justify-content: center; gap: 40px; height: 100%; }
534
+ .template-agenda-item { display: grid; grid-template-columns: 86px minmax(0, 1fr); gap: 44px; align-items: center; min-height: 58px; overflow: visible; }
535
+ .template-agenda-item span { font-family: var(--font-display); font-size: 44px; line-height: 1; letter-spacing: 0.03em; color: var(--accent-cyan, #18a8d8); font-weight: 800; font-variant-numeric: tabular-nums; }
536
+ .template-agenda-item strong { font-size: 18px; line-height: 1.45; letter-spacing: 0.1em; text-transform: uppercase; font-weight: 700; color: rgba(255,255,255,0.92); }
537
+ .template-metric-layout { height: 100%; min-height: 0; display: grid; gap: 26px; }
538
+ .template-metric-layout--insight-top { grid-template-rows: auto minmax(0, 1fr); }
539
+ .template-metric-layout--insight-bottom { grid-template-rows: minmax(0, 1fr) auto; }
540
+ .template-stat-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 24px; align-items: stretch; }
541
+ .template-stat-value { display: block; min-height: 96px; font-size: 58px; line-height: 1.42; color: var(--accent-primary); font-weight: 800; margin-bottom: 18px; padding-bottom: 14px; overflow: visible; }
542
+ .template-chart-panel { min-height: 520px; display: grid; place-items: center; border: 1px solid var(--line); background: rgba(255,255,255,0.72); }
543
+ .template-chart-placeholder { width: 76%; height: 56%; border-left: 2px solid var(--line-strong); border-bottom: 2px solid var(--line-strong); display: flex; align-items: end; gap: 28px; padding: 0 28px 24px; }
544
+ .template-visual-slot-panel { width: 100%; min-height: 520px; border: 1px dashed var(--line-strong); border-radius: var(--surface-radius); background: linear-gradient(135deg, rgba(49,94,234,0.08), rgba(24,168,216,0.08)); display: grid; place-items: center; padding: 0; }
545
+ .template-visual-slot-label { font-size: 13px; line-height: 1.35; letter-spacing: 0.1em; text-transform: uppercase; color: var(--text-muted); font-weight: 800; }
546
+ .template-text-panel.template-chart-takeaway-panel { gap: 28px; background: linear-gradient(135deg, #5f82c8 0%, var(--accent-primary) 58%, #18a8d8 115%); color: white; box-shadow: 0 22px 56px rgba(49,94,234,0.24); }
547
+ .template-chart-takeaway-panel .template-text-panel-title { color: white; }
548
+ .template-chart-takeaway-list { display: grid; gap: 22px; width: 100%; }
549
+ .template-chart-takeaway-item { display: grid; gap: 7px; padding-top: 18px; border-top: 1px solid rgba(255,255,255,0.24); }
550
+ .template-chart-takeaway-item:first-child { padding-top: 0; border-top: 0; }
551
+ .template-chart-takeaway-item h3 { margin: 0; font-size: 25px; line-height: 1.24; color: white; }
552
+ .template-chart-takeaway-item p { margin: 0; font-size: 20px; line-height: 1.46; color: rgba(255,255,255,0.78); }
553
+ .template-bar { flex: 1; background: linear-gradient(180deg, var(--accent-primary), var(--accent-cyan)); min-height: 80px; }
554
+ .template-table-wrap { display: grid; grid-template-rows: minmax(0, auto) auto; gap: 22px; height: 100%; align-content: start; }
555
+ .template-table { width: 100%; border-collapse: collapse; background: rgba(255,255,255,0.86); box-shadow: 0 18px 44px var(--shadow-soft); }
556
+ .template-table th, .template-table td { padding: 22px 24px; border-bottom: 1px solid var(--line); text-align: left; font-size: 21px; }
557
+ .template-table th { color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.12em; font-size: 15px; }
558
+ .template-text-panel { min-height: 0; display: flex; flex-direction: column; justify-content: flex-start; align-items: flex-start; gap: 20px; background: rgba(255,255,255,0.74); border-radius: var(--surface-radius); padding: 42px; }
559
+ .template-text-panel-title { margin: 0; font-size: 34px; line-height: 1.28; color: var(--text-primary); padding-bottom: 4px; overflow: visible; }
560
+ .template-text-panel-body { margin: 0; font-size: 23px; line-height: 1.52; color: var(--text-secondary); }
561
+ .template-side-panel { align-self: stretch; }
562
+ .template-side-panel-title { margin: 0; }
563
+ .template-side-panel-body { margin: 0; }
564
+ .template-insight-panel { display: grid; gap: 10px; background: rgba(255,255,255,0.88); border: 1px solid var(--line); border-radius: var(--surface-radius); padding: 22px 24px; box-shadow: 0 14px 34px var(--shadow-soft); }
565
+ .template-insight-title { margin: 0; display: flex; align-items: center; gap: 12px; font-size: 24px; line-height: 1.24; color: var(--text-primary); }
566
+ .template-insight-icon { width: 24px; height: 24px; color: var(--accent-primary); stroke-width: 2.2; flex: 0 0 auto; }
567
+ .template-insight-body { margin: 0; font-size: 20px; line-height: 1.42; color: var(--text-secondary); }
568
+ .template-metric-layout .template-insight-panel { border: 0; box-shadow: none; background: rgba(255,255,255,0.74); padding: 24px 28px; }
569
+ .template-metric-layout .template-insight-title { font-size: 18px; line-height: 1.22; letter-spacing: 0.04em; text-transform: uppercase; color: var(--text-muted); }
570
+ .template-metric-layout .template-insight-icon { width: 20px; height: 20px; }
571
+ .template-metric-layout .template-insight-body { font-size: 24px; line-height: 1.42; color: var(--text-primary); }
572
+ .template-timeline { position: relative; height: 100%; display: grid; align-items: center; }
573
+ .template-timeline-layout { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 2fr); gap: 34px; height: 100%; align-items: stretch; }
574
+ .template-timeline-layout--left { grid-template-columns: minmax(0, 1fr) minmax(0, 2fr); }
575
+ .template-timeline-layout--left .template-side-panel { grid-column: 1; grid-row: 1; }
576
+ .template-timeline-layout--left .template-timeline { grid-column: 2; grid-row: 1; }
577
+ .template-timeline-layout--right { grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); }
578
+ .template-timeline-layout--right .template-timeline { grid-column: 1; grid-row: 1; }
579
+ .template-timeline-layout--right .template-side-panel { grid-column: 2; grid-row: 1; }
580
+ .template-timeline-layout .template-text-panel { background: linear-gradient(135deg, #7a7fe8 0%, #5f82c8 58%, #315eea 115%); color: white; box-shadow: 0 22px 56px rgba(49,94,234,0.22); }
581
+ .template-timeline-layout .template-text-panel-title { color: white; }
582
+ .template-timeline-layout .template-text-panel-body { color: rgba(255,255,255,0.78); }
583
+ .template-timeline--horizontal { grid-template-columns: repeat(var(--timeline-count), 1fr); column-gap: 18px; align-items: stretch; --timeline-axis-y: 86%; }
584
+ .template-timeline--horizontal::before { content: ""; position: absolute; left: 4%; right: 4%; top: var(--timeline-axis-y); border-top: 2px solid var(--line-strong); transform: translateY(-1px); }
585
+ .template-timeline-item { position: relative; min-height: 400px; display: grid; justify-items: center; align-items: center; }
586
+ .template-timeline--horizontal .template-timeline-item { grid-template-rows: minmax(0, 1fr) 42px 86px; align-items: stretch; }
587
+ .template-timeline-dot { z-index: 2; width: 22px; height: 22px; border-radius: 999px; background: var(--accent-primary); box-shadow: 0 0 0 8px rgba(49,94,234,0.12); }
588
+ .template-timeline-copy { z-index: 2; width: 86%; padding: 18px 4px; background: transparent; border: 0; box-shadow: none; }
589
+ .template-timeline--horizontal .template-timeline-copy.template-card { width: 94%; height: calc(100% - 55px); min-height: 254px; align-self: end; justify-self: center; display: flex; flex-direction: column; justify-content: flex-start; padding: 28px 24px 24px; margin-bottom: 22px; }
590
+ .template-timeline--horizontal .template-timeline-item--highlight .template-timeline-copy.template-card { height: calc(100% - 22px); min-height: 254px; }
591
+ .template-timeline--horizontal .template-insight-icon { width: 28px; height: 28px; margin: 0 auto 28px; color: var(--accent-primary); stroke-width: 2; flex: 0 0 auto; }
592
+ .template-timeline--horizontal .template-timeline-item--highlight .template-insight-icon { color: #f37021; }
593
+ .template-timeline--horizontal .template-timeline-item--highlight .template-timeline-copy h3 { color: #f37021; }
594
+ .template-timeline--horizontal .template-timeline-dot { grid-row: 2; align-self: center; justify-self: center; }
595
+ .template-timeline--horizontal .template-timeline-date { grid-row: 3; align-self: start; justify-self: center; margin: 8px 0 0; font-size: 38px; line-height: 1; font-weight: 300; letter-spacing: 0.03em; color: var(--text-muted); }
596
+ .template-timeline-date { margin: 0 0 8px; font-size: 15px; text-transform: uppercase; letter-spacing: 0.14em; color: var(--accent-primary); font-weight: 800; }
597
+ .template-timeline-copy h3 { margin: 0 0 8px; font-size: 27px; line-height: 1.28; padding-bottom: 4px; overflow: visible; }
598
+ .template-timeline-copy p:last-child { margin: 0; font-size: 19px; color: var(--text-secondary); }
599
+ .template-timeline--vertical { grid-template-columns: 1fr; align-items: stretch; padding: 18px 0; }
600
+ .template-timeline--vertical::before { content: ""; position: absolute; top: 0; bottom: 0; left: 50%; border-left: 2px solid var(--line-strong); }
601
+ .template-timeline--vertical .template-timeline-item { min-height: 128px; grid-template-columns: 1fr 56px 1fr; justify-items: stretch; }
602
+ .template-timeline--vertical .template-timeline-dot { grid-column: 2; grid-row: 1; justify-self: center; align-self: center; }
603
+ .template-timeline--vertical .template-timeline-copy { grid-row: 1; width: auto; align-self: center; background: transparent; border: 0; box-shadow: none; }
604
+ .template-timeline--vertical .template-timeline-item:nth-child(odd) .template-timeline-copy { grid-column: 1; text-align: right; align-self: center; }
605
+ .template-timeline--vertical .template-timeline-item:nth-child(even) .template-timeline-copy { grid-column: 3; text-align: left; align-self: center; }
606
+ .template-steps { display: grid; grid-template-columns: repeat(4, 1fr); gap: 22px; }
607
+ .template-step-number { font-size: 48px; color: var(--accent-primary); font-weight: 800; margin-bottom: 30px; }
608
+ .template-image-card { width: 100%; margin: 18px 0 0; display: grid; gap: 8px; }
609
+ .template-image-frame { width: 100%; height: 128px; border-radius: var(--surface-radius); overflow: hidden; background: var(--surface-tint, #f1f6fc); border: 1px solid var(--line); }
610
+ .template-image-frame img { display: block; width: 100%; height: 100%; object-fit: cover; }
611
+ .template-image-caption { margin: 0; font-size: 13px; line-height: 1.35; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.08em; }
612
+ .template-visual-placeholder { width: 100%; margin: 18px 0 0; display: grid; gap: 8px; }
613
+ .template-visual-placeholder-frame { width: 100%; height: 148px; border-radius: var(--surface-radius); border: 1px dashed var(--line-strong); background: linear-gradient(135deg, rgba(49,94,234,0.08), rgba(24,168,216,0.08)); display: grid; place-items: center; }
614
+ .template-visual-placeholder-label { font-size: 13px; line-height: 1.35; letter-spacing: 0.1em; text-transform: uppercase; color: var(--text-muted); font-weight: 800; }
615
+ .template-page-number { position: absolute; right: 72px; bottom: 52px; font-size: 15px; color: var(--text-muted); letter-spacing: 0.18em; }
616
+ .template-catalog-panel { flex: 0 0 auto; margin-top: auto; background: rgba(255,255,255,0.9); border: 1px solid var(--line); border-radius: var(--surface-radius); box-shadow: 0 18px 44px var(--shadow-soft); padding: 16px 22px; color: var(--text-primary); }
617
+ .template-hero .template-catalog-panel { background: rgba(247,249,252,0.92); }
618
+ .template-catalog-kicker { margin: 0 0 4px; font-size: 12px; text-transform: uppercase; letter-spacing: 0.16em; color: var(--accent-primary); font-weight: 800; }
619
+ .template-catalog-title { margin: 0 0 10px; font-size: 20px; line-height: 1.28; font-weight: 800; }
620
+ .template-catalog-grid { display: grid; grid-template-columns: 1.15fr 1fr 1fr; gap: 16px; }
621
+ .template-catalog-section { min-width: 0; }
622
+ .template-catalog-section h3 { margin: 0 0 6px; font-size: 13px; text-transform: uppercase; letter-spacing: 0.12em; color: var(--text-muted); }
623
+ .template-catalog-section p { margin: 0; font-size: 15px; line-height: 1.36; color: var(--text-secondary); }
624
+ .template-catalog-list { margin: 0; padding-left: 16px; display: grid; gap: 2px; }
625
+ .template-catalog-list li { font-size: 14px; line-height: 1.3; color: var(--text-secondary); }
626
+ .template-frame--catalog .template-title { font-size: 52px; line-height: 1.18; }
627
+ .template-slide[data-template="agenda"] .template-frame--catalog .template-title { font-size: 54px; line-height: 1.04; }
628
+ .template-frame--catalog .template-card { padding: 22px; }
629
+ .template-frame--catalog .template-card h2,
630
+ .template-frame--catalog .template-card h3 { font-size: 24px; line-height: 1.22; margin-bottom: 8px; }
631
+ .template-frame--catalog .template-card p { font-size: 18px; line-height: 1.32; }
632
+ .template-frame--catalog .template-key-message-panel { gap: 16px; }
633
+ .template-frame--catalog .template-key-message-kicker { font-size: 23px; line-height: 1.2; }
634
+ .template-frame--catalog .template-key-message-panel p { font-size: 19px; line-height: 1.42; }
635
+ .template-frame--catalog .template-claim-text-panel { gap: 12px; }
636
+ .template-frame--catalog .template-claim-text-title { font-size: 24px; line-height: 1.24; }
637
+ .template-frame--catalog .template-claim-text-body { font-size: 18px; line-height: 1.36; }
638
+ .template-frame--catalog .template-evidence-grid { gap: 18px; }
639
+ .template-frame--catalog .template-list { gap: 12px; }
640
+ .template-frame--catalog .template-list li { font-size: 20px; line-height: 1.28; }
641
+ .template-frame--catalog .template-metric-layout { gap: 18px; }
642
+ .template-frame--catalog .template-metric-layout .template-card { padding: 20px; }
643
+ .template-frame--catalog .template-metric-layout .template-stat-value { min-height: 70px; font-size: 48px; line-height: 1.24; margin-bottom: 10px; padding-bottom: 8px; }
644
+ .template-frame--catalog .template-metric-layout .template-insight-panel { padding: 18px 22px; gap: 7px; }
645
+ .template-frame--catalog .template-metric-layout .template-insight-title { font-size: 13px; line-height: 1.22; }
646
+ .template-frame--catalog .template-metric-layout .template-insight-icon { width: 16px; height: 16px; }
647
+ .template-frame--catalog .template-metric-layout .template-insight-body { font-size: 19px; line-height: 1.34; }
648
+ .template-frame--catalog .template-chart-panel { min-height: 360px; }
649
+ .template-frame--catalog .template-visual-slot-panel { min-height: 360px; }
650
+ .template-frame--catalog .template-visual-slot-label { font-size: 11px; }
651
+ .template-frame--catalog .template-chart-takeaway-panel { padding: 24px; gap: 16px; }
652
+ .template-frame--catalog .template-chart-takeaway-list { gap: 13px; }
653
+ .template-frame--catalog .template-chart-takeaway-item { gap: 4px; padding-top: 11px; }
654
+ .template-frame--catalog .template-chart-takeaway-item h3 { font-size: 19px; line-height: 1.2; }
655
+ .template-frame--catalog .template-chart-takeaway-item p { font-size: 15px; line-height: 1.3; }
656
+ .template-frame--catalog .template-table-wrap { gap: 16px; }
657
+ .template-frame--catalog .template-table th,
658
+ .template-frame--catalog .template-table td { padding: 14px 18px; font-size: 17px; line-height: 1.32; }
659
+ .template-frame--catalog .template-table th { font-size: 12px; }
660
+ .template-frame--catalog .template-insight-panel { padding: 16px 18px; gap: 6px; }
661
+ .template-frame--catalog .template-insight-title { font-size: 20px; line-height: 1.2; }
662
+ .template-frame--catalog .template-insight-icon { width: 20px; height: 20px; }
663
+ .template-frame--catalog .template-insight-body { font-size: 16px; line-height: 1.32; }
664
+ .template-frame--catalog .template-timeline--vertical { padding: 6px 0; }
665
+ .template-frame--catalog .template-timeline--vertical .template-timeline-item { min-height: 96px; }
666
+ .template-frame--catalog .template-timeline-layout { grid-template-columns: minmax(0, 1fr) minmax(0, 2fr); gap: 22px; }
667
+ .template-frame--catalog .template-timeline-layout--left { grid-template-columns: minmax(0, 1fr) minmax(0, 2fr); }
668
+ .template-frame--catalog .template-timeline-layout--right { grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); }
669
+ .template-frame--catalog .template-timeline-layout .template-text-panel { padding: 22px; gap: 10px; }
670
+ .template-frame--catalog .template-timeline-layout .template-text-panel-title { font-size: 25px; line-height: 1.3; }
671
+ .template-frame--catalog .template-timeline-layout .template-text-panel-body { font-size: 18px; line-height: 1.4; }
672
+ .template-frame--catalog .template-timeline--horizontal .template-timeline-item { grid-template-rows: minmax(0, 1fr) 32px 58px; min-height: 330px; }
673
+ .template-frame--catalog .template-timeline--horizontal .template-timeline-copy.template-card { height: calc(100% - 51px); min-height: 178px; padding: 20px 18px 18px; margin-bottom: 18px; }
674
+ .template-frame--catalog .template-timeline--horizontal .template-timeline-item--highlight .template-timeline-copy.template-card { height: calc(100% - 18px); min-height: 178px; }
675
+ .template-frame--catalog .template-timeline--horizontal .template-insight-icon { width: 22px; height: 22px; margin-bottom: 18px; }
676
+ .template-frame--catalog .template-timeline--horizontal .template-timeline-date { font-size: 28px; }
677
+ .template-frame--catalog .template-timeline-copy { padding: 8px 4px; }
678
+ .template-frame--catalog .template-timeline-copy h3 { font-size: 21px; line-height: 1.18; margin-bottom: 4px; }
679
+ .template-frame--catalog .template-timeline-date { font-size: 12px; margin-bottom: 4px; }
680
+ .template-frame--catalog .template-timeline-copy p:last-child { font-size: 15px; line-height: 1.24; }
681
+ .template-frame--catalog .template-steps { gap: 16px; }
682
+ .template-frame--catalog .template-step-number { font-size: 40px; margin-bottom: 20px; }
683
+ .template-frame--catalog .template-image-frame { height: 86px; }
684
+ .template-frame--catalog .template-image-caption { font-size: 11px; }
685
+ .template-frame--catalog .template-visual-placeholder-frame { height: 110px; }
686
+ .template-frame--catalog .template-visual-placeholder-label { font-size: 11px; }
687
+ `
688
+ }
689
+
690
+ function ensureInlineLucideRuntime(html: string): string {
691
+ if (html.includes("function revelaRenderLucideIcons")) return html
692
+ const runtime = `<script>
693
+ function revelaRenderLucideIcons() {
694
+ var icons = {
695
+ lightbulb: '<svg xmlns="http://www.w3.org/2000/svg" class="template-insight-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 14c.2-1 .7-1.7 1.5-2.5A5.5 5.5 0 0 0 18 7.5C18 4.5 15.5 2 12 2S6 4.5 6 7.5c0 1.5.6 2.9 1.5 4 .8.8 1.3 1.5 1.5 2.5"/><path d="M9 18h6"/><path d="M10 22h4"/></svg>',
696
+ 'scan-search': '<svg xmlns="http://www.w3.org/2000/svg" class="template-insight-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M7 3H5a2 2 0 0 0-2 2v2"/><path d="M17 3h2a2 2 0 0 1 2 2v2"/><path d="M7 21H5a2 2 0 0 1-2-2v-2"/><path d="M14 14l4 4"/><circle cx="11" cy="11" r="3"/></svg>'
697
+ };
698
+ document.querySelectorAll("[data-lucide]").forEach(function (node) {
699
+ var name = node.getAttribute("data-lucide") || "lightbulb";
700
+ var svg = icons[name] || icons.lightbulb;
701
+ var wrapper = document.createElement("span");
702
+ wrapper.innerHTML = svg;
703
+ var next = wrapper.firstElementChild;
704
+ if (next) node.replaceWith(next);
705
+ });
706
+ }
707
+ revelaRenderLucideIcons();
708
+ </script>`
709
+ return html.replace("</body>", ` ${runtime}\n</body>`)
710
+ }
711
+
712
+ function slidePresentationRuntime(): string {
713
+ return ` <script>
714
+ class SlidePresentation {
715
+ constructor() {
716
+ this.slides = document.querySelectorAll('.slide');
717
+ this.currentSlide = 0;
718
+ this.setupScaling();
719
+ this.setupIntersectionObserver();
720
+ this.setupKeyboardNav();
721
+ this.setupTouchNav();
722
+ this.setupMouseWheel();
723
+ }
724
+ setupScaling() {
725
+ const canvases = document.querySelectorAll('.slide-canvas');
726
+ const BASE_W = 1920;
727
+ const BASE_H = 1080;
728
+ const update = () => {
729
+ const scale = Math.min(window.innerWidth / BASE_W, window.innerHeight / BASE_H);
730
+ canvases.forEach((canvas) => { canvas.style.transform = \`scale(\${scale})\`; });
731
+ };
732
+ window.addEventListener('resize', update);
733
+ update();
734
+ }
735
+ setupIntersectionObserver() {
736
+ const observer = new IntersectionObserver((entries) => {
737
+ entries.forEach((entry) => {
738
+ if (entry.isIntersecting) entry.target.querySelectorAll('.reveal').forEach((el) => el.classList.add('visible'));
739
+ });
740
+ }, { threshold: 0.2 });
741
+ this.slides.forEach((slide) => observer.observe(slide));
742
+ }
743
+ setupKeyboardNav() {
744
+ document.addEventListener('keydown', (event) => {
745
+ if (['ArrowDown', 'ArrowRight', ' ', 'PageDown'].includes(event.key)) { event.preventDefault(); this.goTo(this.currentSlide + 1); }
746
+ else if (['ArrowUp', 'ArrowLeft', 'PageUp'].includes(event.key)) { event.preventDefault(); this.goTo(this.currentSlide - 1); }
747
+ });
748
+ }
749
+ setupTouchNav() {
750
+ let startY = 0;
751
+ document.addEventListener('touchstart', (event) => { startY = event.touches[0].clientY; }, { passive: true });
752
+ document.addEventListener('touchend', (event) => {
753
+ const deltaY = startY - event.changedTouches[0].clientY;
754
+ if (Math.abs(deltaY) > 40) this.goTo(this.currentSlide + (deltaY > 0 ? 1 : -1));
755
+ }, { passive: true });
756
+ }
757
+ setupMouseWheel() {
758
+ let last = 0;
759
+ document.addEventListener('wheel', (event) => {
760
+ const now = Date.now();
761
+ if (now - last < 800) return;
762
+ last = now;
763
+ this.goTo(this.currentSlide + (event.deltaY > 0 ? 1 : -1));
764
+ }, { passive: true });
765
+ }
766
+ goTo(index) {
767
+ const clamped = Math.max(0, Math.min(this.slides.length - 1, index));
768
+ this.slides[clamped].scrollIntoView({ behavior: 'smooth' });
769
+ this.currentSlide = clamped;
770
+ }
771
+ }
772
+ if (document.querySelector(".slide")) { new SlidePresentation(); }
773
+ </script>`
774
+ }
775
+
776
+ function cssUrl(value: string): string {
777
+ return value.replace(/\\/g, "/").replace(/"/g, "%22").replace(/\n|\r|\f/g, "")
778
+ }
779
+
780
+ export function validatePageTemplateContracts(filePath: string): PageTemplateContractReport {
781
+ const html = readFileSync(filePath, "utf-8")
782
+ const issues: PageTemplateContractIssue[] = []
783
+ for (const section of html.matchAll(/<section\b[^>]*class=["'][^"']*\bslide\b[^"']*["'][^>]*data-template=["']([^"']+)["'][^>]*>([\s\S]*?)<\/section>/gi)) {
784
+ const templateId = section[1]
785
+ const body = section[2]
786
+ const slideIndex = Number(/data-slide-index=["'](\d+)["']/i.exec(section[0])?.[1])
787
+ issues.push(...validateVocabularyContract(templateId, body, Number.isInteger(slideIndex) ? slideIndex : undefined))
788
+ if (templateId === "timeline-roadmap") issues.push(...validateTimelineContract(body, Number.isInteger(slideIndex) ? slideIndex : undefined))
789
+ }
790
+ return { ok: !issues.some((issue) => issue.severity === "error"), issues }
791
+ }
792
+
793
+ export function formatPageTemplateContractReport(report: PageTemplateContractReport): string {
794
+ if (report.issues.length === 0) return "Template contracts passed."
795
+ return report.issues.map((issue) => `- ${issue.severity.toUpperCase()}: ${issue.templateId}${issue.slideIndex ? ` slide ${issue.slideIndex}` : ""}: ${issue.message}`).join("\n")
796
+ }
797
+
798
+ export function validateBoundedTemplateEdit(input: BoundedTemplateEditInput): PageTemplateContractReport {
799
+ const issues: PageTemplateContractIssue[] = []
800
+ const slideIndex = positiveIndex(input.slideIndex)
801
+ const beforeSlides = slideSections(input.beforeHtml)
802
+ const afterSlides = slideSections(input.afterHtml)
803
+ const beforeKeys = [...beforeSlides.keys()].sort((a, b) => a - b)
804
+ const afterKeys = [...afterSlides.keys()].sort((a, b) => a - b)
805
+ if (beforeKeys.join(",") !== afterKeys.join(",")) {
806
+ issues.push({ severity: "error", templateId: "bounded-edit", message: "Bounded edit must preserve the slide index set." })
807
+ }
808
+ for (const index of afterKeys) {
809
+ if (index === slideIndex) continue
810
+ if (beforeSlides.get(index) !== afterSlides.get(index)) issues.push({ severity: "error", templateId: "bounded-edit", slideIndex: index, message: "Bounded edit changed a slide outside the target slide." })
811
+ }
812
+ const target = afterSlides.get(slideIndex)
813
+ if (!target) {
814
+ issues.push({ severity: "error", templateId: "bounded-edit", slideIndex, message: "Bounded edit target slide is missing." })
815
+ } else {
816
+ const templateId = /data-template=["']([^"']+)["']/i.exec(target)?.[1] || "unknown"
817
+ issues.push(...validateVocabularyContract(templateId, target, slideIndex))
818
+ if (templateId === "timeline-roadmap") issues.push(...validateTimelineContract(target, slideIndex))
819
+ }
820
+ return { ok: !issues.some((issue) => issue.severity === "error"), issues }
821
+ }
822
+
823
+ function define(id: string, title: string, purpose: string, fields: PageTemplateField[], contentRules: string[], qaRules: string[]): PageTemplateDefinition {
824
+ return { id, title, purpose, status: "renderable", fields, contentRules, qaRules }
825
+ }
826
+
827
+ function validateTimelineContract(html: string, slideIndex?: number): PageTemplateContractIssue[] {
828
+ const issues: PageTemplateContractIssue[] = []
829
+ const root = /class=["'][^"']*\btemplate-timeline\b[^"']*["']/i.test(html)
830
+ if (!root) {
831
+ issues.push({ severity: "error", templateId: "timeline-roadmap", slideIndex, message: "Missing .template-timeline root." })
832
+ return issues
833
+ }
834
+ const itemMatches = [...html.matchAll(/<article\b[^>]*class=["'][^"']*\btemplate-timeline-item\b[^"']*["'][^>]*>([\s\S]*?)<\/article>/gi)]
835
+ if (itemMatches.length < 3) issues.push({ severity: "warning", templateId: "timeline-roadmap", slideIndex, message: "Timeline should usually contain at least three milestones." })
836
+ for (let index = 0; index < itemMatches.length; index++) {
837
+ const item = itemMatches[index][1]
838
+ if (!/class=["'][^"']*\btemplate-timeline-dot\b[^"']*["']/i.test(item)) issues.push({ severity: "error", templateId: "timeline-roadmap", slideIndex, message: `Milestone ${index + 1} is missing .template-timeline-dot inside its item.` })
839
+ if (!/class=["'][^"']*\btemplate-timeline-copy\b[^"']*["']/i.test(item)) issues.push({ severity: "error", templateId: "timeline-roadmap", slideIndex, message: `Milestone ${index + 1} is missing .template-timeline-copy inside its item.` })
840
+ }
841
+ const dotCount = (html.match(/\btemplate-timeline-dot\b/g) ?? []).length
842
+ const copyCount = (html.match(/\btemplate-timeline-copy\b/g) ?? []).length
843
+ if (dotCount !== copyCount) issues.push({ severity: "error", templateId: "timeline-roadmap", slideIndex, message: `Timeline dot count (${dotCount}) must match copy count (${copyCount}).` })
844
+ return issues
845
+ }
846
+
847
+ function validateVocabularyContract(templateId: string, html: string, slideIndex?: number): PageTemplateContractIssue[] {
848
+ const issues: PageTemplateContractIssue[] = []
849
+ let vocabulary
850
+ try {
851
+ vocabulary = getPageTemplateVocabulary(templateId)
852
+ } catch {
853
+ return issues
854
+ }
855
+ for (const className of vocabulary.requiredClasses) {
856
+ if (!hasClass(html, className)) issues.push({ severity: "error", templateId, slideIndex, message: `Missing required template class .${className}.` })
857
+ }
858
+ for (const slot of vocabulary.slots.filter((item) => item.required)) {
859
+ if (!hasTemplateSlot(html, slot.name)) issues.push({ severity: "error", templateId, slideIndex, message: `Missing required template slot '${slot.name}'.` })
860
+ }
861
+ for (const slot of vocabulary.slots.filter((item) => item.replaceable && item.name === "visual")) {
862
+ if (hasTemplateSlot(html, slot.name) && !hasVisualSemanticContainer(html)) issues.push({ severity: "error", templateId, slideIndex, message: "Visual slot must keep an image, chart, table, diagram, or template visual slot semantic container." })
863
+ }
864
+ return issues
865
+ }
866
+
867
+ function slideSections(html: string): Map<number, string> {
868
+ const sections = new Map<number, string>()
869
+ for (const match of html.matchAll(/<section\b[^>]*class=["'][^"']*\bslide\b[^"']*["'][^>]*data-slide-index=["'](\d+)["'][^>]*>[\s\S]*?<\/section>/gi)) {
870
+ sections.set(Number(match[1]), match[0])
871
+ }
872
+ return sections
873
+ }
874
+
875
+ function hasClass(html: string, className: string): boolean {
876
+ return new RegExp(`class=["'][^"']*\\b${escapeRegExp(className)}\\b[^"']*["']`, "i").test(html)
877
+ }
878
+
879
+ function hasTemplateSlot(html: string, slot: string): boolean {
880
+ return new RegExp(`data-template-slot=["']${escapeRegExp(slot)}["']`, "i").test(html)
881
+ }
882
+
883
+ function hasVisualSemanticContainer(html: string): boolean {
884
+ return /\b(template-visual-slot-panel|template-image-card|template-chart-panel|template-table|echart-panel|data-table|media-frame|<img\b|<svg\b|<canvas\b|<table\b)/i.test(html)
885
+ }
886
+
887
+ function field(name: string, type: PageTemplateField["type"], description: string, required = false): PageTemplateField {
888
+ return { name, type, description, required }
889
+ }
890
+
891
+ function getPageTemplate(templateId: string): PageTemplateDefinition {
892
+ const id = String(templateId || "").trim()
893
+ const template = templates.find((item) => item.id === id)
894
+ if (!template) throw new Error(`Unknown page template: ${templateId}`)
895
+ return template
896
+ }
897
+
898
+ function renderSlideShell(input: { template: PageTemplateDefinition; slideIndex: number; designName: string; title: string; body: string; catalog?: any }): string {
899
+ const hero = ["cover", "section-divider", "closing"].includes(input.template.id)
900
+ const slideQa = hero ? "false" : "true"
901
+ const slideRole = input.template.id === "cover" || input.template.id === "closing" ? ` data-slide-role="${input.template.id}"` : ""
902
+ const hasCatalog = Boolean(input.catalog)
903
+ const heroModifier = hero ? ` template-hero--${input.template.id}` : ""
904
+ return ` <section class="slide template-slide" slide-qa="${slideQa}" data-slide-index="${input.slideIndex}"${slideRole} data-design="${escapeAttribute(input.designName)}" data-template="${escapeAttribute(input.template.id)}">
905
+ <div class="slide-canvas">
906
+ <div class="template-frame${hero ? " template-hero" : ""}${heroModifier}${hasCatalog ? " template-frame--catalog" : ""}">
907
+ ${input.body}
908
+ ${renderCatalogPanel(input.template, input.catalog)}
909
+ </div>
910
+ <div class="template-page-number">${String(input.slideIndex).padStart(2, "0")}</div>
911
+ </div>
912
+ </section>`
913
+ }
914
+
915
+ function renderCatalogPanel(template: PageTemplateDefinition, content: any): string {
916
+ if (!content || typeof content !== "object") return ""
917
+ const fields = Array.isArray(content.fields) ? content.fields : template.fields.filter((field) => field.required).map((field) => field.name)
918
+ const qa = Array.isArray(content.qa) ? content.qa : template.qaRules
919
+ return `<aside class="template-catalog-panel">
920
+ <p class="template-catalog-kicker">${escapeHtml(template.id)}</p>
921
+ <h2 class="template-catalog-title">${escapeHtml(content.title || template.title)}</h2>
922
+ <div class="template-catalog-grid">
923
+ <section class="template-catalog-section"><h3>Purpose</h3><p>${escapeHtml(content.purpose || template.purpose)}</p></section>
924
+ <section class="template-catalog-section"><h3>Fields</h3><ul class="template-catalog-list">${fields.slice(0, 5).map((item: any) => `<li>${escapeHtml(String(item))}</li>`).join("")}</ul></section>
925
+ <section class="template-catalog-section"><h3>QA</h3><ul class="template-catalog-list">${qa.slice(0, 3).map((item: any) => `<li>${escapeHtml(String(item))}</li>`).join("")}</ul></section>
926
+ </div>
927
+ </aside>`
928
+ }
929
+
930
+ function renderHeader(content: Record<string, any>, fallbackTitle = "", options: { hero?: boolean } = {}): string {
931
+ const eyebrow = stringValue(content.eyebrow)
932
+ const titleClass = options.hero ? "template-title template-hero-title" : "template-title"
933
+ return `<header>
934
+ ${eyebrow ? `<p class="template-eyebrow">${escapeHtml(eyebrow)}</p>` : ""}
935
+ <h1 class="${titleClass}">${escapeHtml(stringValue(content.title) || fallbackTitle)}</h1>
936
+ </header>`
937
+ }
938
+
939
+ function renderBody(templateId: string, content: Record<string, any>): string {
940
+ if (["cover", "section-divider", "closing"].includes(templateId)) return `<div data-template-slot="hero">${renderHeader(content, templateId, { hero: true })}</div>`
941
+ if (templateId === "agenda") return renderAgenda(content)
942
+ if (templateId === "executive-summary") return `${renderHeader(content, "Executive Summary")}<div class="template-body template-grid cols-3" data-template-slot="summary-cards">${cards(items(content), "h2", { visualPlaceholder: true })}</div>`
943
+ if (templateId === "problem-context") return `${renderHeader(content, "Problem / Context")}<div class="template-body template-grid cols-2"><div class="template-card" data-template-slot="context"><p>${escapeHtml(stringValue(content.body))}</p></div><div class="template-card" data-template-slot="supporting-points">${list(items(content))}</div></div>`
944
+ if (templateId === "key-message-evidence") return `${renderHeader(content, "Key Message + Evidence")}<div class="template-body template-grid cols-2">${keyMessagePanel(content)}<div class="template-evidence-grid" data-template-slot="evidence">${evidenceCards(items(content))}</div></div>`
945
+ if (templateId === "claim-supporting-visual") return `${renderHeader(content, "Claim + Supporting Visual")}<div class="template-body template-grid cols-2">${claimTextPanel(content)}${visualSlotPanel()}</div>`
946
+ if (templateId === "metric-highlight") return `${renderHeader(content, "Metric Highlight")}<div class="template-body">${metricHighlight(content)}</div>`
947
+ if (templateId === "chart-takeaways") return `${renderHeader(content, "Chart + Takeaways")}<div class="template-body template-grid template-chart-layout">${visualSlotPanel()}${chartTakeawayPanel(content)}</div>`
948
+ if (templateId === "table-comparison") return `${renderHeader(content, "Table / Comparison")}<div class="template-body" data-template-slot="table">${table(content)}</div>`
949
+ if (templateId === "timeline-roadmap") return `${renderHeader(content, "Timeline / Roadmap")}<div class="template-body">${timeline(content)}</div>`
950
+ if (templateId === "process-steps") return `${renderHeader(content, "Process / Steps")}<div class="template-body"><div class="template-steps" data-template-slot="steps">${steps(content.steps)}</div></div>`
951
+ if (templateId === "recommendation-decision") return `${renderHeader(content, "Recommendation / Decision")}<div class="template-body template-grid cols-3"><div class="template-card" data-template-slot="recommendation"><h2>Recommendation</h2><p>${escapeHtml(stringValue(content.recommendation))}</p>${imageCard(content)}</div><div data-template-slot="rationale">${cards(items(content).slice(0, 1), "h3")}</div><div class="template-card" data-template-slot="next-steps"><h2>Next steps</h2>${orderedSteps(content.steps)}</div></div>`
952
+ if (templateId === "risks-tradeoffs") return `${renderHeader(content, "Risks / Tradeoffs")}<div class="template-body template-grid cols-3" data-template-slot="risks">${cards(items(content), "h3")}</div>`
953
+ return renderHeader(content, templateId)
954
+ }
955
+
956
+ function renderAgenda(content: Record<string, any>): string {
957
+ const agendaItems = items(content)
958
+ const eyebrow = stringValue(content.eyebrow)
959
+ const footer = stringValue(content.footer) || "Structure-First-Design"
960
+ return `<div class="template-body template-agenda-panel" data-template-slot="agenda">
961
+ <div class="template-agenda-inner">
962
+ <div class="template-agenda-header">
963
+ ${eyebrow ? `<p class="template-eyebrow">${escapeHtml(eyebrow)}</p>` : ""}
964
+ <h1 class="template-title">${escapeHtml(stringValue(content.title) || "Agenda")}</h1>
965
+ <p class="template-agenda-footer">${escapeHtml(footer)}</p>
966
+ </div>
967
+ <ol class="template-agenda-list" data-template-slot="agenda-list">${agendaItems.map((item, index) => `<li class="template-agenda-item"><span>${String(index + 1).padStart(2, "0")}</span><strong>${escapeHtml(item.label)}</strong></li>`).join("")}</ol>
968
+ </div>
969
+ </div>`
970
+ }
971
+
972
+ function keyMessagePanel(content: Record<string, any>): string {
973
+ return `<div class="template-key-message-panel" data-template-slot="key-message">
974
+ <h2 class="template-key-message-kicker">Key message</h2>
975
+ <p>${escapeHtml(stringValue(content.body))}</p>
976
+ </div>`
977
+ }
978
+
979
+ function claimTextPanel(content: Record<string, any>): string {
980
+ return `<div class="template-claim-text-panel" data-template-slot="claim">
981
+ <h2 class="template-claim-text-title">${escapeHtml(stringValue(content.claim) || stringValue(content.title) || "Claim")}</h2>
982
+ <p class="template-claim-text-body">${escapeHtml(stringValue(content.body))}</p>
983
+ ${list(items(content))}
984
+ </div>`
985
+ }
986
+
987
+ function evidenceCards(items: Array<{ label: string; description: string; image?: string; imageAlt?: string; imageCaption?: string }>): string {
988
+ return items.map((item, index) => `<article class="template-card template-evidence-card"><h3>${escapeHtml(item.label || `Evidence ${index + 1}`)}</h3><p>${escapeHtml(item.description)}</p>${imageCard(item)}</article>`).join("")
989
+ }
990
+
991
+ function chartTakeawayPanel(content: Record<string, any>): string {
992
+ const takeawayItems = items(content)
993
+ const title = stringValue(content.takeawaysTitle) || "What to read"
994
+ return `<div class="template-text-panel template-chart-takeaway-panel" data-template-slot="takeaways">
995
+ <h2 class="template-text-panel-title">${escapeHtml(title)}</h2>
996
+ <div class="template-chart-takeaway-list">${takeawayItems.map((item) => `<section class="template-chart-takeaway-item"><h3>${escapeHtml(item.label)}</h3><p>${escapeHtml(item.description)}</p></section>`).join("")}</div>
997
+ </div>`
998
+ }
999
+
1000
+ function cards(items: Array<{ label: string; description: string; image?: string; imageAlt?: string; imageCaption?: string }>, heading: "h2" | "h3", options: { visualPlaceholder?: boolean } = {}): string {
1001
+ return items.map((item) => `<article class="template-card"><${heading}>${escapeHtml(item.label)}</${heading}><p>${escapeHtml(item.description)}</p>${imageCard(item) || (options.visualPlaceholder ? visualPlaceholder() : "")}</article>`).join("")
1002
+ }
1003
+
1004
+ function list(items: Array<{ label: string; description: string }>): string {
1005
+ return `<ul class="template-list">${items.map((item) => `<li><strong>${escapeHtml(item.label)}</strong>${item.description ? ` ${escapeHtml(item.description)}` : ""}</li>`).join("")}</ul>`
1006
+ }
1007
+
1008
+ function metrics(input: any): string {
1009
+ const values = Array.isArray(input) ? input : []
1010
+ return values.slice(0, 4).map((item) => `<article class="template-card"><div class="template-stat-value">${escapeHtml(stringValue(item.value) || "0")}</div><h2>${escapeHtml(stringValue(item.label) || "Metric")}</h2><p>${escapeHtml(stringValue(item.description))}</p></article>`).join("")
1011
+ }
1012
+
1013
+ function metricHighlight(content: Record<string, any>): string {
1014
+ const statGrid = `<div class="template-stat-grid" data-template-slot="metrics">${metrics(content.metrics)}</div>`
1015
+ const panel = renderInsightPanel(content)
1016
+ if (!panel) return statGrid
1017
+ const position = stringValue(content.insightPosition) === "top" ? "top" : "bottom"
1018
+ return `<div class="template-metric-layout template-metric-layout--insight-${position}">${position === "top" ? `${panel}${statGrid}` : `${statGrid}${panel}`}</div>`
1019
+ }
1020
+
1021
+ function table(content: Record<string, any>): string {
1022
+ const columns = Array.isArray(content.columns) ? content.columns.map(stringValue).filter(Boolean) : ["Dimension", "Current", "Target"]
1023
+ const rows = Array.isArray(content.rows) ? content.rows : []
1024
+ const insight = renderInsightPanel(content)
1025
+ return `<div class="template-table-wrap"><table class="template-table"><thead><tr>${columns.map((column) => `<th>${escapeHtml(column)}</th>`).join("")}</tr></thead><tbody>${rows.map((row) => `<tr>${columns.map((column, index) => `<td>${escapeHtml(Array.isArray(row) ? stringValue(row[index]) : stringValue(row[column]) || stringValue(row[slug(column)]))}</td>`).join("")}</tr>`).join("")}</tbody></table>${insight}</div>`
1026
+ }
1027
+
1028
+ function renderInsightPanel(content: Record<string, any>): string {
1029
+ const body = stringValue(content.insightBody)
1030
+ if (!body) return ""
1031
+ const title = stringValue(content.insightTitle) || "Insight"
1032
+ const icon = safeLucideIconName(stringValue(content.insightIcon) || "lightbulb")
1033
+ return `<div class="template-insight-panel">
1034
+ <h2 class="template-insight-title"><i class="template-insight-icon" data-lucide="${escapeAttribute(icon)}" aria-hidden="true"></i><span>${escapeHtml(title)}</span></h2>
1035
+ <p class="template-insight-body">${escapeHtml(body)}</p>
1036
+ </div>`
1037
+ }
1038
+
1039
+ function timeline(content: Record<string, any>): string {
1040
+ const milestones = Array.isArray(content.milestones) ? content.milestones : []
1041
+ const orientation = stringValue(content.orientation) === "vertical" ? "vertical" : "horizontal"
1042
+ const sidePanel = renderSidePanel(content)
1043
+ const side = stringValue(content.insightSide) === "right" ? "right" : "left"
1044
+ const timelineHtml = `<div class="template-timeline template-timeline--${orientation}" data-template-slot="timeline" style="--timeline-count:${Math.max(1, milestones.length)}">${milestones.map((item) => timelineMilestone(item, orientation)).join("")}</div>`
1045
+ if (!sidePanel) return timelineHtml
1046
+ return `<div class="template-timeline-layout template-timeline-layout--${side}">${side === "left" ? `${sidePanel}${timelineHtml}` : `${timelineHtml}${sidePanel}`}</div>`
1047
+ }
1048
+
1049
+ function timelineMilestone(item: any, orientation: "horizontal" | "vertical"): string {
1050
+ const itemClass = item?.highlight ? "template-timeline-item template-timeline-item--highlight" : "template-timeline-item"
1051
+ const copyClass = orientation === "horizontal" ? "template-timeline-copy template-card" : "template-timeline-copy"
1052
+ if (orientation === "horizontal") {
1053
+ return `<article class="${itemClass}">
1054
+ <div class="${copyClass}">
1055
+ <i class="template-insight-icon" data-lucide="scan-search" aria-hidden="true"></i>
1056
+ <h3>${escapeHtml(stringValue(item.label))}</h3>
1057
+ <p>${escapeHtml(stringValue(item.description))}</p>
1058
+ </div>
1059
+ <span class="template-timeline-dot" aria-hidden="true"></span>
1060
+ <p class="template-timeline-date">${escapeHtml(stringValue(item.date))}</p>
1061
+ </article>`
1062
+ }
1063
+ return `<article class="${itemClass}">
1064
+ <span class="template-timeline-dot" aria-hidden="true"></span>
1065
+ <div class="${copyClass}">
1066
+ <p class="template-timeline-date">${escapeHtml(stringValue(item.date))}</p>
1067
+ <h3>${escapeHtml(stringValue(item.label))}</h3>
1068
+ <p>${escapeHtml(stringValue(item.description))}</p>
1069
+ </div>
1070
+ </article>`
1071
+ }
1072
+
1073
+ function renderSidePanel(content: Record<string, any>): string {
1074
+ return renderTextPanel(content)
1075
+ }
1076
+
1077
+ function renderTextPanel(content: Record<string, any>): string {
1078
+ const body = stringValue(content.insightBody)
1079
+ if (!body) return ""
1080
+ const title = stringValue(content.insightTitle) || "Insight"
1081
+ return `<div class="template-side-panel template-text-panel" data-template-slot="insight"><h2 class="template-side-panel-title template-text-panel-title">${escapeHtml(title)}</h2><p class="template-side-panel-body template-text-panel-body">${escapeHtml(body)}</p></div>`
1082
+ }
1083
+
1084
+ function imageCard(input: any): string {
1085
+ const image = safeImagePath(stringValue(input?.image))
1086
+ if (!image) return ""
1087
+ const alt = stringValue(input?.imageAlt) || ""
1088
+ const caption = stringValue(input?.imageCaption)
1089
+ return `<figure class="template-image-card"><div class="template-image-frame"><img src="${escapeAttribute(image)}" alt="${escapeAttribute(alt)}"></div>${caption ? `<figcaption class="template-image-caption">${escapeHtml(caption)}</figcaption>` : ""}</figure>`
1090
+ }
1091
+
1092
+ function visualPlaceholder(): string {
1093
+ return `<figure class="template-visual-placeholder"><div class="template-visual-placeholder-frame"><span class="template-visual-placeholder-label">image / chart slot (optional)</span></div></figure>`
1094
+ }
1095
+
1096
+ function visualSlotPanel(): string {
1097
+ return `<div class="template-chart-panel template-visual-slot-panel" data-template-slot="visual"><span class="template-visual-slot-label">image / chart slot (optional)</span></div>`
1098
+ }
1099
+
1100
+ function steps(input: any): string {
1101
+ const values = Array.isArray(input) ? input : []
1102
+ return values.slice(0, 5).map((item, index) => `<article class="template-card"><div class="template-step-number">${index + 1}</div><h2>${escapeHtml(stringValue(item.label) || `Step ${index + 1}`)}</h2><p>${escapeHtml(stringValue(item.description))}</p>${visualPlaceholder()}</article>`).join("")
1103
+ }
1104
+
1105
+ function orderedSteps(input: any): string {
1106
+ const values = Array.isArray(input) ? input : []
1107
+ return `<ol class="template-list">${values.slice(0, 5).map((item) => `<li><strong>${escapeHtml(stringValue(item.label))}</strong>${stringValue(item.description) ? ` ${escapeHtml(stringValue(item.description))}` : ""}${imageCard(item)}</li>`).join("")}</ol>`
1108
+ }
1109
+
1110
+ function items(content: Record<string, any>): Array<{ label: string; description: string; image?: string; imageAlt?: string; imageCaption?: string }> {
1111
+ const raw = Array.isArray(content.items) ? content.items : []
1112
+ return raw.map((item, index) => ({
1113
+ label: stringValue(typeof item === "string" ? item : item.label) || `Item ${index + 1}`,
1114
+ description: stringValue(typeof item === "string" ? "" : item.description || item.body || item.text),
1115
+ image: typeof item === "string" ? "" : stringValue(item.image),
1116
+ imageAlt: typeof item === "string" ? "" : stringValue(item.imageAlt),
1117
+ imageCaption: typeof item === "string" ? "" : stringValue(item.imageCaption),
1118
+ }))
1119
+ }
1120
+
1121
+ function scaffoldSeed(templateId: string, seed: Record<string, any>): Record<string, any> {
1122
+ const title = stringValue(seed.title) || getPageTemplate(templateId).title
1123
+ const base = { ...seed, title }
1124
+ if (templateId === "cover") return { eyebrow: "Deck", ...base }
1125
+ if (templateId === "section-divider") return { eyebrow: "Section", ...base }
1126
+ if (templateId === "closing") return { ...base }
1127
+ if (templateId === "agenda") return { items: defaultItems(["Situation", "Evidence", "Decision"]), ...base }
1128
+ if (templateId === "executive-summary") return { items: defaultItems(["Decision is ready", "Risk is bounded", "Next step is narrow"]), ...base }
1129
+ if (templateId === "problem-context") return { body: "Replace with context, tension, and why now.", items: defaultItems(["Context", "Implication"]), ...base }
1130
+ if (templateId === "key-message-evidence") return { body: "Replace with the key message the audience should remember.", items: defaultItems(["Evidence 1", "Evidence 2", "Evidence 3"]), ...base }
1131
+ if (templateId === "claim-supporting-visual") return { claim: "Replace with one visual claim.", body: "Use this copy to guide how the visual should be read.", items: defaultItems(["Anchor", "Callout"]), ...base }
1132
+ if (templateId === "metric-highlight") return { metrics: [{ value: "67%", label: "Metric", description: "Replace with interpretation." }, { value: "3x", label: "Comparison", description: "Replace with reading note." }, { value: "14d", label: "Window", description: "Replace with time context." }], insightTitle: "Read the signal", insightBody: "Replace with the decision implication, caveat, or next reading step.", ...base }
1133
+ if (templateId === "chart-takeaways") return { takeawaysTitle: "What to read", items: defaultItems(["Trend", "Driver", "Decision use"]), ...base }
1134
+ if (templateId === "table-comparison") return { columns: ["Dimension", "Current", "Target"], rows: [["Replace", "Current state", "Target state"], ["Caveat", "Known limit", "Next proof"]], insightTitle: "Insight", insightBody: "Replace with the table reading note or caveat.", ...base }
1135
+ if (templateId === "timeline-roadmap") return { orientation: "horizontal", milestones: [{ date: "2022", label: "Signal", description: "Name the starting condition." }, { date: "2023", label: "Proof", description: "Show the evidence threshold." }, { date: "2024", label: "Inflection", description: "Use the pivotal moment to frame the shift." }, { date: "2025", label: "Scale", description: "Use a taller card for the highlighted milestone.", highlight: true }, { date: "2026", label: "Decision", description: "State what changes next." }], ...base }
1136
+ if (templateId === "process-steps") return { steps: defaultItems(["Step 1", "Step 2", "Step 3"]), ...base }
1137
+ if (templateId === "recommendation-decision") return { recommendation: "Replace with the recommended decision.", items: defaultItems(["Rationale"]), steps: defaultItems(["Pilot", "Validate", "Ship"]), ...base }
1138
+ if (templateId === "risks-tradeoffs") return { items: defaultItems(["Risk", "Tradeoff", "Mitigation"]), ...base }
1139
+ return base
1140
+ }
1141
+
1142
+ function defaultItems(labels: string[]): Array<{ label: string; description: string }> {
1143
+ return labels.map((label) => ({ label, description: "Replace with slide-specific content." }))
1144
+ }
1145
+
1146
+ function validateRequiredFields(template: PageTemplateDefinition, content: Record<string, any>): string[] {
1147
+ const warnings: string[] = []
1148
+ for (const item of template.fields) {
1149
+ if (!item.required) continue
1150
+ const value = content[item.name]
1151
+ if (Array.isArray(value) ? value.length === 0 : !stringValue(value)) warnings.push(`Missing required template field '${item.name}'.`)
1152
+ }
1153
+ return warnings
1154
+ }
1155
+
1156
+ function positiveIndex(value: number): number {
1157
+ if (!Number.isInteger(value) || value < 1) throw new Error("slideIndex must be a positive 1-based integer.")
1158
+ return value
1159
+ }
1160
+
1161
+ function stringValue(value: any): string {
1162
+ if (value === undefined || value === null) return ""
1163
+ return String(value).trim()
1164
+ }
1165
+
1166
+ function slug(value: string): string {
1167
+ return value.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "")
1168
+ }
1169
+
1170
+ function safeLucideIconName(value: string): string {
1171
+ const icon = value.trim().toLowerCase().replace(/[^a-z0-9-]/g, "")
1172
+ return icon || "lightbulb"
1173
+ }
1174
+
1175
+ function safeImagePath(value: string): string {
1176
+ const image = value.trim()
1177
+ if (!image) return ""
1178
+ if (/^(?:https?:|data:|javascript:|file:)/i.test(image)) return ""
1179
+ if (image.includes("\0")) return ""
1180
+ const parts = image.split(/[\\/]+/)
1181
+ const parentRefs = parts.filter((part) => part === "..").length
1182
+ if (parentRefs > 0 && !image.startsWith("../designs/") && !image.startsWith("..\\designs\\")) return ""
1183
+ if (parentRefs > 1) return ""
1184
+ return image
1185
+ }
1186
+
1187
+ function escapeHtml(value: string): string {
1188
+ return value
1189
+ .replace(/&/g, "&amp;")
1190
+ .replace(/</g, "&lt;")
1191
+ .replace(/>/g, "&gt;")
1192
+ .replace(/"/g, "&quot;")
1193
+ .replace(/'/g, "&#39;")
1194
+ }
1195
+
1196
+ function escapeAttribute(value: string): string {
1197
+ return escapeHtml(value.trim())
1198
+ }
1199
+
1200
+ function escapeRegExp(value: string): string {
1201
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
1202
+ }