pmx-canvas 0.1.20 → 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 (37) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/dist/canvas/global.css +88 -0
  3. package/dist/canvas/index.js +87 -53
  4. package/dist/types/client/nodes/HtmlNode.d.ts +12 -1
  5. package/dist/types/client/types.d.ts +1 -1
  6. package/dist/types/server/html-node-summary.d.ts +2 -0
  7. package/dist/types/server/html-primitives.d.ts +9 -1
  8. package/dist/types/server/index.d.ts +8 -1
  9. package/docs/http-api.md +1 -1
  10. package/docs/mcp.md +4 -0
  11. package/docs/node-types.md +27 -5
  12. package/docs/screenshot.png +0 -0
  13. package/docs/sdk.md +1 -0
  14. package/package.json +1 -1
  15. package/skills/pmx-canvas/SKILL.md +10 -4
  16. package/skills/pmx-canvas/references/html-primitives.md +132 -0
  17. package/src/cli/agent.ts +9 -0
  18. package/src/cli/index.ts +1 -1
  19. package/src/client/App.tsx +1 -1
  20. package/src/client/canvas/CommandPalette.tsx +1 -1
  21. package/src/client/canvas/ExpandedNodeOverlay.tsx +105 -2
  22. package/src/client/canvas/auto-fit.ts +5 -1
  23. package/src/client/nodes/HtmlNode.tsx +125 -13
  24. package/src/client/state/sse-bridge.ts +1 -1
  25. package/src/client/theme/global.css +88 -0
  26. package/src/mcp/canvas-access.ts +31 -1
  27. package/src/mcp/server.ts +17 -3
  28. package/src/server/agent-context.ts +23 -1
  29. package/src/server/canvas-operations.ts +10 -2
  30. package/src/server/canvas-provenance.ts +8 -6
  31. package/src/server/canvas-schema.ts +11 -0
  32. package/src/server/canvas-serialization.ts +10 -5
  33. package/src/server/html-node-summary.ts +141 -0
  34. package/src/server/html-primitives.ts +318 -8
  35. package/src/server/index.ts +22 -3
  36. package/src/server/server.ts +17 -4
  37. package/src/server/spatial-analysis.ts +4 -2
