pmx-canvas 0.1.19 → 0.1.21

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 (65) hide show
  1. package/CHANGELOG.md +159 -0
  2. package/Readme.md +19 -6
  3. package/dist/canvas/global.css +123 -2
  4. package/dist/canvas/index.js +103 -68
  5. package/dist/json-render/index.js +109 -109
  6. package/dist/types/client/canvas/CanvasViewport.d.ts +1 -1
  7. package/dist/types/client/icons.d.ts +2 -0
  8. package/dist/types/client/nodes/HtmlNode.d.ts +12 -1
  9. package/dist/types/client/state/canvas-store.d.ts +2 -0
  10. package/dist/types/client/types.d.ts +3 -2
  11. package/dist/types/json-render/charts/components.d.ts +5 -1
  12. package/dist/types/json-render/renderer/index.d.ts +1 -0
  13. package/dist/types/json-render/server.d.ts +1 -0
  14. package/dist/types/mcp/canvas-access.d.ts +3 -0
  15. package/dist/types/server/canvas-operations.d.ts +4 -0
  16. package/dist/types/server/canvas-schema.d.ts +19 -3
  17. package/dist/types/server/canvas-serialization.d.ts +1 -0
  18. package/dist/types/server/canvas-state.d.ts +6 -2
  19. package/dist/types/server/html-node-summary.d.ts +2 -0
  20. package/dist/types/server/html-primitives.d.ts +42 -0
  21. package/dist/types/server/index.d.ts +26 -0
  22. package/docs/cli.md +4 -1
  23. package/docs/http-api.md +11 -1
  24. package/docs/mcp.md +10 -4
  25. package/docs/node-types.md +54 -4
  26. package/docs/screenshot.png +0 -0
  27. package/docs/sdk.md +12 -0
  28. package/package.json +1 -1
  29. package/skills/pmx-canvas/SKILL.md +17 -3
  30. package/skills/pmx-canvas/references/html-primitives.md +132 -0
  31. package/src/cli/agent.ts +159 -5
  32. package/src/cli/index.ts +1 -1
  33. package/src/client/App.tsx +21 -2
  34. package/src/client/canvas/AnnotationLayer.tsx +33 -12
  35. package/src/client/canvas/CanvasViewport.tsx +88 -7
  36. package/src/client/canvas/CommandPalette.tsx +2 -2
  37. package/src/client/canvas/ContextMenu.tsx +2 -2
  38. package/src/client/canvas/ExpandedNodeOverlay.tsx +112 -3
  39. package/src/client/canvas/auto-fit.ts +5 -1
  40. package/src/client/icons.tsx +13 -0
  41. package/src/client/nodes/HtmlNode.tsx +125 -13
  42. package/src/client/nodes/McpAppNode.tsx +12 -4
  43. package/src/client/state/canvas-store.ts +15 -5
  44. package/src/client/state/sse-bridge.ts +5 -4
  45. package/src/client/theme/global.css +123 -2
  46. package/src/client/types.ts +2 -1
  47. package/src/json-render/charts/components.tsx +41 -7
  48. package/src/json-render/charts/extra-components.tsx +13 -12
  49. package/src/json-render/renderer/index.tsx +1 -0
  50. package/src/json-render/server.ts +3 -1
  51. package/src/mcp/canvas-access.ts +54 -1
  52. package/src/mcp/server.ts +98 -28
  53. package/src/server/agent-context.ts +39 -0
  54. package/src/server/canvas-operations.ts +99 -38
  55. package/src/server/canvas-provenance.ts +8 -6
  56. package/src/server/canvas-schema.ts +94 -3
  57. package/src/server/canvas-serialization.ts +16 -4
  58. package/src/server/canvas-state.ts +9 -4
  59. package/src/server/demo-state.json +1143 -0
  60. package/src/server/demo.ts +25 -777
  61. package/src/server/html-node-summary.ts +141 -0
  62. package/src/server/html-primitives.ts +1300 -0
  63. package/src/server/index.ts +63 -3
  64. package/src/server/server.ts +154 -17
  65. package/src/server/spatial-analysis.ts +5 -3