@@ -0,0 +1,141 @@
1
+ const HTML_CONTENT_SUMMARY_MAX_LENGTH = 900;
2
+ const HTML_AGENT_SUMMARY_MAX_LENGTH = 1200;
3
+ const HTML_REFERENCE_LIMIT = 12;
4
+
5
+ function pickString(value: unknown): string | null {
6
+ return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
7
+ }
8
+
9
+ function strings(value: unknown): string[] {
10
+ return Array.isArray(value)
11
+ ? value.filter((item): item is string => typeof item === 'string' && item.trim().length > 0)
12
+ : [];
13
+ }
14
+
15
+ function truncateText(value: string, maxLength: number): string {
16
+ if (value.length <= maxLength) return value;
17
+ return `${value.slice(0, Math.max(0, maxLength - 3)).trimEnd()}...`;
18
+ }
19
+
20
+ function normalizeWhitespace(value: string): string {
21
+ return value.replace(/\s+/g, ' ').trim();
22
+ }
23
+
24
+ function decodeHtmlEntities(value: string): string {
25
+ const named: Record<string, string> = {
26
+ amp: '&',
27
+ gt: '>',
28
+ lt: '<',
29
+ nbsp: ' ',
30
+ quot: '"',
31
+ apos: "'",
32
+ };
33
+ return value.replace(/&(#x?[0-9a-f]+|[a-z]+);/gi, (match, entity) => {
34
+ const lower = entity.toLowerCase();
35
+ if (lower.startsWith('#x')) {
36
+ const codePoint = Number.parseInt(lower.slice(2), 16);
37
+ return Number.isInteger(codePoint) && codePoint >= 0 && codePoint <= 0x10ffff ? String.fromCodePoint(codePoint) : match;
38
+ }
39
+ if (lower.startsWith('#')) {
40
+ const codePoint = Number.parseInt(lower.slice(1), 10);
41
+ return Number.isInteger(codePoint) && codePoint >= 0 && codePoint <= 0x10ffff ? String.fromCodePoint(codePoint) : match;
42
+ }
43
+ return named[lower] ?? match;
44
+ });
45
+ }
46
+
47
+ export function summarizeHtmlText(html: string): string | null {
48
+ const withoutNoise = html
49
+ .replace(/<!--[\s\S]*?-->/g, ' ')
50
+ .replace(/<script\b[\s\S]*?<\/script>/gi, ' ')
51
+ .replace(/<style\b[\s\S]*?<\/style>/gi, ' ')
52
+ .replace(/<noscript\b[\s\S]*?<\/noscript>/gi, ' ');
53
+ const text = withoutNoise
54
+ .replace(/<(?:h[1-6]|p|li|br|div|section|article|header|footer|main|aside|summary|figcaption|blockquote|tr|td|th)\b[^>]*>/gi, '\n')
55
+ .replace(/<[^>]+>/g, ' ');
56
+ const normalized = normalizeWhitespace(decodeHtmlEntities(text));
57
+ return normalized.length > 0 ? truncateText(normalized, HTML_CONTENT_SUMMARY_MAX_LENGTH) : null;
58
+ }
59
+
60
+ function uniqueLimited(values: string[]): string[] {
61
+ const seen = new Set<string>();
62
+ const unique: string[] = [];
63
+ for (const value of values) {
64
+ const trimmed = value.trim();
65
+ if (!trimmed || seen.has(trimmed)) continue;
66
+ seen.add(trimmed);
67
+ unique.push(trimmed);
68
+ if (unique.length >= HTML_REFERENCE_LIMIT) break;
69
+ }
70
+ return unique;
71
+ }
72
+
73
+ function extractHtmlNodeIds(html: string): string[] {
74
+ const ids: string[] = [];
75
+ for (const match of html.matchAll(/\b(?:node|graph|json-render|web-artifact|mcp-app|group)-[a-z0-9-]+\b/gi)) {
76
+ ids.push(match[0]);
77
+ }
78
+ return uniqueLimited(ids);
79
+ }
80
+
81
+ function extractHtmlUrls(html: string): string[] {
82
+ const urls: string[] = [];
83
+ for (const match of html.matchAll(/\b(?:src|href)\s*=\s*["']([^"']+)["']/gi)) {
84
+ const url = match[1]?.trim();
85
+ if (!url) continue;
86
+ if (/^(?:https?:)?\/\//i.test(url) || url.startsWith('/') || url.startsWith('ui://')) {
87
+ urls.push(url);
88
+ }
89
+ }
90
+ return uniqueLimited(urls);
91
+ }
92
+
93
+ function joinSummaryParts(parts: string[]): string | null {
94
+ const summary = parts
95
+ .map((part) => part.trim())
96
+ .filter(Boolean)
97
+ .filter((part, index, all) => all.findIndex((candidate) => candidate === part) === index)
98
+ .join('\n');
99
+ return summary ? truncateText(summary, HTML_AGENT_SUMMARY_MAX_LENGTH) : null;
100
+ }
101
+
102
+ export function normalizeHtmlNodeSemanticData<T extends Record<string, unknown>>(data: T): T {
103
+ const {
104
+ agentSummary: _agentSummary,
105
+ contentSummary: _contentSummary,
106
+ embeddedNodeIds: _embeddedNodeIds,
107
+ embeddedUrls: _embeddedUrls,
108
+ embeddedNodeId: _embeddedNodeId,
109
+ embeddedGraphId: _embeddedGraphId,
110
+ sourceNodeId: _sourceNodeId,
111
+ ...base
112
+ } = data;
113
+
114
+ const html = pickString(base.html);
115
+ const explicitSummary = pickString(base.summary) ?? pickString(base.description);
116
+ const primitive = pickString(base.htmlPrimitive);
117
+ const contentSummary = html ? summarizeHtmlText(html) : null;
118
+ const explicitNodeIds = [
119
+ ...strings(data.embeddedNodeIds),
120
+ pickString(data.embeddedNodeId),
121
+ pickString(data.embeddedGraphId),
122
+ pickString(data.sourceNodeId),
123
+ ].filter((value): value is string => value !== null);
124
+ const embeddedNodeIds = uniqueLimited([...explicitNodeIds, ...(html ? extractHtmlNodeIds(html) : [])]);
125
+ const embeddedUrls = uniqueLimited([...strings(data.embeddedUrls), ...(html ? extractHtmlUrls(html) : [])]);
126
+ const agentSummary = pickString(data.agentSummary) ?? joinSummaryParts([
127
+ primitive ? `HTML primitive: ${primitive}` : '',
128
+ explicitSummary ?? '',
129
+ contentSummary ?? '',
130
+ embeddedNodeIds.length > 0 ? `Embedded canvas nodes: ${embeddedNodeIds.join(', ')}` : '',
131
+ embeddedUrls.length > 0 ? `Embedded URLs: ${embeddedUrls.join(', ')}` : '',
132
+ ]);
133
+
134
+ return {
135
+ ...base,
136
+ ...(contentSummary ? { contentSummary } : {}),
137
+ ...(agentSummary ? { agentSummary } : {}),
138
+ ...(embeddedNodeIds.length > 0 ? { embeddedNodeIds } : {}),
139
+ ...(embeddedUrls.length > 0 ? { embeddedUrls } : {}),
140
+ } as T;
141
+ }
@@ -10,6 +10,7 @@ export const HTML_PRIMITIVE_KINDS = [
10
10
  'interaction-prototype',
11
11
  'flowchart',
12
12
  'deck',
13
+ 'presentation',
13
14
  'illustration-set',
14
15
  'explainer',
15
16
  'status-report',
@@ -46,6 +47,14 @@ export interface HtmlPrimitiveBuildResult {
46
47
  data: Record<string, unknown>;
47
48
  }
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
+
49
58
  type PrimitiveRenderer = (input: { title: string; data: Record<string, unknown>; descriptor: HtmlPrimitiveDescriptor }) => string;
50
59
 
51
60
  const DESCRIPTORS: HtmlPrimitiveDescriptor[] = [
@@ -222,6 +231,25 @@ const DESCRIPTORS: HtmlPrimitiveDescriptor[] = [
222
231
  },
223
232
  },
224
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
+ },
225
253
  {
226
254
  kind: 'illustration-set',
227
255
  title: 'Illustration Set',
@@ -358,6 +386,43 @@ function fieldRecords(data: Record<string, unknown>, key: string, fallback: Reco
358
386
  return found.length > 0 ? found : fallback;
359
387
  }
360
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
+
361
426
  function fieldStrings(data: Record<string, unknown>, key: string, fallback: string[]): string[] {
362
427
  const found = strings(data[key]);
363
428
  return found.length > 0 ? found : fallback;
@@ -374,11 +439,120 @@ function escapeHtml(value: string): string {
374
439
 
375
440
  function safeCssColor(value: string): string {
376
441
  const trimmed = value.trim();
442
+ if (/^var\(--[a-z0-9-]+\)$/i.test(trimmed)) return trimmed;
377
443
  if (/^#[0-9a-f]{3,8}$/i.test(trimmed)) return trimmed;
378
444
  if (/^(?:rgb|hsl)a?\([\d\s.,%+-]+\)$/i.test(trimmed)) return trimmed;
379
445
  return 'transparent';
380
446
  }
381
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
+
382
556
  function number(value: unknown, fallback = 0): number {
383
557
  return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
384
558
  }
@@ -760,10 +934,7 @@ document.querySelectorAll('[data-copy-svg]').forEach((button) => button.addEvent
760
934
  }
761
935
 
762
936
  function renderDeck({ title, data, descriptor }: Parameters<PrimitiveRenderer>[0]): string {
763
- const slides = fieldRecords(data, 'slides', [
764
- { title: 'HTML keeps humans in the loop', kicker: 'Thesis', bullets: ['Higher information density', 'Visual clarity', 'Two-way interaction'] },
765
- { title: 'Use the lightest tier that works', bullets: ['json-render for structured UI', 'html primitives for rich documents', 'web artifacts for full React apps'] },
766
- ]);
937
+ const slides = presentationSlides(data, DEFAULT_DECK_SLIDES);
767
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>`;
768
939
  return page({
769
940
  title,
@@ -780,12 +951,132 @@ function showSlide(index) {
780
951
  document.getElementById('slide-count').textContent = String(currentSlide + 1);
781
952
  }
782
953
  document.addEventListener('keydown', (event) => {
783
- if (event.key === 'ArrowRight') showSlide(currentSlide + 1);
784
- if (event.key === 'ArrowLeft') showSlide(currentSlide - 1);
954
+ if (event.key === 'ArrowRight' || event.key === 'PageDown' || event.key === ' ') showSlide(currentSlide + 1);
955
+ if (event.key === 'ArrowLeft' || event.key === 'PageUp') showSlide(currentSlide - 1);
785
956
  });`,
786
957
  });
787
958
  }
788
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
+
789
1080
  function renderExplainer({ title, data, descriptor }: Parameters<PrimitiveRenderer>[0]): string {
790
1081
  const steps = fieldRecords(data, 'steps', [{ title: 'Start here', detail: 'Add request path, data flow, or concept steps.' }]);
791
1082
  const snippets = fieldRecords(data, 'snippets', []);
@@ -952,6 +1243,7 @@ const RENDERERS: Record<HtmlPrimitiveKind, PrimitiveRenderer> = {
952
1243
  flowchart: renderFlowchart,
953
1244
  'illustration-set': renderIllustrationSet,
954
1245
  deck: renderDeck,
1246
+ presentation: renderPresentation,
955
1247
  explainer: renderExplainer,
956
1248
  'status-report': renderStatusReport,
957
1249
  'incident-report': renderIncidentReport,
@@ -974,16 +1266,34 @@ export function listHtmlPrimitiveDescriptors(): HtmlPrimitiveDescriptor[] {
974
1266
  return JSON.parse(JSON.stringify(DESCRIPTORS)) as HtmlPrimitiveDescriptor[];
975
1267
  }
976
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
+
977
1283
  export function buildHtmlPrimitive(input: HtmlPrimitiveInput): HtmlPrimitiveBuildResult {
978
1284
  const descriptor = getHtmlPrimitiveDescriptor(input.kind);
979
1285
  const title = input.title ?? descriptor.title;
980
- const data = input.data ?? {};
1286
+ const data = enrichPresentationData(input.kind, input.data ?? {});
981
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;
982
1292
  return {
983
1293
  kind: input.kind,
984
1294
  title,
985
1295
  html: renderer({ title, data, descriptor }),
986
- summary: descriptor.description,
1296
+ summary,
987
1297
  defaultSize: descriptor.defaultSize,
988
1298
  data,
989
1299
  };
@@ -40,7 +40,7 @@ import {
40
40
  } from './canvas-operations.js';
41
41
  import { validateCanvasLayout } from './canvas-validation.js';
42
42
  import { describeCanvasSchema, validateStructuredCanvasPayload } from './canvas-schema.js';
43
- import { buildHtmlPrimitive, isHtmlPrimitiveKind, listHtmlPrimitiveDescriptors } from './html-primitives.js';
43
+ import { buildHtmlPrimitive, getHtmlPrimitiveSemanticMetadata, isHtmlPrimitiveKind, listHtmlPrimitiveDescriptors } from './html-primitives.js';
44
44
  import type { HtmlPrimitiveKind } from './html-primitives.js';
45
45
  import {
46
46
  buildWebArtifactOnCanvas,
@@ -647,6 +647,13 @@ export class PmxCanvas extends EventEmitter {
647
647
  addHtmlNode(input: {
648
648
  html: string;
649
649
  title?: string;
650
+ summary?: string;
651
+ agentSummary?: string;
652
+ description?: string;
653
+ presentation?: boolean;
654
+ slideTitles?: string[];
655
+ embeddedNodeIds?: string[];
656
+ embeddedUrls?: string[];
650
657
  x?: number;
651
658
  y?: number;
652
659
  width?: number;
@@ -656,7 +663,16 @@ export class PmxCanvas extends EventEmitter {
656
663
  const { id } = addCanvasNode({
657
664
  type: 'html',
658
665
  ...(typeof input.title === 'string' ? { title: input.title } : {}),
659
- data: { html: input.html },
666
+ data: {
667
+ html: input.html,
668
+ ...(typeof input.summary === 'string' ? { summary: input.summary } : {}),
669
+ ...(typeof input.agentSummary === 'string' ? { agentSummary: input.agentSummary } : {}),
670
+ ...(typeof input.description === 'string' ? { description: input.description } : {}),
671
+ ...(input.presentation === true ? { presentation: true } : {}),
672
+ ...(Array.isArray(input.slideTitles) ? { slideTitles: input.slideTitles } : {}),
673
+ ...(Array.isArray(input.embeddedNodeIds) ? { embeddedNodeIds: input.embeddedNodeIds } : {}),
674
+ ...(Array.isArray(input.embeddedUrls) ? { embeddedUrls: input.embeddedUrls } : {}),
675
+ },
660
676
  ...(typeof input.x === 'number' ? { x: input.x } : {}),
661
677
  ...(typeof input.y === 'number' ? { y: input.y } : {}),
662
678
  ...(typeof input.width === 'number' ? { width: input.width } : {}),
@@ -692,6 +708,9 @@ export class PmxCanvas extends EventEmitter {
692
708
  htmlPrimitive: built.kind,
693
709
  primitiveData: built.data,
694
710
  description: built.summary,
711
+ agentSummary: typeof input.data?.agentSummary === 'string' ? input.data.agentSummary : built.summary,
712
+ ...(typeof input.data?.summary === 'string' ? { summary: input.data.summary } : {}),
713
+ ...getHtmlPrimitiveSemanticMetadata(built.data),
695
714
  },
696
715
  ...(typeof input.x === 'number' ? { x: input.x } : {}),
697
716
  ...(typeof input.y === 'number' ? { y: input.y } : {}),
@@ -786,7 +805,7 @@ export type { SpatialCluster, SpatialContext, SpatialNeighbor, NodeSpatialInfo }
786
805
  export { mutationHistory, diffLayouts, formatDiff } from './mutation-history.js';
787
806
  export { recomputeCodeGraph, buildCodeGraphSummary, formatCodeGraph } from './code-graph.js';
788
807
  export { describeCanvasSchema, validateStructuredCanvasPayload } from './canvas-schema.js';
789
- export { buildHtmlPrimitive, isHtmlPrimitiveKind, listHtmlPrimitiveDescriptors } from './html-primitives.js';
808
+ export { buildHtmlPrimitive, getHtmlPrimitiveSemanticMetadata, isHtmlPrimitiveKind, listHtmlPrimitiveDescriptors } from './html-primitives.js';
790
809
  export {
791
810
  buildWebArtifactOnCanvas,
792
811
  executeWebArtifactBuild,
@@ -111,7 +111,7 @@ import {
111
111
  } from './canvas-operations.js';
112
112
  import { validateCanvasLayout } from './canvas-validation.js';
113
113
  import { describeCanvasSchema, validateStructuredCanvasPayload } from './canvas-schema.js';
114
- import { buildHtmlPrimitive, isHtmlPrimitiveKind } from './html-primitives.js';
114
+ import { buildHtmlPrimitive, getHtmlPrimitiveSemanticMetadata, isHtmlPrimitiveKind } from './html-primitives.js';
115
115
  import {
116
116
  EXCALIDRAW_READ_CHECKPOINT_TOOL,
117
117
  EXCALIDRAW_SAVE_CHECKPOINT_TOOL,
@@ -1487,8 +1487,18 @@ async function handleCanvasAddNode(req: Request): Promise<Response> {
1487
1487
  : body.content;
1488
1488
  // For html nodes, accept top-level `html` field and merge into data so callers
1489
1489
  // can POST { type: 'html', title, html } without nesting under `data`.
1490
- const htmlMergedData = type === 'html' && typeof body.html === 'string'
1491
- ? { ...(extraData ?? {}), html: body.html }
1490
+ const htmlMergedData = type === 'html'
1491
+ ? {
1492
+ ...(extraData ?? {}),
1493
+ ...(typeof body.html === 'string' ? { html: body.html } : {}),
1494
+ ...(typeof body.summary === 'string' ? { summary: body.summary } : {}),
1495
+ ...(typeof body.agentSummary === 'string' ? { agentSummary: body.agentSummary } : {}),
1496
+ ...(typeof body.description === 'string' ? { description: body.description } : {}),
1497
+ ...(body.presentation === true ? { presentation: true } : {}),
1498
+ ...(Array.isArray(body.slideTitles) ? { slideTitles: body.slideTitles } : {}),
1499
+ ...(Array.isArray(body.embeddedNodeIds) ? { embeddedNodeIds: body.embeddedNodeIds } : {}),
1500
+ ...(Array.isArray(body.embeddedUrls) ? { embeddedUrls: body.embeddedUrls } : {}),
1501
+ }
1492
1502
  : extraData;
1493
1503
  let added: ReturnType<typeof addCanvasNode>;
1494
1504
  const geometry = resolveCreateGeometry(body);
@@ -1497,7 +1507,7 @@ async function handleCanvasAddNode(req: Request): Promise<Response> {
1497
1507
  type: type as CanvasNodeState['type'],
1498
1508
  ...(typeof body.title === 'string' ? { title: body.title } : {}),
1499
1509
  ...(typeof content === 'string' ? { content } : {}),
1500
- ...(htmlMergedData ? { data: htmlMergedData } : {}),
1510
+ ...(htmlMergedData && Object.keys(htmlMergedData).length > 0 ? { data: htmlMergedData } : {}),
1501
1511
  ...(type === 'trace' && typeof body.toolName === 'string' ? { toolName: body.toolName } : {}),
1502
1512
  ...(type === 'trace' && typeof body.category === 'string' ? { category: body.category } : {}),
1503
1513
  ...(type === 'trace' && typeof body.status === 'string' ? { status: body.status } : {}),
@@ -1545,6 +1555,9 @@ function createCanvasHtmlPrimitiveNode(body: Record<string, unknown>): Response
1545
1555
  htmlPrimitive: built.kind,
1546
1556
  primitiveData: built.data,
1547
1557
  description: built.summary,
1558
+ agentSummary: typeof data.agentSummary === 'string' ? data.agentSummary : built.summary,
1559
+ ...(typeof data.summary === 'string' ? { summary: data.summary } : {}),
1560
+ ...getHtmlPrimitiveSemanticMetadata(built.data),
1548
1561
  },
1549
1562
  ...(body.strictSize === true ? { strictSize: true } : {}),
1550
1563
  ...geometry,