@@ -0,0 +1,1300 @@
1
+ export const HTML_PRIMITIVE_KINDS = [
2
+ 'choice-grid',
3
+ 'plan-timeline',
4
+ 'review-sheet',
5
+ 'pr-writeup',
6
+ 'system-map',
7
+ 'code-walkthrough',
8
+ 'design-sheet',
9
+ 'component-gallery',
10
+ 'interaction-prototype',
11
+ 'flowchart',
12
+ 'deck',
13
+ 'presentation',
14
+ 'illustration-set',
15
+ 'explainer',
16
+ 'status-report',
17
+ 'incident-report',
18
+ 'triage-board',
19
+ 'config-editor',
20
+ 'prompt-tuner',
21
+ ] as const;
22
+
23
+ export type HtmlPrimitiveKind = typeof HTML_PRIMITIVE_KINDS[number];
24
+
25
+ export interface HtmlPrimitiveDescriptor {
26
+ kind: HtmlPrimitiveKind;
27
+ title: string;
28
+ description: string;
29
+ useWhen: string;
30
+ defaultSize: { width: number; height: number };
31
+ dataShape: string;
32
+ example: Record<string, unknown>;
33
+ }
34
+
35
+ export interface HtmlPrimitiveInput {
36
+ kind: HtmlPrimitiveKind;
37
+ title?: string;
38
+ data?: Record<string, unknown>;
39
+ }
40
+
41
+ export interface HtmlPrimitiveBuildResult {
42
+ kind: HtmlPrimitiveKind;
43
+ title: string;
44
+ html: string;
45
+ summary: string;
46
+ defaultSize: { width: number; height: number };
47
+ data: Record<string, unknown>;
48
+ }
49
+
50
+ export interface HtmlPrimitiveSemanticMetadata {
51
+ presentation?: true;
52
+ slideCount?: number;
53
+ slideTitles?: string[];
54
+ speakerNotes?: string[];
55
+ presentationTheme?: string | Record<string, string>;
56
+ }
57
+
58
+ type PrimitiveRenderer = (input: { title: string; data: Record<string, unknown>; descriptor: HtmlPrimitiveDescriptor }) => string;
59
+
60
+ const DESCRIPTORS: HtmlPrimitiveDescriptor[] = [
61
+ {
62
+ kind: 'choice-grid',
63
+ title: 'Choice Grid',
64
+ description: 'Side-by-side options with tradeoffs, pros, cons, and code or evidence snippets.',
65
+ useWhen: 'Use for code approaches, product directions, visual explorations, and decision comparisons.',
66
+ defaultSize: { width: 980, height: 720 },
67
+ dataShape: '{ items: [{ title, summary, tradeoff, pros: string[], cons: string[], code? }] }',
68
+ example: {
69
+ kind: 'choice-grid',
70
+ title: 'Implementation Options',
71
+ data: {
72
+ items: [
73
+ { title: 'Small patch', summary: 'Least disruption.', tradeoff: 'Limited future flexibility.', pros: ['Fast'], cons: ['May need follow-up'] },
74
+ ],
75
+ },
76
+ },
77
+ },
78
+ {
79
+ kind: 'plan-timeline',
80
+ title: 'Plan Timeline',
81
+ description: 'Implementation plan with milestones, data flow, code checkpoints, and risks.',
82
+ useWhen: 'Use when a markdown plan would be long and the human needs sequence, dependencies, and risk at a glance.',
83
+ defaultSize: { width: 1040, height: 760 },
84
+ dataShape: '{ milestones: [{ title, detail, status }], flow: [{ from, to, label }], risks: [{ risk, mitigation }], snippets: [{ label, code }] }',
85
+ example: {
86
+ kind: 'plan-timeline',
87
+ title: 'Feature Plan',
88
+ data: {
89
+ milestones: [{ title: 'Trace current flow', detail: 'Map server to client path.', status: 'done' }],
90
+ risks: [{ risk: 'Schema drift', mitigation: 'Add tests at each boundary.' }],
91
+ },
92
+ },
93
+ },
94
+ {
95
+ kind: 'review-sheet',
96
+ title: 'Review Sheet',
97
+ description: 'Annotated PR/code review sheet with severity-colored findings and diff excerpts.',
98
+ useWhen: 'Use for reviewing a change, explaining a PR, or teaching a risky code path.',
99
+ defaultSize: { width: 1040, height: 760 },
100
+ dataShape: '{ findings: [{ severity, title, file, line, detail }], files: [{ path, why }], diff?: string }',
101
+ example: {
102
+ kind: 'review-sheet',
103
+ title: 'Streaming Review',
104
+ data: {
105
+ findings: [{ severity: 'warning', title: 'Backpressure boundary', file: 'src/server.ts', line: 42, detail: 'Confirm queue flush before close.' }],
106
+ },
107
+ },
108
+ },
109
+ {
110
+ kind: 'pr-writeup',
111
+ title: 'PR Writeup',
112
+ description: 'Reviewer-ready PR narrative with motivation, before/after, file tour, test plan, and rollout notes.',
113
+ useWhen: 'Use when a change needs a high-signal review guide rather than a flat pull request description.',
114
+ defaultSize: { width: 1040, height: 760 },
115
+ dataShape: '{ summary, why, before: string[], after: string[], files: [{ path, why, focus }], reviewFocus: string[], tests: string[], rollout: string[] }',
116
+ example: {
117
+ kind: 'pr-writeup',
118
+ title: 'HTML Primitive PR',
119
+ data: {
120
+ summary: 'Adds generated HTML primitives across HTTP, MCP, CLI, and SDK.',
121
+ files: [{ path: 'src/server/html-primitives.ts', why: 'Primitive catalog and renderers.', focus: 'Escaping and export behavior.' }],
122
+ },
123
+ },
124
+ },
125
+ {
126
+ kind: 'system-map',
127
+ title: 'System Map',
128
+ description: 'Module or architecture map with boxes, relationships, entry points, and hot paths.',
129
+ useWhen: 'Use to explain unfamiliar packages, request flows, dependencies, or architecture at a glance.',
130
+ defaultSize: { width: 1040, height: 720 },
131
+ dataShape: '{ modules: [{ id, title, detail, role }], edges: [{ from, to, label }], entryPoints: string[] }',
132
+ example: {
133
+ kind: 'system-map',
134
+ title: 'Canvas Server Map',
135
+ data: {
136
+ modules: [{ id: 'api', title: 'HTTP API', detail: 'Routes requests into canvas operations.', role: 'entry' }],
137
+ edges: [{ from: 'api', to: 'state', label: 'mutates' }],
138
+ },
139
+ },
140
+ },
141
+ {
142
+ kind: 'code-walkthrough',
143
+ title: 'Code Walkthrough',
144
+ description: 'Guided code-path map with modules, ordered file steps, snippets, key files, and gotchas.',
145
+ useWhen: 'Use to explain an unfamiliar package, request path, or implementation slice after inspecting source files.',
146
+ defaultSize: { width: 1040, height: 760 },
147
+ dataShape: '{ summary, modules: [{ id, title, detail, role }], edges: [{ from, to, label }], steps: [{ title, file, detail, code }], keyFiles: [{ path, description }], gotchas: string[] }',
148
+ example: {
149
+ kind: 'code-walkthrough',
150
+ title: 'Canvas Creation Path',
151
+ data: {
152
+ modules: [{ id: 'api', title: 'HTTP API', detail: 'Receives node create requests.', role: 'entry' }],
153
+ steps: [{ title: 'Route request', file: 'src/server/server.ts', detail: 'Normalize input before mutating state.' }],
154
+ },
155
+ },
156
+ },
157
+ {
158
+ kind: 'design-sheet',
159
+ title: 'Design Sheet',
160
+ description: 'Visual design directions, tokens, swatches, type samples, and rationale.',
161
+ useWhen: 'Use for design system reviews, style exploration, and visual option comparisons.',
162
+ defaultSize: { width: 1040, height: 760 },
163
+ dataShape: '{ directions: [{ title, tone, palette: string[], rationale }], tokens: [{ name, value }] }',
164
+ example: {
165
+ kind: 'design-sheet',
166
+ title: 'Visual Directions',
167
+ data: {
168
+ directions: [{ title: 'Editorial', tone: 'calm, high contrast', palette: ['#f8f1e7', '#16120f', '#d65a31'], rationale: 'Readable and opinionated.' }],
169
+ },
170
+ },
171
+ },
172
+ {
173
+ kind: 'component-gallery',
174
+ title: 'Component Gallery',
175
+ description: 'Contact sheet for component variants, states, sizes, and accessibility notes.',
176
+ useWhen: 'Use to review button, card, form, or status component states in one browser-visible sheet.',
177
+ defaultSize: { width: 980, height: 720 },
178
+ dataShape: '{ component, variants: [{ label, state, intent, example, note }] }',
179
+ example: {
180
+ kind: 'component-gallery',
181
+ title: 'Button Variants',
182
+ data: {
183
+ component: 'Button',
184
+ variants: [{ label: 'Primary', state: 'default', intent: 'main action', example: 'Continue' }],
185
+ },
186
+ },
187
+ },
188
+ {
189
+ kind: 'interaction-prototype',
190
+ title: 'Interaction Prototype',
191
+ description: 'Throwaway interaction or motion sandbox with a live stage, controls, annotations, and copyable config.',
192
+ useWhen: 'Use for animation tuning, click-through sketches, draggable behavior studies, and interaction questions that prose cannot answer.',
193
+ defaultSize: { width: 1040, height: 760 },
194
+ dataShape: '{ scenario, controls: [{ key, label, value, min?, max?, unit? }], screens: [{ title, detail }], annotations: [{ title, detail }], questions: string[], snippet? }',
195
+ example: {
196
+ kind: 'interaction-prototype',
197
+ title: 'Sidebar Motion Study',
198
+ data: {
199
+ scenario: 'Tune the collapse transition before wiring it into the app.',
200
+ controls: [{ key: 'duration', label: 'Duration', value: 280, min: 100, max: 900, unit: 'ms' }],
201
+ },
202
+ },
203
+ },
204
+ {
205
+ kind: 'flowchart',
206
+ title: 'Flowchart',
207
+ description: 'Clickable flowchart for pipelines, user journeys, process diagrams, and failure paths.',
208
+ useWhen: 'Use when sequence, branching, timings, or failure states matter more than prose.',
209
+ defaultSize: { width: 980, height: 700 },
210
+ dataShape: '{ steps: [{ title, detail, status, duration }], failurePaths?: [{ from, label, detail }] }',
211
+ example: {
212
+ kind: 'flowchart',
213
+ title: 'Deploy Flow',
214
+ data: {
215
+ steps: [{ title: 'Build', detail: 'Compile assets and run typecheck.', status: 'ok', duration: '45s' }],
216
+ },
217
+ },
218
+ },
219
+ {
220
+ kind: 'deck',
221
+ title: 'Slide Deck',
222
+ description: 'Arrow-key HTML deck with sections, speaker notes, and copyable JSON payload.',
223
+ useWhen: 'Use to turn a thread, report, or plan into a meeting-ready narrative.',
224
+ defaultSize: { width: 960, height: 620 },
225
+ dataShape: '{ slides: [{ title, kicker?, body?, bullets?: string[], note? }] }',
226
+ example: {
227
+ kind: 'deck',
228
+ title: 'Project Update',
229
+ data: {
230
+ slides: [{ title: 'Why this matters', bullets: ['Less markdown fatigue', 'More reviewable decisions'] }],
231
+ },
232
+ },
233
+ },
234
+ {
235
+ kind: 'presentation',
236
+ title: 'HTML Presentation',
237
+ description: 'PowerPoint-style fullscreen-ready HTML presentation with slide navigation, progress, speaker notes, and presentation metadata.',
238
+ useWhen: 'Use when the human asks for a presentation, pitch deck, briefing, workshop walkthrough, or PowerPoint-like deliverable.',
239
+ defaultSize: { width: 1120, height: 700 },
240
+ dataShape: '{ subtitle?, theme?: "canvas"|"midnight"|"paper"|"aurora"|{ bg?, panel?, surface?, border?, text?, textSecondary?, textMuted?, accent? }, slides: [{ title, kicker?, body?, bullets?: string[], metrics?: [{ label, value, detail? }], note? }] }',
241
+ example: {
242
+ kind: 'presentation',
243
+ title: 'Project Briefing',
244
+ data: {
245
+ subtitle: 'A meeting-ready narrative for review.',
246
+ slides: [
247
+ { title: 'Why this matters', kicker: '01', body: 'Frame the decision and outcome.', bullets: ['Human-readable', 'Fullscreen-ready'] },
248
+ { title: 'What changes', kicker: '02', bullets: ['Show the before/after', 'End with clear next steps'] },
249
+ ],
250
+ },
251
+ },
252
+ },
253
+ {
254
+ kind: 'illustration-set',
255
+ title: 'Illustration Set',
256
+ description: 'Inline SVG figure sheet with captions and per-figure SVG copy/export controls.',
257
+ useWhen: 'Use for blog figures, architecture illustrations, conceptual diagrams, and vector sketches that should be tweakable or pasteable.',
258
+ defaultSize: { width: 1040, height: 760 },
259
+ dataShape: '{ figures: [{ title, caption, shapes: [{ type, x, y, width, height, cx, cy, r, x1, y1, x2, y2, text, color }] }] }',
260
+ example: {
261
+ kind: 'illustration-set',
262
+ title: 'Article Figures',
263
+ data: {
264
+ figures: [{ title: 'Feedback Loop', caption: 'Human and agent exchange context.', shapes: [{ type: 'rect', x: 40, y: 50, width: 130, height: 70, text: 'Human' }] }],
265
+ },
266
+ },
267
+ },
268
+ {
269
+ kind: 'explainer',
270
+ title: 'Feature Explainer',
271
+ description: 'Readable explainer with TLDR, collapsible steps, annotated snippets, FAQ, and glossary.',
272
+ useWhen: 'Use to teach how a feature, algorithm, or code path works after inspecting repo context.',
273
+ defaultSize: { width: 980, height: 760 },
274
+ dataShape: '{ summary, steps: [{ title, detail }], snippets: [{ label, code, note }], faq: [{ q, a }], glossary: [{ term, definition }] }',
275
+ example: {
276
+ kind: 'explainer',
277
+ title: 'Rate Limiter Explainer',
278
+ data: {
279
+ summary: 'Requests spend tokens from a bucket that refills over time.',
280
+ steps: [{ title: 'Identify key', detail: 'The route derives a tenant/user key.' }],
281
+ },
282
+ },
283
+ },
284
+ {
285
+ kind: 'incident-report',
286
+ title: 'Incident Report',
287
+ description: 'Post-incident report with impact metrics, minute-by-minute timeline, root cause, log excerpts, and action checklist.',
288
+ useWhen: 'Use for incident summaries, post-mortems, reliability reviews, and follow-up tracking.',
289
+ defaultSize: { width: 1040, height: 760 },
290
+ dataShape: '{ severity, status, duration, summary, impact: [{ label, value, tone }], timeline: [{ time, event, detail, tone }], rootCause, logs?: string, actions: [{ done, owner, description, due }] }',
291
+ example: {
292
+ kind: 'incident-report',
293
+ title: 'API Latency Incident',
294
+ data: {
295
+ severity: 'SEV-2',
296
+ summary: 'Elevated API latency after deploy.',
297
+ timeline: [{ time: '10:04', event: 'Alert fired', detail: 'p95 latency crossed threshold.', tone: 'warn' }],
298
+ },
299
+ },
300
+ },
301
+ {
302
+ kind: 'status-report',
303
+ title: 'Status Report',
304
+ description: 'Skimmable report with metrics, shipped/slipped lists, blockers, and next actions.',
305
+ useWhen: 'Use for weekly updates, project health, incident summaries, and leadership-ready status.',
306
+ defaultSize: { width: 980, height: 720 },
307
+ dataShape: '{ metrics: [{ label, value, tone }], shipped: string[], slipped: string[], risks: string[], next: string[] }',
308
+ example: {
309
+ kind: 'status-report',
310
+ title: 'Weekly Canvas Status',
311
+ data: {
312
+ metrics: [{ label: 'Tests', value: 'green', tone: 'ok' }],
313
+ shipped: ['HTML primitive endpoint'],
314
+ },
315
+ },
316
+ },
317
+ {
318
+ kind: 'triage-board',
319
+ title: 'Triage Board',
320
+ description: 'Draggable Now/Next/Later/Cut board with copy-as-markdown export.',
321
+ useWhen: 'Use when a human needs to reorder, bucket, approve, or cut items and send the result back to the agent.',
322
+ defaultSize: { width: 1040, height: 760 },
323
+ dataShape: '{ columns?: string[], items: [{ title, detail, column, rationale }] }',
324
+ example: {
325
+ kind: 'triage-board',
326
+ title: 'Ticket Triage',
327
+ data: {
328
+ items: [{ title: 'Fix flaky smoke test', detail: 'Fails on CI timeout.', column: 'Now', rationale: 'Blocks release.' }],
329
+ },
330
+ },
331
+ },
332
+ {
333
+ kind: 'config-editor',
334
+ title: 'Config Editor',
335
+ description: 'Form-like editor for flags or structured config with dependency warnings and copy-diff export.',
336
+ useWhen: 'Use for feature flags, environment settings, structured JSON/YAML choices, and constraint-aware edits.',
337
+ defaultSize: { width: 980, height: 720 },
338
+ dataShape: '{ flags: [{ key, label, area, enabled, requires?: string[], description }] }',
339
+ example: {
340
+ kind: 'config-editor',
341
+ title: 'Feature Flags',
342
+ data: {
343
+ flags: [{ key: 'newCheckout', label: 'New checkout', area: 'Checkout', enabled: false, requires: ['paymentsV2'] }],
344
+ },
345
+ },
346
+ },
347
+ {
348
+ kind: 'prompt-tuner',
349
+ title: 'Prompt Tuner',
350
+ description: 'Side-by-side prompt/template editor with live variable previews and copy export.',
351
+ useWhen: 'Use to tune prompts, copy, templates, examples, and variable slots with human-in-the-loop feedback.',
352
+ defaultSize: { width: 1040, height: 760 },
353
+ dataShape: '{ template: string, samples: [{ name, variables: Record<string,string> }] }',
354
+ example: {
355
+ kind: 'prompt-tuner',
356
+ title: 'Prompt Tuner',
357
+ data: {
358
+ template: 'Explain {{feature}} for {{audience}}.',
359
+ samples: [{ name: 'Engineering', variables: { feature: 'canvas pins', audience: 'backend engineers' } }],
360
+ },
361
+ },
362
+ },
363
+ ];
364
+
365
+ function isRecord(value: unknown): value is Record<string, unknown> {
366
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
367
+ }
368
+
369
+ function text(value: unknown, fallback = ''): string {
370
+ if (typeof value === 'string') return value;
371
+ if (typeof value === 'number' || typeof value === 'boolean') return String(value);
372
+ return fallback;
373
+ }
374
+
375
+ function records(value: unknown): Record<string, unknown>[] {
376
+ return Array.isArray(value) ? value.filter(isRecord) : [];
377
+ }
378
+
379
+ function strings(value: unknown): string[] {
380
+ if (!Array.isArray(value)) return [];
381
+ return value.map((item) => text(item).trim()).filter(Boolean);
382
+ }
383
+
384
+ function fieldRecords(data: Record<string, unknown>, key: string, fallback: Record<string, unknown>[]): Record<string, unknown>[] {
385
+ const found = records(data[key]);
386
+ return found.length > 0 ? found : fallback;
387
+ }
388
+
389
+ const DEFAULT_DECK_SLIDES: Record<string, unknown>[] = [
390
+ { title: 'HTML keeps humans in the loop', kicker: 'Thesis', bullets: ['Higher information density', 'Visual clarity', 'Two-way interaction'] },
391
+ { title: 'Use the lightest tier that works', bullets: ['json-render for structured UI', 'html primitives for rich documents', 'web artifacts for full React apps'] },
392
+ ];
393
+
394
+ const DEFAULT_PRESENTATION_SLIDES: Record<string, unknown>[] = [
395
+ { title: 'Set the frame', kicker: '01', body: 'Open with the decision, audience, and outcome this presentation supports.' },
396
+ { title: 'Show the evidence', kicker: '02', bullets: ['Use concrete facts', 'Keep one idea per slide', 'Make risks visible'] },
397
+ { title: 'Close with action', kicker: '03', bullets: ['Decision needed', 'Owner and next step', 'Timing'] },
398
+ ];
399
+
400
+ function presentationSlides(data: Record<string, unknown>, fallback = DEFAULT_PRESENTATION_SLIDES): Record<string, unknown>[] {
401
+ return fieldRecords(data, 'slides', fallback);
402
+ }
403
+
404
+ function enrichPresentationData(
405
+ kind: HtmlPrimitiveKind,
406
+ data: Record<string, unknown>,
407
+ ): Record<string, unknown> {
408
+ if (kind !== 'deck' && kind !== 'presentation') return data;
409
+ const slides = presentationSlides(data, kind === 'deck' ? DEFAULT_DECK_SLIDES : DEFAULT_PRESENTATION_SLIDES);
410
+ const slideTitles = slides.map((slide, index) => itemTitle(slide, `Slide ${index + 1}`));
411
+ const speakerNotes = slides
412
+ .map((slide) => text(slide.note).trim())
413
+ .filter(Boolean);
414
+ const theme = kind === 'presentation' ? presentationThemeMetadata(data) : undefined;
415
+ return {
416
+ ...data,
417
+ slides,
418
+ presentation: true,
419
+ slideCount: slides.length,
420
+ slideTitles,
421
+ ...(speakerNotes.length > 0 ? { speakerNotes } : {}),
422
+ ...(theme !== undefined ? { presentationTheme: theme } : {}),
423
+ };
424
+ }
425
+
426
+ function fieldStrings(data: Record<string, unknown>, key: string, fallback: string[]): string[] {
427
+ const found = strings(data[key]);
428
+ return found.length > 0 ? found : fallback;
429
+ }
430
+
431
+ function escapeHtml(value: string): string {
432
+ return value
433
+ .replaceAll('&', '&amp;')
434
+ .replaceAll('<', '&lt;')
435
+ .replaceAll('>', '&gt;')
436
+ .replaceAll('"', '&quot;')
437
+ .replaceAll("'", '&#39;');
438
+ }
439
+
440
+ function safeCssColor(value: string): string {
441
+ const trimmed = value.trim();
442
+ if (/^var\(--[a-z0-9-]+\)$/i.test(trimmed)) return trimmed;
443
+ if (/^#[0-9a-f]{3,8}$/i.test(trimmed)) return trimmed;
444
+ if (/^(?:rgb|hsl)a?\([\d\s.,%+-]+\)$/i.test(trimmed)) return trimmed;
445
+ return 'transparent';
446
+ }
447
+
448
+ type PresentationThemeName = 'canvas' | 'midnight' | 'paper' | 'aurora';
449
+
450
+ interface PresentationThemeTokens {
451
+ name: PresentationThemeName | 'custom';
452
+ bg: string;
453
+ panel: string;
454
+ surface: string;
455
+ border: string;
456
+ text: string;
457
+ textSecondary: string;
458
+ textMuted: string;
459
+ accent: string;
460
+ colorScheme: string;
461
+ }
462
+
463
+ const PRESENTATION_THEMES: Record<PresentationThemeName, PresentationThemeTokens> = {
464
+ canvas: {
465
+ name: 'canvas',
466
+ bg: 'var(--color-bg, #081524)',
467
+ panel: 'var(--color-panel, #0f1d31)',
468
+ surface: 'var(--color-surface, #10213a)',
469
+ border: 'var(--color-border, #1b2c44)',
470
+ text: 'var(--color-text, #e6eef7)',
471
+ textSecondary: 'var(--color-text-secondary, #c7d3ea)',
472
+ textMuted: 'var(--color-text-muted, #8ea3bd)',
473
+ accent: 'var(--color-accent, #4BBCFF)',
474
+ colorScheme: 'dark light',
475
+ },
476
+ midnight: {
477
+ name: 'midnight',
478
+ bg: '#081524',
479
+ panel: '#0f1d31',
480
+ surface: '#10213a',
481
+ border: '#1b2c44',
482
+ text: '#e6eef7',
483
+ textSecondary: '#c7d3ea',
484
+ textMuted: '#8ea3bd',
485
+ accent: '#4BBCFF',
486
+ colorScheme: 'dark',
487
+ },
488
+ paper: {
489
+ name: 'paper',
490
+ bg: '#F4EFE6',
491
+ panel: '#EFE7D4',
492
+ surface: '#FAF6EE',
493
+ border: '#D6CBB4',
494
+ text: '#081524',
495
+ textSecondary: '#3d4d63',
496
+ textMuted: '#5c6b80',
497
+ accent: '#1A7ABF',
498
+ colorScheme: 'light',
499
+ },
500
+ aurora: {
501
+ name: 'aurora',
502
+ bg: '#090f1f',
503
+ panel: '#101a32',
504
+ surface: '#12263b',
505
+ border: '#24415f',
506
+ text: '#f5fbff',
507
+ textSecondary: '#d5e8f7',
508
+ textMuted: '#95adc2',
509
+ accent: '#8cffd2',
510
+ colorScheme: 'dark',
511
+ },
512
+ };
513
+
514
+ function presentationTheme(data: Record<string, unknown>): PresentationThemeTokens {
515
+ const raw = data.theme ?? data.presentationTheme;
516
+ if (typeof raw === 'string') {
517
+ return PRESENTATION_THEMES[raw as PresentationThemeName] ?? PRESENTATION_THEMES.canvas;
518
+ }
519
+ if (!isRecord(raw)) return PRESENTATION_THEMES.canvas;
520
+ const baseName = typeof raw.base === 'string' && raw.base in PRESENTATION_THEMES
521
+ ? raw.base as PresentationThemeName
522
+ : 'canvas';
523
+ const base = PRESENTATION_THEMES[baseName];
524
+ const readColor = (key: string, fallback: string): string => {
525
+ const value = text(raw[key]);
526
+ if (!value) return fallback;
527
+ const color = safeCssColor(value);
528
+ return color === 'transparent' ? fallback : color;
529
+ };
530
+ const colorScheme = raw.colorScheme === 'light' ? 'light' : raw.colorScheme === 'dark' ? 'dark' : base.colorScheme;
531
+ return {
532
+ name: 'custom',
533
+ bg: readColor('bg', base.bg),
534
+ panel: readColor('panel', base.panel),
535
+ surface: readColor('surface', base.surface),
536
+ border: readColor('border', base.border),
537
+ text: readColor('text', base.text),
538
+ textSecondary: readColor('textSecondary', base.textSecondary),
539
+ textMuted: readColor('textMuted', base.textMuted),
540
+ accent: readColor('accent', base.accent),
541
+ colorScheme,
542
+ };
543
+ }
544
+
545
+ function presentationThemeMetadata(data: Record<string, unknown>): string | Record<string, string> | undefined {
546
+ const raw = data.theme ?? data.presentationTheme;
547
+ if (typeof raw === 'string') return raw;
548
+ if (!isRecord(raw)) return undefined;
549
+ const result: Record<string, string> = {};
550
+ for (const [key, value] of Object.entries(raw)) {
551
+ if (typeof value === 'string') result[key] = value;
552
+ }
553
+ return Object.keys(result).length > 0 ? result : undefined;
554
+ }
555
+
556
+ function number(value: unknown, fallback = 0): number {
557
+ return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
558
+ }
559
+
560
+ function inlineSvgShape(shape: Record<string, unknown>, index: number): string {
561
+ const color = safeCssColor(text(shape.color, '#4a9eff'));
562
+ const label = text(shape.text, text(shape.label));
563
+ const labelText = label ? `<text x="${number(shape.x, number(shape.cx, 80)) + 12}" y="${number(shape.y, number(shape.cy, 80)) + 28}" fill="var(--color-text, #e8edf2)" font-size="13" font-weight="700">${escapeHtml(label)}</text>` : '';
564
+ switch (text(shape.type, 'rect')) {
565
+ case 'circle':
566
+ return `<circle cx="${number(shape.cx, 80 + index * 40)}" cy="${number(shape.cy, 80)}" r="${number(shape.r, 32)}" fill="${color}" opacity="0.22" stroke="${color}" stroke-width="2" />${label ? `<text x="${number(shape.cx, 80 + index * 40)}" y="${number(shape.cy, 80) + 4}" text-anchor="middle" fill="var(--color-text, #e8edf2)" font-size="13" font-weight="700">${escapeHtml(label)}</text>` : ''}`;
567
+ case 'line':
568
+ case 'arrow':
569
+ return `<line x1="${number(shape.x1, 40)}" y1="${number(shape.y1, 40 + index * 30)}" x2="${number(shape.x2, 220)}" y2="${number(shape.y2, 40 + index * 30)}" stroke="${color}" stroke-width="2.5" stroke-linecap="round" ${text(shape.type) === 'arrow' ? 'marker-end="url(#arrow)"' : ''}/>`;
570
+ case 'text':
571
+ return `<text x="${number(shape.x, 40)}" y="${number(shape.y, 60 + index * 24)}" fill="var(--color-text, #e8edf2)" font-size="15" font-weight="700">${escapeHtml(label || `Label ${index + 1}`)}</text>`;
572
+ default:
573
+ return `<rect x="${number(shape.x, 40 + index * 24)}" y="${number(shape.y, 50 + index * 18)}" width="${number(shape.width, 150)}" height="${number(shape.height, 70)}" rx="14" fill="${color}" opacity="0.18" stroke="${color}" stroke-width="2" />${labelText}`;
574
+ }
575
+ }
576
+
577
+ function safeJson(value: unknown): string {
578
+ return (JSON.stringify(value ?? {}, null, 2) ?? '{}')
579
+ .replaceAll('<', '\\u003c')
580
+ .replaceAll('>', '\\u003e')
581
+ .replaceAll('&', '\\u0026');
582
+ }
583
+
584
+ function itemTitle(item: Record<string, unknown>, fallback: string): string {
585
+ return text(item.title, text(item.label, text(item.name, fallback)));
586
+ }
587
+
588
+ function badge(value: string): string {
589
+ const normalized = value.toLowerCase();
590
+ const tone = normalized.includes('block') || normalized.includes('fail') || normalized.includes('danger') || normalized.includes('critical')
591
+ ? 'danger'
592
+ : normalized.includes('warn') || normalized.includes('risk') || normalized.includes('progress') || normalized.includes('later')
593
+ ? 'warn'
594
+ : normalized.includes('ok') || normalized.includes('done') || normalized.includes('ship') || normalized.includes('green')
595
+ ? 'ok'
596
+ : 'info';
597
+ return `<span class="badge ${tone}">${escapeHtml(value)}</span>`;
598
+ }
599
+
600
+ function list(items: string[]): string {
601
+ if (items.length === 0) return '';
602
+ return `<ul>${items.map((item) => `<li>${escapeHtml(item)}</li>`).join('')}</ul>`;
603
+ }
604
+
605
+ function codeBlock(value: unknown): string {
606
+ const code = text(value).trim();
607
+ return code ? `<pre><code>${escapeHtml(code)}</code></pre>` : '';
608
+ }
609
+
610
+ function page(input: {
611
+ title: string;
612
+ kind: HtmlPrimitiveKind;
613
+ summary: string;
614
+ data: Record<string, unknown>;
615
+ body: string;
616
+ script?: string;
617
+ }): string {
618
+ return `<!doctype html>
619
+ <html lang="en">
620
+ <head>
621
+ <meta charset="utf-8">
622
+ <meta name="viewport" content="width=device-width, initial-scale=1">
623
+ <title>${escapeHtml(input.title)}</title>
624
+ <style>
625
+ :root { color-scheme: dark light; }
626
+ * { box-sizing: border-box; }
627
+ body { margin: 0; padding: 22px; background: radial-gradient(circle at top left, color-mix(in srgb, var(--color-accent, #4a9eff) 18%, transparent), transparent 34rem), var(--color-bg, #0b0f14); color: var(--color-text, #e8edf2); font-family: var(--font-sans, ui-sans-serif, system-ui, sans-serif); }
628
+ .shell { max-width: 1160px; margin: 0 auto; }
629
+ header.hero { display: grid; grid-template-columns: 1fr auto; gap: 16px; align-items: start; margin-bottom: 20px; }
630
+ .kicker { color: var(--color-accent, #4a9eff); text-transform: uppercase; letter-spacing: .16em; font-size: 11px; font-weight: 800; }
631
+ h1 { margin: 5px 0 8px; font-size: clamp(28px, 4vw, 46px); line-height: .95; letter-spacing: -.04em; }
632
+ h2 { margin: 0 0 12px; font-size: 19px; letter-spacing: -.02em; }
633
+ h3 { margin: 0 0 8px; font-size: 15px; }
634
+ p { color: var(--color-text-secondary, #aeb8c2); margin: 0 0 12px; }
635
+ .actions { display: flex; gap: 8px; flex-wrap: wrap; justify-content: flex-end; }
636
+ button { border: 1px solid var(--color-border, #263241); background: var(--color-panel, #111821); color: var(--color-text, #e8edf2); border-radius: 999px; padding: 8px 12px; font: inherit; cursor: pointer; }
637
+ button:hover { border-color: var(--color-accent, #4a9eff); transform: translateY(-1px); }
638
+ table { width: 100%; border-collapse: collapse; color: var(--color-text-secondary, #aeb8c2); font-size: 13px; }
639
+ th, td { text-align: left; border-bottom: 1px solid var(--color-border, #263241); padding: 9px 8px; vertical-align: top; }
640
+ th { color: var(--color-text, #e8edf2); font-size: 11px; text-transform: uppercase; letter-spacing: .08em; }
641
+ .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(235px, 1fr)); gap: 14px; }
642
+ .two { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 14px; }
643
+ .three { display: grid; grid-template-columns: 1.1fr 1fr .9fr; gap: 14px; align-items: start; }
644
+ .card, details, .panel { border: 1px solid var(--color-border, #263241); background: color-mix(in srgb, var(--color-panel, #111821) 88%, transparent); border-radius: 18px; padding: 16px; box-shadow: 0 18px 50px rgba(0, 0, 0, .18); }
645
+ .card.emphasis { border-color: color-mix(in srgb, var(--color-accent, #4a9eff) 55%, var(--color-border, #263241)); }
646
+ .sticky { position: sticky; top: 14px; }
647
+ .metric { min-height: 104px; display: grid; align-content: space-between; }
648
+ .metric strong { font-size: clamp(24px, 4vw, 42px); letter-spacing: -.04em; }
649
+ .muted { color: var(--color-text-secondary, #aeb8c2); }
650
+ .small { font-size: 12px; color: var(--color-text-muted, #7e8a97); }
651
+ ul { padding-left: 18px; margin: 8px 0 0; color: var(--color-text-secondary, #aeb8c2); }
652
+ li + li { margin-top: 5px; }
653
+ pre { white-space: pre-wrap; overflow: auto; margin: 10px 0 0; padding: 12px; border-radius: 12px; background: #05070a; border: 1px solid color-mix(in srgb, var(--color-border, #263241) 75%, black); color: #d7eadb; font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); font-size: 12px; line-height: 1.45; }
654
+ .badge { display: inline-flex; align-items: center; border-radius: 999px; padding: 3px 8px; font-size: 11px; font-weight: 800; letter-spacing: .04em; text-transform: uppercase; }
655
+ .badge.ok { color: #062b18; background: var(--color-success, #22c55e); }
656
+ .badge.warn { color: #2b1a02; background: var(--color-warning, #eab308); }
657
+ .badge.danger { color: white; background: var(--color-danger, #ef4444); }
658
+ .badge.info { color: #051a33; background: var(--color-accent, #4a9eff); }
659
+ .timeline { position: relative; display: grid; gap: 12px; }
660
+ .timeline .step { display: grid; grid-template-columns: 34px 1fr; gap: 12px; align-items: start; }
661
+ .dot { width: 34px; height: 34px; border-radius: 50%; display: grid; place-items: center; background: var(--color-accent, #4a9eff); color: #06111f; font-weight: 900; }
662
+ .flow { display: flex; gap: 10px; align-items: stretch; overflow-x: auto; padding-bottom: 4px; }
663
+ .flow-node { min-width: 180px; border-radius: 16px; border: 1px solid var(--color-border, #263241); padding: 14px; background: var(--color-surface, #17202b); }
664
+ .arrow { align-self: center; color: var(--color-accent, #4a9eff); font-weight: 900; }
665
+ .swatches { display: flex; gap: 6px; flex-wrap: wrap; margin: 8px 0; }
666
+ .swatch { width: 44px; height: 44px; border-radius: 13px; border: 1px solid rgba(255,255,255,.22); }
667
+ textarea, input[type="text"] { width: 100%; border: 1px solid var(--color-border, #263241); border-radius: 14px; padding: 12px; background: var(--color-bg, #0b0f14); color: var(--color-text, #e8edf2); font: inherit; }
668
+ textarea { min-height: 220px; resize: vertical; font-family: var(--font-mono, ui-monospace, monospace); }
669
+ .columns { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 12px; align-items: start; }
670
+ .column { min-height: 220px; border: 1px dashed var(--color-border, #263241); border-radius: 18px; padding: 12px; background: color-mix(in srgb, var(--color-panel, #111821) 70%, transparent); }
671
+ .ticket { cursor: grab; margin: 10px 0; }
672
+ .ticket:active { cursor: grabbing; }
673
+ .slide { min-height: 390px; display: none; align-content: center; }
674
+ .slide.active { display: grid; }
675
+ .slide h2 { font-size: clamp(30px, 5vw, 58px); line-height: .95; }
676
+ .preview { white-space: pre-wrap; min-height: 220px; }
677
+ .figure svg { width: 100%; min-height: 220px; border-radius: 14px; background: color-mix(in srgb, var(--color-bg, #0b0f14) 86%, white); border: 1px solid var(--color-border, #263241); }
678
+ @media (max-width: 760px) { body { padding: 14px; } header.hero, .two, .three { grid-template-columns: 1fr; } .actions { justify-content: flex-start; } .sticky { position: static; } }
679
+ </style>
680
+ </head>
681
+ <body>
682
+ <div class="shell">
683
+ <header class="hero">
684
+ <div><div class="kicker">PMX HTML primitive / ${escapeHtml(input.kind)}</div><h1>${escapeHtml(input.title)}</h1><p>${escapeHtml(input.summary)}</p></div>
685
+ <div class="actions"><button type="button" data-copy-json>Copy JSON</button><button type="button" data-copy-prompt>Copy prompt</button></div>
686
+ </header>
687
+ ${input.body}
688
+ </div>
689
+ <script type="application/json" id="pmx-data">${safeJson(input.data)}</script>
690
+ <script>
691
+ const PMX_DATA = JSON.parse(document.getElementById('pmx-data').textContent);
692
+ function fallbackCopy(text) {
693
+ const el = document.createElement('textarea');
694
+ el.value = text;
695
+ el.setAttribute('readonly', '');
696
+ el.style.position = 'fixed';
697
+ el.style.left = '-9999px';
698
+ document.body.appendChild(el);
699
+ el.select();
700
+ document.execCommand('copy');
701
+ el.remove();
702
+ }
703
+ async function copyText(text) {
704
+ if (navigator.clipboard && window.isSecureContext) await navigator.clipboard.writeText(text);
705
+ else fallbackCopy(text);
706
+ }
707
+ document.querySelector('[data-copy-json]')?.addEventListener('click', () => {
708
+ const payload = typeof window.__pmxGetCopyJson === 'function' ? window.__pmxGetCopyJson() : PMX_DATA;
709
+ copyText(JSON.stringify(payload, null, 2));
710
+ });
711
+ document.querySelector('[data-copy-prompt]')?.addEventListener('click', () => copyText('Use this PMX Canvas HTML primitive output as context:\\n\\n' + JSON.stringify(PMX_DATA, null, 2)));
712
+ ${input.script ?? ''}
713
+ </script>
714
+ </body>
715
+ </html>`;
716
+ }
717
+
718
+ function renderChoiceGrid({ title, data, descriptor }: Parameters<PrimitiveRenderer>[0]): string {
719
+ const items = fieldRecords(data, 'items', [
720
+ { title: 'Option A', summary: 'Conservative path with minimal code churn.', tradeoff: 'Lower upside, lower risk.', pros: ['Quick to ship', 'Easy to review'], cons: ['Less flexible'] },
721
+ { title: 'Option B', summary: 'Balanced refactor with clearer seams.', tradeoff: 'More code touched.', pros: ['Better long-term shape'], cons: ['Requires more tests'] },
722
+ { title: 'Option C', summary: 'Purpose-built new layer.', tradeoff: 'Best UX, highest implementation cost.', pros: ['Distinctive output'], cons: ['More maintenance'] },
723
+ ]);
724
+ const body = `<section class="grid">${items.map((item, index) => `
725
+ <article class="card ${index === 0 ? 'emphasis' : ''}">
726
+ <div class="small">Choice ${index + 1}</div>
727
+ <h2>${escapeHtml(itemTitle(item, `Option ${index + 1}`))}</h2>
728
+ <p>${escapeHtml(text(item.summary, 'Summarize the approach here.'))}</p>
729
+ ${text(item.tradeoff) ? `<p><strong>Tradeoff:</strong> ${escapeHtml(text(item.tradeoff))}</p>` : ''}
730
+ <div class="two"><div><h3>Pros</h3>${list(strings(item.pros))}</div><div><h3>Cons</h3>${list(strings(item.cons))}</div></div>
731
+ ${codeBlock(item.code)}
732
+ </article>`).join('')}</section>`;
733
+ return page({ title, kind: 'choice-grid', summary: descriptor.description, data, body });
734
+ }
735
+
736
+ function renderPlanTimeline({ title, data, descriptor }: Parameters<PrimitiveRenderer>[0]): string {
737
+ const milestones = fieldRecords(data, 'milestones', [
738
+ { title: 'Understand current flow', detail: 'Map API, state, rendering, and agent entry points.', status: 'done' },
739
+ { title: 'Add primitive catalog', detail: 'Generate sandboxed HTML from typed templates.', status: 'in progress' },
740
+ { title: 'Expose interfaces', detail: 'Wire HTTP, SDK, MCP, CLI, and schema discovery.', status: 'next' },
741
+ { title: 'Verify', detail: 'Run tests that prove schema, creation, and metadata behavior.', status: 'next' },
742
+ ]);
743
+ const flow = fieldRecords(data, 'flow', [
744
+ { from: 'Agent', to: 'Primitive catalog', label: 'chooses kind + data' },
745
+ { from: 'Primitive catalog', to: 'HTML node', label: 'renders iframe HTML' },
746
+ { from: 'Human', to: 'Agent', label: 'exports edited JSON/prompt' },
747
+ ]);
748
+ const risks = fieldRecords(data, 'risks', [{ risk: 'Overly generic output', mitigation: 'Use named primitives with clear use cases.' }]);
749
+ const snippets = fieldRecords(data, 'snippets', []);
750
+ const body = `<section class="two"><div class="panel"><h2>Milestones</h2><div class="timeline">${milestones.map((item, index) => `
751
+ <div class="step"><div class="dot">${index + 1}</div><div class="card"><h3>${escapeHtml(itemTitle(item, `Milestone ${index + 1}`))} ${badge(text(item.status, 'planned'))}</h3><p>${escapeHtml(text(item.detail, 'Add implementation detail.'))}</p></div></div>`).join('')}</div></div>
752
+ <div class="panel"><h2>Data Flow</h2><div class="flow">${flow.map((item, index) => `<div class="flow-node"><h3>${escapeHtml(text(item.from, `Step ${index + 1}`))}</h3><p>${escapeHtml(text(item.label, 'flows to'))}</p><strong>${escapeHtml(text(item.to, 'Next'))}</strong></div>${index < flow.length - 1 ? '<div class="arrow">-></div>' : ''}`).join('')}</div><h2 style="margin-top:18px">Risks</h2>${risks.map((item) => `<div class="card"><strong>${escapeHtml(text(item.risk, 'Risk'))}</strong><p>${escapeHtml(text(item.mitigation, 'Mitigation'))}</p></div>`).join('')}</div></section>
753
+ ${snippets.length > 0 ? `<section class="panel" style="margin-top:14px"><h2>Code Checkpoints</h2>${snippets.map((item) => `<h3>${escapeHtml(itemTitle(item, 'Snippet'))}</h3>${codeBlock(item.code)}`).join('')}</section>` : ''}`;
754
+ return page({ title, kind: 'plan-timeline', summary: descriptor.description, data, body });
755
+ }
756
+
757
+ function renderReviewSheet({ title, data, descriptor }: Parameters<PrimitiveRenderer>[0]): string {
758
+ const findings = fieldRecords(data, 'findings', [
759
+ { severity: 'warning', title: 'Review focus', file: 'src/example.ts', line: 1, detail: 'Explain the risky behavior and what to inspect.' },
760
+ ]);
761
+ const files = fieldRecords(data, 'files', []);
762
+ const diff = text(data.diff);
763
+ const body = `<section class="two"><div class="panel"><h2>Findings</h2>${findings.map((item) => `
764
+ <article class="card"><h3>${badge(text(item.severity, 'info'))} ${escapeHtml(itemTitle(item, 'Finding'))}</h3><p class="small">${escapeHtml([text(item.file), text(item.line)].filter(Boolean).join(':'))}</p><p>${escapeHtml(text(item.detail, 'Add review note.'))}</p></article>`).join('')}</div>
765
+ <div class="panel"><h2>Review Tour</h2>${files.length > 0 ? files.map((item) => `<article class="card"><h3>${escapeHtml(text(item.path, 'File'))}</h3><p>${escapeHtml(text(item.why, 'Why this file matters.'))}</p></article>`).join('') : '<p class="muted">Add files with path and why fields for a guided review.</p>'}</div></section>
766
+ ${diff ? `<section class="panel" style="margin-top:14px"><h2>Diff Excerpt</h2>${codeBlock(diff)}</section>` : ''}`;
767
+ return page({ title, kind: 'review-sheet', summary: descriptor.description, data, body });
768
+ }
769
+
770
+ function renderPrWriteup({ title, data, descriptor }: Parameters<PrimitiveRenderer>[0]): string {
771
+ const files = fieldRecords(data, 'files', [{ path: 'src/example.ts', why: 'Core behavior changed here.', focus: 'Review edge cases and tests.' }]);
772
+ const body = `<section class="panel"><h2>Summary</h2><p>${escapeHtml(text(data.summary, 'Summarize the change in one reviewer-friendly paragraph.'))}</p><p>${escapeHtml(text(data.why, 'Explain why this change matters now.'))}</p></section>
773
+ <section class="two" style="margin-top:14px"><div class="card"><h2>Before</h2>${list(fieldStrings(data, 'before', ['Current behavior or pain point.']))}</div><div class="card emphasis"><h2>After</h2>${list(fieldStrings(data, 'after', ['New behavior or reviewer-visible outcome.']))}</div></section>
774
+ <section class="three" style="margin-top:14px"><div class="panel"><h2>File Tour</h2>${files.map((file) => `<details open><summary><strong>${escapeHtml(text(file.path, 'File'))}</strong></summary><p>${escapeHtml(text(file.why, 'Why this file matters.'))}</p><p class="small">Focus: ${escapeHtml(text(file.focus, 'Review behavior and tests.'))}</p></details>`).join('')}</div>
775
+ <div class="panel"><h2>Review Focus</h2>${list(fieldStrings(data, 'reviewFocus', ['Correctness of changed behavior.', 'Missing regression coverage.']))}<h2 style="margin-top:18px">Tests</h2>${list(fieldStrings(data, 'tests', ['Add or run targeted tests.']))}</div>
776
+ <div class="panel sticky"><h2>Rollout</h2>${list(fieldStrings(data, 'rollout', ['Merge behind normal release flow.']))}<button type="button" data-copy-markdown style="margin-top:12px">Copy PR markdown</button></div></section>`;
777
+ return page({
778
+ title,
779
+ kind: 'pr-writeup',
780
+ summary: descriptor.description,
781
+ data,
782
+ body,
783
+ script: `
784
+ function prMarkdown() {
785
+ const d = PMX_DATA;
786
+ const lines = ['## Summary', d.summary || '', '', '## Why', d.why || '', '', '## Before / After'];
787
+ (d.before || []).forEach((item) => lines.push('- Before: ' + item));
788
+ (d.after || []).forEach((item) => lines.push('- After: ' + item));
789
+ lines.push('', '## Review Focus');
790
+ (d.reviewFocus || []).forEach((item) => lines.push('- ' + item));
791
+ lines.push('', '## Tests');
792
+ (d.tests || []).forEach((item) => lines.push('- ' + item));
793
+ return lines.join('\\n');
794
+ }
795
+ document.querySelector('[data-copy-markdown]')?.addEventListener('click', () => copyText(prMarkdown()));`,
796
+ });
797
+ }
798
+
799
+ function renderSystemMap({ title, data, descriptor }: Parameters<PrimitiveRenderer>[0]): string {
800
+ const modules = fieldRecords(data, 'modules', [
801
+ { id: 'agent', title: 'Agent', detail: 'Chooses an operation and passes structured data.', role: 'entry' },
802
+ { id: 'api', title: 'HTTP/MCP API', detail: 'Validates input and creates nodes.', role: 'boundary' },
803
+ { id: 'state', title: 'Canvas State', detail: 'Persists nodes, edges, and pins.', role: 'core' },
804
+ { id: 'browser', title: 'Workbench', detail: 'Renders the shared canvas.', role: 'view' },
805
+ ]);
806
+ const edges = fieldRecords(data, 'edges', [
807
+ { from: 'agent', to: 'api', label: 'calls' },
808
+ { from: 'api', to: 'state', label: 'mutates' },
809
+ { from: 'state', to: 'browser', label: 'SSE update' },
810
+ ]);
811
+ const entryPoints = fieldStrings(data, 'entryPoints', ['MCP tools', 'CLI commands', 'HTTP API']);
812
+ const body = `<section class="panel"><h2>Entry Points</h2><div class="swatches">${entryPoints.map((entry) => badge(entry)).join('')}</div></section>
813
+ <section class="grid" style="margin-top:14px">${modules.map((item) => `<article class="card"><div class="small">${escapeHtml(text(item.role, text(item.id, 'module')))}</div><h2>${escapeHtml(itemTitle(item, 'Module'))}</h2><p>${escapeHtml(text(item.detail, 'Describe this module.'))}</p></article>`).join('')}</section>
814
+ <section class="panel" style="margin-top:14px"><h2>Relationships</h2><div class="flow">${edges.map((item) => `<div class="flow-node"><strong>${escapeHtml(text(item.from, 'from'))}</strong><p>${escapeHtml(text(item.label, 'connects'))}</p><strong>${escapeHtml(text(item.to, 'to'))}</strong></div>`).join('<div class="arrow">+</div>')}</div></section>`;
815
+ return page({ title, kind: 'system-map', summary: descriptor.description, data, body });
816
+ }
817
+
818
+ function renderCodeWalkthrough({ title, data, descriptor }: Parameters<PrimitiveRenderer>[0]): string {
819
+ const modules = fieldRecords(data, 'modules', [
820
+ { id: 'entry', title: 'Entry Point', detail: 'Request or command enters here.', role: 'entry' },
821
+ { id: 'core', title: 'Core Logic', detail: 'State change or main computation.', role: 'core' },
822
+ { id: 'view', title: 'Renderer', detail: 'User-visible result.', role: 'view' },
823
+ ]);
824
+ const steps = fieldRecords(data, 'steps', [{ title: 'Trace the path', file: 'src/example.ts', detail: 'Explain the first important hop.', code: '' }]);
825
+ const keyFiles = fieldRecords(data, 'keyFiles', []);
826
+ const edges = fieldRecords(data, 'edges', []);
827
+ const body = `<section class="panel"><h2>Path Summary</h2><p>${escapeHtml(text(data.summary, 'Explain the code path this walkthrough covers.'))}</p><div class="flow" style="margin-top:12px">${modules.map((module) => `<div class="flow-node"><div class="small">${escapeHtml(text(module.role, text(module.id, 'module')))}</div><h3>${escapeHtml(itemTitle(module, 'Module'))}</h3><p>${escapeHtml(text(module.detail, ''))}</p></div>`).join('<div class="arrow">-></div>')}</div>${edges.length > 0 ? `<p class="small" style="margin-top:10px">Edges: ${escapeHtml(edges.map((edge) => `${text(edge.from)} -> ${text(edge.to)}${text(edge.label) ? ` (${text(edge.label)})` : ''}`).join(', '))}</p>` : ''}</section>
828
+ <section class="three" style="margin-top:14px"><div class="panel"><h2>Walkthrough</h2>${steps.map((step, index) => `<details ${index === 0 ? 'open' : ''}><summary><strong>${index + 1}. ${escapeHtml(itemTitle(step, 'Step'))}</strong></summary><p class="small">${escapeHtml(text(step.file, ''))}</p><p>${escapeHtml(text(step.detail, ''))}</p>${codeBlock(step.code)}</details>`).join('')}</div>
829
+ <div class="panel"><h2>Key Files</h2>${keyFiles.length > 0 ? keyFiles.map((file) => `<article class="card"><h3>${escapeHtml(text(file.path, 'File'))}</h3><p>${escapeHtml(text(file.description, text(file.why, '')))}</p></article>`).join('') : '<p class="muted">No key files listed.</p>'}</div>
830
+ <div class="panel sticky"><h2>Gotchas</h2>${list(fieldStrings(data, 'gotchas', ['Watch for hidden state, async boundaries, and validation gaps.']))}</div></section>`;
831
+ return page({ title, kind: 'code-walkthrough', summary: descriptor.description, data, body });
832
+ }
833
+
834
+ function renderDesignSheet({ title, data, descriptor }: Parameters<PrimitiveRenderer>[0]): string {
835
+ const directions = fieldRecords(data, 'directions', [
836
+ { title: 'Editorial dense', tone: 'serious, information-rich', palette: ['#f4efe5', '#17120f', '#c84f2f'], rationale: 'Good when humans need to compare many details quickly.' },
837
+ { title: 'Control-room dark', tone: 'operational, high contrast', palette: ['#07111d', '#dce8f2', '#4a9eff'], rationale: 'Good for dashboards and incident views.' },
838
+ ]);
839
+ const tokens = fieldRecords(data, 'tokens', []);
840
+ const body = `<section class="grid">${directions.map((item) => {
841
+ const palette = strings(item.palette);
842
+ return `<article class="card"><h2>${escapeHtml(itemTitle(item, 'Direction'))}</h2><p>${escapeHtml(text(item.tone, 'Tone'))}</p><div class="swatches">${palette.map((color) => `<span class="swatch" title="${escapeHtml(color)}" style="background:${safeCssColor(color)}"></span>`).join('')}</div><p>${escapeHtml(text(item.rationale, 'Rationale'))}</p></article>`;
843
+ }).join('')}</section>
844
+ ${tokens.length > 0 ? `<section class="panel" style="margin-top:14px"><h2>Tokens</h2><div class="grid">${tokens.map((item) => `<div class="card"><strong>${escapeHtml(itemTitle(item, 'Token'))}</strong><p>${escapeHtml(text(item.value, ''))}</p></div>`).join('')}</div></section>` : ''}`;
845
+ return page({ title, kind: 'design-sheet', summary: descriptor.description, data, body });
846
+ }
847
+
848
+ function renderComponentGallery({ title, data, descriptor }: Parameters<PrimitiveRenderer>[0]): string {
849
+ const component = text(data.component, 'Component');
850
+ const variants = fieldRecords(data, 'variants', [
851
+ { label: 'Primary', state: 'default', intent: 'Main action', example: 'Continue', note: 'High emphasis.' },
852
+ { label: 'Secondary', state: 'hover', intent: 'Alternative action', example: 'Back', note: 'Lower emphasis.' },
853
+ { label: 'Destructive', state: 'disabled', intent: 'Danger zone', example: 'Delete', note: 'Requires confirmation.' },
854
+ ]);
855
+ const body = `<section class="panel"><h2>${escapeHtml(component)}</h2><p>Variant contact sheet for fast visual review.</p></section>
856
+ <section class="grid" style="margin-top:14px">${variants.map((item) => `<article class="card"><div class="small">${escapeHtml(text(item.state, 'state'))} / ${escapeHtml(text(item.intent, 'intent'))}</div><h2>${escapeHtml(itemTitle(item, 'Variant'))}</h2><button type="button">${escapeHtml(text(item.example, itemTitle(item, 'Example')))}</button><p>${escapeHtml(text(item.note, ''))}</p></article>`).join('')}</section>`;
857
+ return page({ title, kind: 'component-gallery', summary: descriptor.description, data, body });
858
+ }
859
+
860
+ function renderInteractionPrototype({ title, data, descriptor }: Parameters<PrimitiveRenderer>[0]): string {
861
+ const controls = fieldRecords(data, 'controls', [{ key: 'duration', label: 'Duration', value: 280, min: 100, max: 900, unit: 'ms' }]);
862
+ const screens = fieldRecords(data, 'screens', [
863
+ { title: 'Start', detail: 'Initial state before interaction.' },
864
+ { title: 'Active', detail: 'The user has started the interaction.' },
865
+ { title: 'Done', detail: 'Final state after completion.' },
866
+ ]);
867
+ const annotations = fieldRecords(data, 'annotations', [{ title: 'Decision', detail: 'Tune the values until this feels right.' }]);
868
+ const body = `<section class="three"><div class="panel"><h2>Stage</h2><p>${escapeHtml(text(data.scenario, 'Describe the interaction this prototype evaluates.'))}</p><div class="flow" style="margin-top:16px">${screens.map((screen, index) => `<button class="flow-node" type="button" data-screen="${index}"><h3>${escapeHtml(itemTitle(screen, 'Screen'))}</h3><p>${escapeHtml(text(screen.detail, ''))}</p></button>`).join('<div class="arrow">-></div>')}</div><div class="card emphasis" id="prototype-readout" style="margin-top:14px">Select a screen to inspect it.</div></div>
869
+ <div class="panel"><h2>Controls</h2>${controls.map((control, index) => `<label class="card"><span class="small">${escapeHtml(text(control.key, `control${index}`))}</span><h3>${escapeHtml(text(control.label, 'Control'))}: <span data-control-value="${index}">${escapeHtml(text(control.value, '0'))}</span>${escapeHtml(text(control.unit, ''))}</h3><input type="range" data-control="${index}" min="${number(control.min, 0)}" max="${number(control.max, 1000)}" value="${number(control.value, 0)}"></label>`).join('')}</div>
870
+ <div class="panel sticky"><h2>Notes</h2>${annotations.map((item) => `<article class="card"><h3>${escapeHtml(itemTitle(item, 'Note'))}</h3><p>${escapeHtml(text(item.detail, ''))}</p></article>`).join('')}<h2 style="margin-top:18px">Questions</h2>${list(fieldStrings(data, 'questions', ['Does the timing feel responsive?', 'What should persist after completion?']))}${codeBlock(data.snippet)}</div></section>`;
871
+ return page({
872
+ title,
873
+ kind: 'interaction-prototype',
874
+ summary: descriptor.description,
875
+ data,
876
+ body,
877
+ script: `
878
+ const screens = PMX_DATA.screens || [];
879
+ document.querySelectorAll('[data-screen]').forEach((button) => button.addEventListener('click', () => {
880
+ const screen = screens[Number(button.getAttribute('data-screen'))] || {};
881
+ document.getElementById('prototype-readout').textContent = (screen.title || 'Screen') + ': ' + (screen.detail || '');
882
+ }));
883
+ document.querySelectorAll('[data-control]').forEach((input) => input.addEventListener('input', () => {
884
+ document.querySelector('[data-control-value="' + input.getAttribute('data-control') + '"]').textContent = input.value;
885
+ }));
886
+ window.__pmxGetCopyJson = () => ({ ...PMX_DATA, controls: Array.from(document.querySelectorAll('[data-control]')).map((input, index) => ({ ...(PMX_DATA.controls || [])[index], value: Number(input.value) })) });`,
887
+ });
888
+ }
889
+
890
+ function renderFlowchart({ title, data, descriptor }: Parameters<PrimitiveRenderer>[0]): string {
891
+ const steps = fieldRecords(data, 'steps', [
892
+ { title: 'Receive request', detail: 'Validate inputs and determine target path.', status: 'ok', duration: '10ms' },
893
+ { title: 'Run operation', detail: 'Mutate server-side canvas state.', status: 'ok', duration: '25ms' },
894
+ { title: 'Emit event', detail: 'Notify browser and agents through SSE/resources.', status: 'ok', duration: '5ms' },
895
+ ]);
896
+ const failurePaths = fieldRecords(data, 'failurePaths', []);
897
+ const body = `<section class="flow">${steps.map((item, index) => `<button class="flow-node" type="button" data-step="${index}"><h3>${escapeHtml(itemTitle(item, `Step ${index + 1}`))}</h3><p>${badge(text(item.status, 'step'))} ${escapeHtml(text(item.duration))}</p></button>${index < steps.length - 1 ? '<div class="arrow">-></div>' : ''}`).join('')}</section>
898
+ <section class="panel" style="margin-top:14px"><h2 id="step-title">${escapeHtml(itemTitle(steps[0] ?? {}, 'Step'))}</h2><p id="step-detail">${escapeHtml(text((steps[0] ?? {}).detail, 'Select a step.'))}</p></section>
899
+ ${failurePaths.length > 0 ? `<section class="panel" style="margin-top:14px"><h2>Failure Paths</h2>${failurePaths.map((item) => `<article class="card"><h3>${escapeHtml(text(item.from, 'Step'))}: ${escapeHtml(text(item.label, 'failure'))}</h3><p>${escapeHtml(text(item.detail, ''))}</p></article>`).join('')}</section>` : ''}`;
900
+ return page({
901
+ title,
902
+ kind: 'flowchart',
903
+ summary: descriptor.description,
904
+ data,
905
+ body,
906
+ script: `
907
+ const steps = PMX_DATA.steps || [];
908
+ document.querySelectorAll('[data-step]').forEach((button) => button.addEventListener('click', () => {
909
+ const step = steps[Number(button.getAttribute('data-step'))] || {};
910
+ document.getElementById('step-title').textContent = step.title || step.label || 'Step';
911
+ document.getElementById('step-detail').textContent = step.detail || step.description || '';
912
+ }));`,
913
+ });
914
+ }
915
+
916
+ function renderIllustrationSet({ title, data, descriptor }: Parameters<PrimitiveRenderer>[0]): string {
917
+ const figures = fieldRecords(data, 'figures', [{ title: 'System Loop', caption: 'A small editable SVG-style figure.', shapes: [{ type: 'rect', x: 40, y: 60, width: 160, height: 72, text: 'Agent', color: '#4a9eff' }, { type: 'arrow', x1: 210, y1: 96, x2: 340, y2: 96, color: '#eab308' }, { type: 'circle', cx: 420, cy: 96, r: 42, text: 'Human', color: '#22c55e' }] }]);
918
+ const body = `<section class="grid">${figures.map((figure, index) => {
919
+ const shapes = records(figure.shapes);
920
+ return `<article class="card figure"><h2>${escapeHtml(itemTitle(figure, `Figure ${index + 1}`))}</h2><svg viewBox="0 0 560 260" role="img" aria-label="${escapeHtml(itemTitle(figure, `Figure ${index + 1}`))}" data-figure="${index}"><defs><marker id="arrow" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto" markerUnits="strokeWidth"><path d="M0,0 L0,6 L9,3 z" fill="currentColor"></path></marker></defs>${shapes.map(inlineSvgShape).join('')}</svg><p>${escapeHtml(text(figure.caption, ''))}</p><button type="button" data-copy-svg="${index}">Copy SVG</button></article>`;
921
+ }).join('')}</section>`;
922
+ return page({
923
+ title,
924
+ kind: 'illustration-set',
925
+ summary: descriptor.description,
926
+ data,
927
+ body,
928
+ script: `
929
+ document.querySelectorAll('[data-copy-svg]').forEach((button) => button.addEventListener('click', () => {
930
+ const svg = document.querySelector('[data-figure="' + button.getAttribute('data-copy-svg') + '"]');
931
+ copyText(svg ? svg.outerHTML : '');
932
+ }));`,
933
+ });
934
+ }
935
+
936
+ function renderDeck({ title, data, descriptor }: Parameters<PrimitiveRenderer>[0]): string {
937
+ const slides = presentationSlides(data, DEFAULT_DECK_SLIDES);
938
+ const body = `<section class="panel"><div class="small"><span id="slide-count">1</span> / ${slides.length} - use left/right arrows</div>${slides.map((item, index) => `<article class="slide ${index === 0 ? 'active' : ''}" data-slide="${index}"><div><div class="kicker">${escapeHtml(text(item.kicker, `Slide ${index + 1}`))}</div><h2>${escapeHtml(itemTitle(item, 'Slide'))}</h2><p>${escapeHtml(text(item.body, ''))}</p>${list(strings(item.bullets))}<p class="small">${escapeHtml(text(item.note, ''))}</p></div></article>`).join('')}</section>`;
939
+ return page({
940
+ title,
941
+ kind: 'deck',
942
+ summary: descriptor.description,
943
+ data,
944
+ body,
945
+ script: `
946
+ let currentSlide = 0;
947
+ const slides = Array.from(document.querySelectorAll('[data-slide]'));
948
+ function showSlide(index) {
949
+ currentSlide = Math.max(0, Math.min(slides.length - 1, index));
950
+ slides.forEach((slide, i) => slide.classList.toggle('active', i === currentSlide));
951
+ document.getElementById('slide-count').textContent = String(currentSlide + 1);
952
+ }
953
+ document.addEventListener('keydown', (event) => {
954
+ if (event.key === 'ArrowRight' || event.key === 'PageDown' || event.key === ' ') showSlide(currentSlide + 1);
955
+ if (event.key === 'ArrowLeft' || event.key === 'PageUp') showSlide(currentSlide - 1);
956
+ });`,
957
+ });
958
+ }
959
+
960
+ function renderPresentation({ title, data, descriptor }: Parameters<PrimitiveRenderer>[0]): string {
961
+ const slides = presentationSlides(data);
962
+ const subtitle = text(data.subtitle, descriptor.description);
963
+ const theme = presentationTheme(data);
964
+ const accentOverride = safeCssColor(text(data.accent, ''));
965
+ const accent = accentOverride === 'transparent' ? theme.accent : accentOverride;
966
+ const slideMarkup = slides.map((item, index) => {
967
+ const metrics = records(item.metrics);
968
+ return `<article class="slide ${index === 0 ? 'active' : ''}" data-slide="${index}">
969
+ <div class="slide-grid ${metrics.length > 0 ? 'with-metrics' : 'without-metrics'}">
970
+ <div class="slide-copy">
971
+ <div class="kicker">${escapeHtml(text(item.kicker, `Slide ${index + 1}`))}</div>
972
+ <h2>${escapeHtml(itemTitle(item, 'Slide'))}</h2>
973
+ ${text(item.body) ? `<p class="lede">${escapeHtml(text(item.body))}</p>` : ''}
974
+ ${list(strings(item.bullets))}
975
+ </div>
976
+ ${metrics.length > 0 ? `<div class="metrics">${metrics.map((metric) => `<div class="metric"><span>${escapeHtml(text(metric.label, 'Metric'))}</span><strong>${escapeHtml(text(metric.value, '0'))}</strong>${text(metric.detail) ? `<p>${escapeHtml(text(metric.detail))}</p>` : ''}</div>`).join('')}</div>` : ''}
977
+ </div>
978
+ ${text(item.note) ? `<aside class="speaker-note"><span>Speaker note</span>${escapeHtml(text(item.note))}</aside>` : ''}
979
+ </article>`;
980
+ }).join('');
981
+
982
+ return `<!doctype html>
983
+ <html lang="en">
984
+ <head>
985
+ <meta charset="utf-8">
986
+ <meta name="viewport" content="width=device-width, initial-scale=1">
987
+ <title>${escapeHtml(title)}</title>
988
+ <style>
989
+ :root { color-scheme: ${theme.colorScheme}; --deck-accent: ${accent}; --deck-bg: ${theme.bg}; --deck-panel: ${theme.panel}; --deck-surface: ${theme.surface}; --deck-border: ${theme.border}; --deck-text: ${theme.text}; --deck-text-secondary: ${theme.textSecondary}; --deck-text-muted: ${theme.textMuted}; }
990
+ * { box-sizing: border-box; }
991
+ html, body { width: 100%; height: 100%; overflow: hidden; }
992
+ body { margin: 0; padding: 0; background: var(--deck-bg); color: var(--deck-text); font-family: var(--font-sans, ui-sans-serif, system-ui, sans-serif); }
993
+ .deck { height: 100vh; min-height: 0; display: grid; grid-template-rows: auto minmax(0, 1fr) auto; background: radial-gradient(circle at 10% 10%, color-mix(in srgb, var(--deck-accent) 32%, transparent), transparent 28rem), linear-gradient(135deg, color-mix(in srgb, var(--deck-panel) 88%, black), var(--deck-bg)); }
994
+ .topbar, .bottombar { display: flex; align-items: center; justify-content: space-between; gap: 14px; padding: clamp(12px, 2.5vmin, 24px) clamp(18px, 4vw, 48px); color: var(--deck-text-secondary); }
995
+ .brand { display: grid; gap: 2px; }
996
+ .brand p { margin: 0; }
997
+ .eyebrow { color: var(--deck-accent); font-size: 11px; font-weight: 900; letter-spacing: .18em; text-transform: uppercase; }
998
+ .title { max-width: 70vw; overflow: hidden; color: var(--deck-text); font-size: clamp(16px, 2vw, 24px); font-weight: 850; letter-spacing: -.03em; text-overflow: ellipsis; white-space: nowrap; }
999
+ .controls { display: flex; gap: 8px; flex-wrap: wrap; justify-content: flex-end; }
1000
+ button { border: 1px solid color-mix(in srgb, var(--deck-border) 82%, white); background: color-mix(in srgb, var(--deck-panel) 82%, transparent); color: var(--deck-text); border-radius: 999px; padding: 8px 12px; font: 700 12px/1 var(--font-sans, ui-sans-serif, system-ui, sans-serif); cursor: pointer; }
1001
+ button:hover, button.active { border-color: var(--deck-accent); color: var(--deck-text); background: color-mix(in srgb, var(--deck-accent) 18%, var(--deck-panel)); }
1002
+ .slides { min-height: 0; overflow-x: hidden; overflow-y: auto; overscroll-behavior: contain; scrollbar-gutter: stable; }
1003
+ .slide { display: none; min-height: 100%; align-content: center; gap: clamp(16px, 3vmin, 28px); padding: clamp(24px, 5vmin, 64px) clamp(28px, 6vw, 92px); }
1004
+ .slide.active { display: grid; }
1005
+ .slide-grid { display: grid; gap: clamp(24px, 5vw, 72px); align-items: center; }
1006
+ .slide-grid.with-metrics { grid-template-columns: minmax(0, 1.15fr) minmax(260px, .85fr); }
1007
+ .slide-grid.without-metrics { grid-template-columns: minmax(0, 1fr); }
1008
+ .slide-copy { max-width: min(1120px, 100%); }
1009
+ .kicker { color: var(--deck-accent); font-size: clamp(12px, 1.8vw, 18px); font-weight: 950; letter-spacing: .18em; text-transform: uppercase; }
1010
+ h2 { margin: 10px 0 18px; max-width: 16ch; font-size: clamp(40px, 8vmin, 104px); line-height: .9; letter-spacing: -.07em; }
1011
+ .lede { max-width: 900px; margin: 0 0 20px; color: var(--deck-text-secondary); font-size: clamp(18px, 3vmin, 32px); line-height: 1.14; letter-spacing: -.03em; }
1012
+ ul { display: grid; gap: 12px; max-width: 780px; margin: 0; padding: 0; list-style: none; }
1013
+ li { position: relative; padding-left: 30px; color: var(--deck-text-secondary); font-size: clamp(17px, 2.2vmin, 25px); line-height: 1.22; }
1014
+ li::before { content: ''; position: absolute; left: 0; top: .42em; width: 12px; height: 12px; border-radius: 50%; background: var(--deck-accent); box-shadow: 0 0 24px color-mix(in srgb, var(--deck-accent) 60%, transparent); }
1015
+ .metrics { display: grid; gap: 14px; }
1016
+ .metric { border: 1px solid color-mix(in srgb, var(--deck-accent) 42%, var(--deck-border)); border-radius: 28px; padding: 22px; background: color-mix(in srgb, var(--deck-panel) 78%, transparent); box-shadow: 0 24px 70px rgba(0,0,0,.26); }
1017
+ .metric span { color: var(--deck-text-muted); font-size: 11px; font-weight: 900; letter-spacing: .14em; text-transform: uppercase; }
1018
+ .metric strong { display: block; margin-top: 8px; font-size: clamp(34px, 6vw, 78px); line-height: .9; letter-spacing: -.06em; }
1019
+ .metric p { margin: 10px 0 0; color: var(--deck-text-secondary); }
1020
+ .speaker-note { max-width: min(1120px, 100%); border-left: 4px solid var(--deck-accent); padding: 10px 14px; color: var(--deck-text-muted); background: color-mix(in srgb, var(--deck-panel) 76%, transparent); border-radius: 14px; }
1021
+ .speaker-note span { display: block; margin-bottom: 2px; color: var(--deck-accent); font-size: 10px; font-weight: 900; letter-spacing: .12em; text-transform: uppercase; }
1022
+ .dots { display: flex; gap: 7px; align-items: center; }
1023
+ .dot { width: 30px; height: 7px; border: 0; border-radius: 999px; padding: 0; background: color-mix(in srgb, var(--deck-text) 24%, transparent); }
1024
+ .dot.active { width: 54px; background: var(--deck-accent); }
1025
+ .hint { font-size: 12px; color: var(--deck-text-muted); }
1026
+ html[data-pmx-presentation-mode="present"] .hint { display: none; }
1027
+ .progress { height: 3px; width: 180px; overflow: hidden; border-radius: 999px; background: color-mix(in srgb, var(--deck-text) 18%, transparent); }
1028
+ .progress span { display: block; height: 100%; width: 0; background: var(--deck-accent); transition: width .2s ease; }
1029
+ @media (max-width: 820px) { .slide-grid { grid-template-columns: 1fr; } .metrics { grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); } h2 { max-width: none; font-size: clamp(42px, 14vw, 78px); } .lede, li { font-size: 21px; } .topbar { align-items: flex-start; flex-direction: column; } .title { max-width: 100%; } }
1030
+ </style>
1031
+ </head>
1032
+ <body>
1033
+ <main class="deck">
1034
+ <header class="topbar">
1035
+ <div class="brand"><div class="eyebrow">PMX presentation</div><div class="title">${escapeHtml(title)}</div><p>${escapeHtml(subtitle)}</p></div>
1036
+ </header>
1037
+ <section class="slides">${slideMarkup}</section>
1038
+ <footer class="bottombar">
1039
+ <div class="dots">${slides.map((_, index) => `<button class="dot ${index === 0 ? 'active' : ''}" type="button" data-dot="${index}" aria-label="Go to slide ${index + 1}"></button>`).join('')}</div>
1040
+ <div class="hint"><span id="slide-current">1</span> / ${slides.length} - Arrow keys, Space, Page Up/Down</div>
1041
+ <div class="progress" aria-hidden="true"><span id="slide-progress"></span></div>
1042
+ </footer>
1043
+ </main>
1044
+ <script type="application/json" id="pmx-data">${safeJson(data)}</script>
1045
+ <script>
1046
+ let currentSlide = 0;
1047
+ const slides = Array.from(document.querySelectorAll('[data-slide]'));
1048
+ const dots = Array.from(document.querySelectorAll('[data-dot]'));
1049
+ function showSlide(index) {
1050
+ currentSlide = Math.max(0, Math.min(slides.length - 1, index));
1051
+ slides.forEach((slide, i) => slide.classList.toggle('active', i === currentSlide));
1052
+ dots.forEach((dot, i) => dot.classList.toggle('active', i === currentSlide));
1053
+ document.getElementById('slide-current').textContent = String(currentSlide + 1);
1054
+ document.getElementById('slide-progress').style.width = String(((currentSlide + 1) / slides.length) * 100) + '%';
1055
+ }
1056
+ dots.forEach((dot) => dot.addEventListener('click', () => showSlide(Number(dot.getAttribute('data-dot')))));
1057
+ function handlePresentationKey(key) {
1058
+ if (key === 'ArrowRight' || key === 'PageDown' || key === ' ') { showSlide(currentSlide + 1); return true; }
1059
+ if (key === 'ArrowLeft' || key === 'PageUp') { showSlide(currentSlide - 1); return true; }
1060
+ if (key === 'Home') { showSlide(0); return true; }
1061
+ if (key === 'End') { showSlide(slides.length - 1); return true; }
1062
+ return false;
1063
+ }
1064
+ window.PMX_CANVAS_PRESENTATION_HANDLE_KEY = handlePresentationKey;
1065
+ document.addEventListener('pmx-presentation-key', (event) => {
1066
+ if (!event.detail || typeof event.detail.key !== 'string') return;
1067
+ handlePresentationKey(event.detail.key);
1068
+ });
1069
+ document.addEventListener('keydown', (event) => {
1070
+ const tag = event.target && event.target.tagName;
1071
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
1072
+ if (handlePresentationKey(event.key)) event.preventDefault();
1073
+ });
1074
+ showSlide(0);
1075
+ </script>
1076
+ </body>
1077
+ </html>`;
1078
+ }
1079
+
1080
+ function renderExplainer({ title, data, descriptor }: Parameters<PrimitiveRenderer>[0]): string {
1081
+ const steps = fieldRecords(data, 'steps', [{ title: 'Start here', detail: 'Add request path, data flow, or concept steps.' }]);
1082
+ const snippets = fieldRecords(data, 'snippets', []);
1083
+ const faq = fieldRecords(data, 'faq', []);
1084
+ const glossary = fieldRecords(data, 'glossary', []);
1085
+ const body = `<section class="panel"><h2>TLDR</h2><p>${escapeHtml(text(data.summary, 'Add the one-paragraph explanation a reader should remember.'))}</p></section>
1086
+ <section class="grid" style="margin-top:14px">${steps.map((item, index) => `<details open><summary><strong>${index + 1}. ${escapeHtml(itemTitle(item, 'Step'))}</strong></summary><p>${escapeHtml(text(item.detail, ''))}</p></details>`).join('')}</section>
1087
+ ${snippets.length > 0 ? `<section class="panel" style="margin-top:14px"><h2>Annotated Snippets</h2>${snippets.map((item) => `<h3>${escapeHtml(itemTitle(item, 'Snippet'))}</h3><p>${escapeHtml(text(item.note, ''))}</p>${codeBlock(item.code)}`).join('')}</section>` : ''}
1088
+ <section class="two" style="margin-top:14px"><div class="panel"><h2>FAQ</h2>${faq.map((item) => `<details><summary>${escapeHtml(text(item.q, 'Question'))}</summary><p>${escapeHtml(text(item.a, 'Answer'))}</p></details>`).join('') || '<p class="muted">No FAQ entries yet.</p>'}</div><div class="panel"><h2>Glossary</h2>${glossary.map((item) => `<div class="card"><strong>${escapeHtml(text(item.term, 'Term'))}</strong><p>${escapeHtml(text(item.definition, 'Definition'))}</p></div>`).join('') || '<p class="muted">No glossary entries yet.</p>'}</div></section>`;
1089
+ return page({ title, kind: 'explainer', summary: descriptor.description, data, body });
1090
+ }
1091
+
1092
+ function renderStatusReport({ title, data, descriptor }: Parameters<PrimitiveRenderer>[0]): string {
1093
+ const metrics = fieldRecords(data, 'metrics', [{ label: 'Health', value: 'on track', tone: 'ok' }, { label: 'Risk', value: 'medium', tone: 'warn' }]);
1094
+ const body = `<section class="grid">${metrics.map((item) => `<article class="card"><div class="small">${escapeHtml(text(item.label, 'Metric'))}</div><h2>${escapeHtml(text(item.value, 'Value'))}</h2>${badge(text(item.tone, 'info'))}</article>`).join('')}</section>
1095
+ <section class="grid" style="margin-top:14px"><article class="card"><h2>Shipped</h2>${list(fieldStrings(data, 'shipped', ['Add shipped items.']))}</article><article class="card"><h2>Slipped</h2>${list(fieldStrings(data, 'slipped', ['Add slipped items.']))}</article><article class="card"><h2>Risks</h2>${list(fieldStrings(data, 'risks', ['Add risks.']))}</article><article class="card"><h2>Next</h2>${list(fieldStrings(data, 'next', ['Add next actions.']))}</article></section>`;
1096
+ return page({ title, kind: 'status-report', summary: descriptor.description, data, body });
1097
+ }
1098
+
1099
+ function renderIncidentReport({ title, data, descriptor }: Parameters<PrimitiveRenderer>[0]): string {
1100
+ const impact = fieldRecords(data, 'impact', [
1101
+ { label: 'Severity', value: text(data.severity, 'SEV-2'), tone: 'warn' },
1102
+ { label: 'Status', value: text(data.status, 'resolved'), tone: 'ok' },
1103
+ { label: 'Duration', value: text(data.duration, 'unknown'), tone: 'info' },
1104
+ ]);
1105
+ const timeline = fieldRecords(data, 'timeline', [{ time: '00:00', event: 'Incident started', detail: 'Add the first observed signal.', tone: 'warn' }]);
1106
+ const actions = fieldRecords(data, 'actions', [{ done: false, owner: 'Unassigned', description: 'Add follow-up action.', due: 'TBD' }]);
1107
+ const body = `<section class="grid">${impact.map((item) => `<article class="card metric"><div class="small">${escapeHtml(text(item.label, 'Metric'))}</div><strong>${escapeHtml(text(item.value, 'Value'))}</strong>${badge(text(item.tone, 'info'))}</article>`).join('')}</section>
1108
+ <section class="panel" style="margin-top:14px"><h2>Executive Summary</h2><p>${escapeHtml(text(data.summary, 'Summarize user impact, detection, and resolution.'))}</p></section>
1109
+ <section class="three" style="margin-top:14px"><div class="panel"><h2>Timeline</h2><div class="timeline">${timeline.map((item, index) => `<div class="step"><div class="dot">${escapeHtml(text(item.time, String(index + 1)))}</div><div class="card"><h3>${escapeHtml(text(item.event, 'Event'))} ${badge(text(item.tone, 'info'))}</h3><p>${escapeHtml(text(item.detail, ''))}</p></div></div>`).join('')}</div></div>
1110
+ <div class="panel"><h2>Root Cause</h2><p>${escapeHtml(text(data.rootCause, 'Add confirmed or suspected root cause.'))}</p>${codeBlock(data.logs)}</div>
1111
+ <div class="panel sticky"><h2>Actions</h2>${actions.map((action) => `<label class="card"><h3><input type="checkbox" data-action ${action.done === true ? 'checked' : ''}> ${escapeHtml(text(action.description, 'Action'))}</h3><p class="small">${escapeHtml(text(action.owner, 'Owner'))} / ${escapeHtml(text(action.due, 'Due'))}</p></label>`).join('')}<button type="button" data-copy-actions>Copy actions</button></div></section>`;
1112
+ return page({
1113
+ title,
1114
+ kind: 'incident-report',
1115
+ summary: descriptor.description,
1116
+ data,
1117
+ body,
1118
+ script: `
1119
+ function actionState() {
1120
+ return Array.from(document.querySelectorAll('[data-action]')).map((input, index) => ({ ...(PMX_DATA.actions || [])[index], done: input.checked }));
1121
+ }
1122
+ window.__pmxGetCopyJson = () => ({ ...PMX_DATA, actions: actionState() });
1123
+ document.querySelector('[data-copy-actions]')?.addEventListener('click', () => copyText(actionState().map((action) => '- [' + (action.done ? 'x' : ' ') + '] ' + (action.description || 'Action') + ' (' + (action.owner || 'owner') + ', ' + (action.due || 'due') + ')').join('\\n')));`,
1124
+ });
1125
+ }
1126
+
1127
+ function renderTriageBoard({ title, data, descriptor }: Parameters<PrimitiveRenderer>[0]): string {
1128
+ const columns = fieldStrings(data, 'columns', ['Now', 'Next', 'Later', 'Cut']);
1129
+ const items = fieldRecords(data, 'items', [
1130
+ { title: 'Clarify requirements', detail: 'Human should decide scope boundary.', column: 'Now', rationale: 'Blocks accurate implementation.' },
1131
+ { title: 'Polish visuals', detail: 'Improve hierarchy after behavior lands.', column: 'Next', rationale: 'Useful but not blocking.' },
1132
+ ]);
1133
+ const body = `<section class="columns">${columns.map((column) => `<div class="column" data-column="${escapeHtml(column)}"><h2>${escapeHtml(column)}</h2>${items.filter((item) => text(item.column, columns[0]) === column).map((item, index) => `<article class="card ticket" draggable="true" data-ticket="${index}"><h3>${escapeHtml(itemTitle(item, 'Item'))}</h3><p>${escapeHtml(text(item.detail, ''))}</p><p class="small">${escapeHtml(text(item.rationale, ''))}</p></article>`).join('')}</div>`).join('')}</section><p class="small" style="margin-top:12px">Drag cards between columns, then copy JSON or markdown.</p><button type="button" data-copy-markdown>Copy markdown</button>`;
1134
+ return page({
1135
+ title,
1136
+ kind: 'triage-board',
1137
+ summary: descriptor.description,
1138
+ data,
1139
+ body,
1140
+ script: `
1141
+ let dragged = null;
1142
+ function boardState() {
1143
+ return Array.from(document.querySelectorAll('.column')).map((column) => ({
1144
+ column: column.getAttribute('data-column') || 'Column',
1145
+ items: Array.from(column.querySelectorAll('.ticket')).map((ticket) => ({
1146
+ title: ticket.querySelector('h3')?.textContent || '',
1147
+ detail: ticket.querySelector('p')?.textContent || '',
1148
+ })),
1149
+ }));
1150
+ }
1151
+ document.querySelectorAll('.ticket').forEach((ticket) => {
1152
+ ticket.addEventListener('dragstart', () => { dragged = ticket; });
1153
+ });
1154
+ document.querySelectorAll('.column').forEach((column) => {
1155
+ column.addEventListener('dragover', (event) => event.preventDefault());
1156
+ column.addEventListener('drop', () => { if (dragged) column.appendChild(dragged); });
1157
+ });
1158
+ function boardMarkdown() {
1159
+ return boardState().map((column) => {
1160
+ const items = column.items.map((item) => '- ' + item.title + ': ' + item.detail);
1161
+ return '## ' + column.column + '\\n' + (items.join('\\n') || '- None');
1162
+ }).join('\\n\\n');
1163
+ }
1164
+ window.__pmxGetCopyJson = () => ({ ...PMX_DATA, board: boardState() });
1165
+ document.querySelector('[data-copy-markdown]')?.addEventListener('click', () => copyText(boardMarkdown()));`,
1166
+ });
1167
+ }
1168
+
1169
+ function renderConfigEditor({ title, data, descriptor }: Parameters<PrimitiveRenderer>[0]): string {
1170
+ const flags = fieldRecords(data, 'flags', [
1171
+ { key: 'paymentsV2', label: 'Payments V2', area: 'Checkout', enabled: true, description: 'Required for the new checkout path.' },
1172
+ { key: 'newCheckout', label: 'New checkout', area: 'Checkout', enabled: false, requires: ['paymentsV2'], description: 'Routes users through the new flow.' },
1173
+ ]);
1174
+ const body = `<section class="grid">${flags.map((flag, index) => `<label class="card"><div class="small">${escapeHtml(text(flag.area, 'General'))}</div><h3><input type="checkbox" data-flag="${index}" ${flag.enabled === true ? 'checked' : ''}> ${escapeHtml(text(flag.label, text(flag.key, `Flag ${index + 1}`)))}</h3><p>${escapeHtml(text(flag.description, ''))}</p><p class="small">Key: ${escapeHtml(text(flag.key, 'unknown'))}${strings(flag.requires).length > 0 ? ` / requires: ${escapeHtml(strings(flag.requires).join(', '))}` : ''}</p><p class="small" data-warning="${index}"></p></label>`).join('')}</section><button type="button" data-copy-diff style="margin-top:12px">Copy diff</button>`;
1175
+ return page({
1176
+ title,
1177
+ kind: 'config-editor',
1178
+ summary: descriptor.description,
1179
+ data,
1180
+ body,
1181
+ script: `
1182
+ const originalFlags = PMX_DATA.flags || [];
1183
+ function flagState() {
1184
+ return originalFlags.map((flag, index) => ({ ...flag, enabled: document.querySelector('[data-flag="' + index + '"]').checked }));
1185
+ }
1186
+ function refreshWarnings() {
1187
+ const state = flagState();
1188
+ const byKey = new Map(state.map((flag) => [flag.key, flag]));
1189
+ state.forEach((flag, index) => {
1190
+ const missing = (flag.requires || []).filter((key) => flag.enabled && !byKey.get(key)?.enabled);
1191
+ document.querySelector('[data-warning="' + index + '"]').textContent = missing.length ? 'Warning: requires ' + missing.join(', ') : '';
1192
+ });
1193
+ }
1194
+ document.querySelectorAll('[data-flag]').forEach((input) => input.addEventListener('change', refreshWarnings));
1195
+ window.__pmxGetCopyJson = () => ({ ...PMX_DATA, flags: flagState() });
1196
+ document.querySelector('[data-copy-diff]')?.addEventListener('click', () => {
1197
+ const diff = flagState().filter((flag, index) => flag.enabled !== originalFlags[index]?.enabled).map((flag) => ({ key: flag.key, enabled: flag.enabled }));
1198
+ copyText(JSON.stringify(diff, null, 2));
1199
+ });
1200
+ refreshWarnings();`,
1201
+ });
1202
+ }
1203
+
1204
+ function renderPromptTuner({ title, data, descriptor }: Parameters<PrimitiveRenderer>[0]): string {
1205
+ const template = text(data.template, 'Explain {{feature}} for {{audience}}. Include the tradeoffs and one concrete example.');
1206
+ const samples = fieldRecords(data, 'samples', [{ name: 'Default', variables: { feature: 'PMX Canvas pins', audience: 'coding agents' } }]);
1207
+ const body = `<section class="two"><div class="panel"><h2>Template</h2><textarea id="template">${escapeHtml(template)}</textarea><p class="small"><span id="char-count">0</span> characters</p></div><div class="panel"><h2>Live Samples</h2><div id="previews"></div><button type="button" data-copy-template>Copy template</button></div></section>`;
1208
+ return page({
1209
+ title,
1210
+ kind: 'prompt-tuner',
1211
+ summary: descriptor.description,
1212
+ data: { ...data, samples },
1213
+ body,
1214
+ script: `
1215
+ const templateEl = document.getElementById('template');
1216
+ const previewsEl = document.getElementById('previews');
1217
+ const samples = PMX_DATA.samples || [];
1218
+ function fill(template, vars) {
1219
+ return template.replace(/{{\\s*([\\w.-]+)\\s*}}/g, (_, key) => vars?.[key] ?? '{{' + key + '}}');
1220
+ }
1221
+ function renderPreviews() {
1222
+ const value = templateEl.value;
1223
+ document.getElementById('char-count').textContent = String(value.length);
1224
+ previewsEl.innerHTML = samples.map((sample) => '<div class="card"><h3>' + (sample.name || 'Sample') + '</h3><div class="preview">' + fill(value, sample.variables || {}).replace(/[&<>]/g, (c) => ({'&':'&amp;','<':'&lt;','>':'&gt;'}[c])) + '</div></div>').join('');
1225
+ }
1226
+ templateEl.addEventListener('input', renderPreviews);
1227
+ window.__pmxGetCopyJson = () => ({ ...PMX_DATA, template: templateEl.value });
1228
+ document.querySelector('[data-copy-template]')?.addEventListener('click', () => copyText(templateEl.value));
1229
+ renderPreviews();`,
1230
+ });
1231
+ }
1232
+
1233
+ const RENDERERS: Record<HtmlPrimitiveKind, PrimitiveRenderer> = {
1234
+ 'choice-grid': renderChoiceGrid,
1235
+ 'plan-timeline': renderPlanTimeline,
1236
+ 'review-sheet': renderReviewSheet,
1237
+ 'pr-writeup': renderPrWriteup,
1238
+ 'system-map': renderSystemMap,
1239
+ 'code-walkthrough': renderCodeWalkthrough,
1240
+ 'design-sheet': renderDesignSheet,
1241
+ 'component-gallery': renderComponentGallery,
1242
+ 'interaction-prototype': renderInteractionPrototype,
1243
+ flowchart: renderFlowchart,
1244
+ 'illustration-set': renderIllustrationSet,
1245
+ deck: renderDeck,
1246
+ presentation: renderPresentation,
1247
+ explainer: renderExplainer,
1248
+ 'status-report': renderStatusReport,
1249
+ 'incident-report': renderIncidentReport,
1250
+ 'triage-board': renderTriageBoard,
1251
+ 'config-editor': renderConfigEditor,
1252
+ 'prompt-tuner': renderPromptTuner,
1253
+ };
1254
+
1255
+ export function isHtmlPrimitiveKind(value: string): value is HtmlPrimitiveKind {
1256
+ return (HTML_PRIMITIVE_KINDS as readonly string[]).includes(value);
1257
+ }
1258
+
1259
+ export function getHtmlPrimitiveDescriptor(kind: HtmlPrimitiveKind): HtmlPrimitiveDescriptor {
1260
+ const descriptor = DESCRIPTORS.find((entry) => entry.kind === kind);
1261
+ if (!descriptor) throw new Error(`Unknown HTML primitive: ${kind}`);
1262
+ return descriptor;
1263
+ }
1264
+
1265
+ export function listHtmlPrimitiveDescriptors(): HtmlPrimitiveDescriptor[] {
1266
+ return JSON.parse(JSON.stringify(DESCRIPTORS)) as HtmlPrimitiveDescriptor[];
1267
+ }
1268
+
1269
+ export function getHtmlPrimitiveSemanticMetadata(data: Record<string, unknown>): HtmlPrimitiveSemanticMetadata {
1270
+ if (data.presentation !== true) return {};
1271
+ const slideTitles = strings(data.slideTitles);
1272
+ const speakerNotes = strings(data.speakerNotes);
1273
+ const theme = presentationThemeMetadata(data);
1274
+ return {
1275
+ presentation: true,
1276
+ ...(typeof data.slideCount === 'number' && Number.isFinite(data.slideCount) ? { slideCount: data.slideCount } : {}),
1277
+ ...(slideTitles.length > 0 ? { slideTitles } : {}),
1278
+ ...(speakerNotes.length > 0 ? { speakerNotes } : {}),
1279
+ ...(theme !== undefined ? { presentationTheme: theme } : {}),
1280
+ };
1281
+ }
1282
+
1283
+ export function buildHtmlPrimitive(input: HtmlPrimitiveInput): HtmlPrimitiveBuildResult {
1284
+ const descriptor = getHtmlPrimitiveDescriptor(input.kind);
1285
+ const title = input.title ?? descriptor.title;
1286
+ const data = enrichPresentationData(input.kind, input.data ?? {});
1287
+ const renderer = RENDERERS[input.kind];
1288
+ const slideTitles = strings(data.slideTitles);
1289
+ const summary = slideTitles.length > 0
1290
+ ? `${descriptor.description} Slides: ${slideTitles.join(', ')}.`
1291
+ : descriptor.description;
1292
+ return {
1293
+ kind: input.kind,
1294
+ title,
1295
+ html: renderer({ title, data, descriptor }),
1296
+ summary,
1297
+ defaultSize: descriptor.defaultSize,
1298
+ data,
1299
+ };
1300
+ }