pmx-canvas 0.1.20 → 0.1.22

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 (38) hide show
  1. package/CHANGELOG.md +150 -0
  2. package/dist/canvas/global.css +71 -0
  3. package/dist/canvas/index.js +94 -60
  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/canvas-serialization.d.ts +1 -0
  7. package/dist/types/server/html-node-summary.d.ts +2 -0
  8. package/dist/types/server/html-primitives.d.ts +9 -1
  9. package/dist/types/server/index.d.ts +8 -1
  10. package/docs/http-api.md +1 -1
  11. package/docs/mcp.md +4 -0
  12. package/docs/node-types.md +27 -5
  13. package/docs/screenshot.png +0 -0
  14. package/docs/sdk.md +1 -0
  15. package/package.json +1 -1
  16. package/skills/pmx-canvas/SKILL.md +10 -4
  17. package/skills/pmx-canvas/references/html-primitives.md +132 -0
  18. package/src/cli/agent.ts +34 -1
  19. package/src/cli/index.ts +3 -1
  20. package/src/client/App.tsx +1 -1
  21. package/src/client/canvas/CommandPalette.tsx +1 -1
  22. package/src/client/canvas/ExpandedNodeOverlay.tsx +115 -2
  23. package/src/client/canvas/auto-fit.ts +5 -1
  24. package/src/client/nodes/HtmlNode.tsx +125 -13
  25. package/src/client/state/sse-bridge.ts +1 -1
  26. package/src/client/theme/global.css +71 -0
  27. package/src/mcp/canvas-access.ts +31 -1
  28. package/src/mcp/server.ts +17 -3
  29. package/src/server/agent-context.ts +23 -1
  30. package/src/server/canvas-operations.ts +18 -5
  31. package/src/server/canvas-provenance.ts +8 -6
  32. package/src/server/canvas-schema.ts +11 -0
  33. package/src/server/canvas-serialization.ts +36 -5
  34. package/src/server/html-node-summary.ts +141 -0
  35. package/src/server/html-primitives.ts +328 -8
  36. package/src/server/index.ts +22 -3
  37. package/src/server/server.ts +27 -9
  38. package/src/server/spatial-analysis.ts +4 -2
@@ -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,130 @@ 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 isPresentationThemeName(value: string): value is PresentationThemeName {
515
+ return value === 'canvas' || value === 'midnight' || value === 'paper' || value === 'aurora';
516
+ }
517
+
518
+ function parsePresentationThemeName(value: string, field = 'theme'): PresentationThemeName {
519
+ if (isPresentationThemeName(value)) return value;
520
+ throw new Error(`Invalid presentation ${field} "${value}". Use canvas, midnight, paper, aurora, or a custom theme object.`);
521
+ }
522
+
523
+ function presentationTheme(data: Record<string, unknown>): PresentationThemeTokens {
524
+ const raw = data.theme ?? data.presentationTheme;
525
+ if (typeof raw === 'string') {
526
+ return PRESENTATION_THEMES[parsePresentationThemeName(raw)];
527
+ }
528
+ if (!isRecord(raw)) return PRESENTATION_THEMES.canvas;
529
+ const baseName = typeof raw.base === 'string'
530
+ ? parsePresentationThemeName(raw.base, 'theme base')
531
+ : 'canvas';
532
+ const base = PRESENTATION_THEMES[baseName];
533
+ const readColor = (key: string, fallback: string): string => {
534
+ const value = text(raw[key]);
535
+ if (!value) return fallback;
536
+ const color = safeCssColor(value);
537
+ return color === 'transparent' ? fallback : color;
538
+ };
539
+ const colorScheme = raw.colorScheme === 'light' ? 'light' : raw.colorScheme === 'dark' ? 'dark' : base.colorScheme;
540
+ return {
541
+ name: 'custom',
542
+ bg: readColor('bg', base.bg),
543
+ panel: readColor('panel', base.panel),
544
+ surface: readColor('surface', base.surface),
545
+ border: readColor('border', base.border),
546
+ text: readColor('text', base.text),
547
+ textSecondary: readColor('textSecondary', base.textSecondary),
548
+ textMuted: readColor('textMuted', base.textMuted),
549
+ accent: readColor('accent', base.accent),
550
+ colorScheme,
551
+ };
552
+ }
553
+
554
+ function presentationThemeMetadata(data: Record<string, unknown>): string | Record<string, string> | undefined {
555
+ const raw = data.theme ?? data.presentationTheme;
556
+ if (typeof raw === 'string') return parsePresentationThemeName(raw);
557
+ if (!isRecord(raw)) return undefined;
558
+ if (typeof raw.base === 'string') parsePresentationThemeName(raw.base, 'theme base');
559
+ const result: Record<string, string> = {};
560
+ for (const [key, value] of Object.entries(raw)) {
561
+ if (typeof value === 'string') result[key] = value;
562
+ }
563
+ return Object.keys(result).length > 0 ? result : undefined;
564
+ }
565
+
382
566
  function number(value: unknown, fallback = 0): number {
383
567
  return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
384
568
  }
@@ -760,10 +944,7 @@ document.querySelectorAll('[data-copy-svg]').forEach((button) => button.addEvent
760
944
  }
761
945
 
762
946
  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
- ]);
947
+ const slides = presentationSlides(data, DEFAULT_DECK_SLIDES);
767
948
  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
949
  return page({
769
950
  title,
@@ -780,12 +961,132 @@ function showSlide(index) {
780
961
  document.getElementById('slide-count').textContent = String(currentSlide + 1);
781
962
  }
782
963
  document.addEventListener('keydown', (event) => {
783
- if (event.key === 'ArrowRight') showSlide(currentSlide + 1);
784
- if (event.key === 'ArrowLeft') showSlide(currentSlide - 1);
964
+ if (event.key === 'ArrowRight' || event.key === 'PageDown' || event.key === ' ') showSlide(currentSlide + 1);
965
+ if (event.key === 'ArrowLeft' || event.key === 'PageUp') showSlide(currentSlide - 1);
785
966
  });`,
786
967
  });
787
968
  }
788
969
 
970
+ function renderPresentation({ title, data, descriptor }: Parameters<PrimitiveRenderer>[0]): string {
971
+ const slides = presentationSlides(data);
972
+ const subtitle = text(data.subtitle, descriptor.description);
973
+ const theme = presentationTheme(data);
974
+ const accentOverride = safeCssColor(text(data.accent, ''));
975
+ const accent = accentOverride === 'transparent' ? theme.accent : accentOverride;
976
+ const slideMarkup = slides.map((item, index) => {
977
+ const metrics = records(item.metrics);
978
+ return `<article class="slide ${index === 0 ? 'active' : ''}" data-slide="${index}">
979
+ <div class="slide-grid ${metrics.length > 0 ? 'with-metrics' : 'without-metrics'}">
980
+ <div class="slide-copy">
981
+ <div class="kicker">${escapeHtml(text(item.kicker, `Slide ${index + 1}`))}</div>
982
+ <h2>${escapeHtml(itemTitle(item, 'Slide'))}</h2>
983
+ ${text(item.body) ? `<p class="lede">${escapeHtml(text(item.body))}</p>` : ''}
984
+ ${list(strings(item.bullets))}
985
+ </div>
986
+ ${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>` : ''}
987
+ </div>
988
+ ${text(item.note) ? `<aside class="speaker-note"><span>Speaker note</span>${escapeHtml(text(item.note))}</aside>` : ''}
989
+ </article>`;
990
+ }).join('');
991
+
992
+ return `<!doctype html>
993
+ <html lang="en">
994
+ <head>
995
+ <meta charset="utf-8">
996
+ <meta name="viewport" content="width=device-width, initial-scale=1">
997
+ <title>${escapeHtml(title)}</title>
998
+ <style>
999
+ :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}; }
1000
+ * { box-sizing: border-box; }
1001
+ html, body { width: 100%; height: 100%; overflow: hidden; }
1002
+ 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); }
1003
+ .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)); }
1004
+ .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); }
1005
+ .brand { display: grid; gap: 2px; }
1006
+ .brand p { margin: 0; }
1007
+ .eyebrow { color: var(--deck-accent); font-size: 11px; font-weight: 900; letter-spacing: .18em; text-transform: uppercase; }
1008
+ .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; }
1009
+ .controls { display: flex; gap: 8px; flex-wrap: wrap; justify-content: flex-end; }
1010
+ 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; }
1011
+ 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)); }
1012
+ .slides { min-height: 0; overflow-x: hidden; overflow-y: auto; overscroll-behavior: contain; scrollbar-gutter: stable; }
1013
+ .slide { display: none; min-height: 100%; align-content: center; gap: clamp(16px, 3vmin, 28px); padding: clamp(24px, 5vmin, 64px) clamp(28px, 6vw, 92px); }
1014
+ .slide.active { display: grid; }
1015
+ .slide-grid { display: grid; gap: clamp(24px, 5vw, 72px); align-items: center; }
1016
+ .slide-grid.with-metrics { grid-template-columns: minmax(0, 1.15fr) minmax(260px, .85fr); }
1017
+ .slide-grid.without-metrics { grid-template-columns: minmax(0, 1fr); }
1018
+ .slide-copy { max-width: min(1120px, 100%); }
1019
+ .kicker { color: var(--deck-accent); font-size: clamp(12px, 1.8vw, 18px); font-weight: 950; letter-spacing: .18em; text-transform: uppercase; }
1020
+ h2 { margin: 10px 0 18px; max-width: 16ch; font-size: clamp(40px, 8vmin, 104px); line-height: .9; letter-spacing: -.07em; }
1021
+ .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; }
1022
+ ul { display: grid; gap: 12px; max-width: 780px; margin: 0; padding: 0; list-style: none; }
1023
+ li { position: relative; padding-left: 30px; color: var(--deck-text-secondary); font-size: clamp(17px, 2.2vmin, 25px); line-height: 1.22; }
1024
+ 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); }
1025
+ .metrics { display: grid; gap: 14px; }
1026
+ .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); }
1027
+ .metric span { color: var(--deck-text-muted); font-size: 11px; font-weight: 900; letter-spacing: .14em; text-transform: uppercase; }
1028
+ .metric strong { display: block; margin-top: 8px; font-size: clamp(34px, 6vw, 78px); line-height: .9; letter-spacing: -.06em; }
1029
+ .metric p { margin: 10px 0 0; color: var(--deck-text-secondary); }
1030
+ .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; }
1031
+ .speaker-note span { display: block; margin-bottom: 2px; color: var(--deck-accent); font-size: 10px; font-weight: 900; letter-spacing: .12em; text-transform: uppercase; }
1032
+ .dots { display: flex; gap: 7px; align-items: center; }
1033
+ .dot { width: 30px; height: 7px; border: 0; border-radius: 999px; padding: 0; background: color-mix(in srgb, var(--deck-text) 24%, transparent); }
1034
+ .dot.active { width: 54px; background: var(--deck-accent); }
1035
+ .hint { font-size: 12px; color: var(--deck-text-muted); }
1036
+ html[data-pmx-presentation-mode="present"] .hint { display: none; }
1037
+ .progress { height: 3px; width: 180px; overflow: hidden; border-radius: 999px; background: color-mix(in srgb, var(--deck-text) 18%, transparent); }
1038
+ .progress span { display: block; height: 100%; width: 0; background: var(--deck-accent); transition: width .2s ease; }
1039
+ @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%; } }
1040
+ </style>
1041
+ </head>
1042
+ <body>
1043
+ <main class="deck">
1044
+ <header class="topbar">
1045
+ <div class="brand"><div class="eyebrow">PMX presentation</div><div class="title">${escapeHtml(title)}</div><p>${escapeHtml(subtitle)}</p></div>
1046
+ </header>
1047
+ <section class="slides">${slideMarkup}</section>
1048
+ <footer class="bottombar">
1049
+ <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>
1050
+ <div class="hint"><span id="slide-current">1</span> / ${slides.length} - Arrow keys, Space, Page Up/Down</div>
1051
+ <div class="progress" aria-hidden="true"><span id="slide-progress"></span></div>
1052
+ </footer>
1053
+ </main>
1054
+ <script type="application/json" id="pmx-data">${safeJson(data)}</script>
1055
+ <script>
1056
+ let currentSlide = 0;
1057
+ const slides = Array.from(document.querySelectorAll('[data-slide]'));
1058
+ const dots = Array.from(document.querySelectorAll('[data-dot]'));
1059
+ function showSlide(index) {
1060
+ currentSlide = Math.max(0, Math.min(slides.length - 1, index));
1061
+ slides.forEach((slide, i) => slide.classList.toggle('active', i === currentSlide));
1062
+ dots.forEach((dot, i) => dot.classList.toggle('active', i === currentSlide));
1063
+ document.getElementById('slide-current').textContent = String(currentSlide + 1);
1064
+ document.getElementById('slide-progress').style.width = String(((currentSlide + 1) / slides.length) * 100) + '%';
1065
+ }
1066
+ dots.forEach((dot) => dot.addEventListener('click', () => showSlide(Number(dot.getAttribute('data-dot')))));
1067
+ function handlePresentationKey(key) {
1068
+ if (key === 'ArrowRight' || key === 'PageDown' || key === ' ') { showSlide(currentSlide + 1); return true; }
1069
+ if (key === 'ArrowLeft' || key === 'PageUp') { showSlide(currentSlide - 1); return true; }
1070
+ if (key === 'Home') { showSlide(0); return true; }
1071
+ if (key === 'End') { showSlide(slides.length - 1); return true; }
1072
+ return false;
1073
+ }
1074
+ window.PMX_CANVAS_PRESENTATION_HANDLE_KEY = handlePresentationKey;
1075
+ document.addEventListener('pmx-presentation-key', (event) => {
1076
+ if (!event.detail || typeof event.detail.key !== 'string') return;
1077
+ handlePresentationKey(event.detail.key);
1078
+ });
1079
+ document.addEventListener('keydown', (event) => {
1080
+ const tag = event.target && event.target.tagName;
1081
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
1082
+ if (handlePresentationKey(event.key)) event.preventDefault();
1083
+ });
1084
+ showSlide(0);
1085
+ </script>
1086
+ </body>
1087
+ </html>`;
1088
+ }
1089
+
789
1090
  function renderExplainer({ title, data, descriptor }: Parameters<PrimitiveRenderer>[0]): string {
790
1091
  const steps = fieldRecords(data, 'steps', [{ title: 'Start here', detail: 'Add request path, data flow, or concept steps.' }]);
791
1092
  const snippets = fieldRecords(data, 'snippets', []);
@@ -952,6 +1253,7 @@ const RENDERERS: Record<HtmlPrimitiveKind, PrimitiveRenderer> = {
952
1253
  flowchart: renderFlowchart,
953
1254
  'illustration-set': renderIllustrationSet,
954
1255
  deck: renderDeck,
1256
+ presentation: renderPresentation,
955
1257
  explainer: renderExplainer,
956
1258
  'status-report': renderStatusReport,
957
1259
  'incident-report': renderIncidentReport,
@@ -974,16 +1276,34 @@ export function listHtmlPrimitiveDescriptors(): HtmlPrimitiveDescriptor[] {
974
1276
  return JSON.parse(JSON.stringify(DESCRIPTORS)) as HtmlPrimitiveDescriptor[];
975
1277
  }
976
1278
 
1279
+ export function getHtmlPrimitiveSemanticMetadata(data: Record<string, unknown>): HtmlPrimitiveSemanticMetadata {
1280
+ if (data.presentation !== true) return {};
1281
+ const slideTitles = strings(data.slideTitles);
1282
+ const speakerNotes = strings(data.speakerNotes);
1283
+ const theme = presentationThemeMetadata(data);
1284
+ return {
1285
+ presentation: true,
1286
+ ...(typeof data.slideCount === 'number' && Number.isFinite(data.slideCount) ? { slideCount: data.slideCount } : {}),
1287
+ ...(slideTitles.length > 0 ? { slideTitles } : {}),
1288
+ ...(speakerNotes.length > 0 ? { speakerNotes } : {}),
1289
+ ...(theme !== undefined ? { presentationTheme: theme } : {}),
1290
+ };
1291
+ }
1292
+
977
1293
  export function buildHtmlPrimitive(input: HtmlPrimitiveInput): HtmlPrimitiveBuildResult {
978
1294
  const descriptor = getHtmlPrimitiveDescriptor(input.kind);
979
1295
  const title = input.title ?? descriptor.title;
980
- const data = input.data ?? {};
1296
+ const data = enrichPresentationData(input.kind, input.data ?? {});
981
1297
  const renderer = RENDERERS[input.kind];
1298
+ const slideTitles = strings(data.slideTitles);
1299
+ const summary = slideTitles.length > 0
1300
+ ? `${descriptor.description} Slides: ${slideTitles.join(', ')}.`
1301
+ : descriptor.description;
982
1302
  return {
983
1303
  kind: input.kind,
984
1304
  title,
985
1305
  html: renderer({ title, data, descriptor }),
986
- summary: descriptor.description,
1306
+ summary,
987
1307
  defaultSize: descriptor.defaultSize,
988
1308
  data,
989
1309
  };
@@ -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 } : {}),
@@ -1531,11 +1541,16 @@ function createCanvasHtmlPrimitiveNode(body: Record<string, unknown>): Response
1531
1541
  return responseJson({ ok: false, error: `Unknown HTML primitive: ${String(rawKind)}.` }, 400);
1532
1542
  }
1533
1543
  const data = isRecord(body.data) ? body.data : {};
1534
- const built = buildHtmlPrimitive({
1535
- kind: rawKind,
1536
- ...(typeof body.title === 'string' ? { title: body.title } : {}),
1537
- data,
1538
- });
1544
+ let built: ReturnType<typeof buildHtmlPrimitive>;
1545
+ try {
1546
+ built = buildHtmlPrimitive({
1547
+ kind: rawKind,
1548
+ ...(typeof body.title === 'string' ? { title: body.title } : {}),
1549
+ data,
1550
+ });
1551
+ } catch (error) {
1552
+ return responseJson({ ok: false, error: error instanceof Error ? error.message : String(error) }, 400);
1553
+ }
1539
1554
  const geometry = resolveCreateGeometry(body);
1540
1555
  const { node } = addCanvasNode({
1541
1556
  type: 'html',
@@ -1545,6 +1560,9 @@ function createCanvasHtmlPrimitiveNode(body: Record<string, unknown>): Response
1545
1560
  htmlPrimitive: built.kind,
1546
1561
  primitiveData: built.data,
1547
1562
  description: built.summary,
1563
+ agentSummary: typeof data.agentSummary === 'string' ? data.agentSummary : built.summary,
1564
+ ...(typeof data.summary === 'string' ? { summary: data.summary } : {}),
1565
+ ...getHtmlPrimitiveSemanticMetadata(built.data),
1548
1566
  },
1549
1567
  ...(body.strictSize === true ? { strictSize: true } : {}),
1550
1568
  ...geometry,
@@ -312,9 +312,10 @@ export function searchNodes(
312
312
 
313
313
  for (const node of nodes) {
314
314
  const title = ((node.data.title as string) ?? '').toLowerCase();
315
- const content = ((node.data.content as string) ?? (node.data.description as string) ?? (node.data.fileContent as string) ?? '').toLowerCase();
315
+ const content = ((node.data.content as string) ?? (node.data.agentSummary as string) ?? (node.data.contentSummary as string) ?? (node.data.description as string) ?? (node.data.fileContent as string) ?? '').toLowerCase();
316
316
  const path = ((node.data.path as string) ?? '').toLowerCase();
317
317
  const description = ((node.data.description as string) ?? '').toLowerCase();
318
+ const summary = ((node.data.summary as string) ?? (node.data.agentSummary as string) ?? (node.data.contentSummary as string) ?? '').toLowerCase();
318
319
  const url = ((node.data.url as string) ?? '').toLowerCase();
319
320
 
320
321
  let score = 0;
@@ -324,6 +325,7 @@ export function searchNodes(
324
325
  if (path.includes(term)) score += 2;
325
326
  if (url.includes(term)) score += 2;
326
327
  if (description.includes(term)) score += 1;
328
+ if (summary.includes(term)) score += 1;
327
329
  if (content.includes(term)) score += 1;
328
330
  }
329
331
 
@@ -331,7 +333,7 @@ export function searchNodes(
331
333
 
332
334
  // Extract a snippet around the first match in content
333
335
  let snippet = '';
334
- const fullContent = (node.data.content as string) ?? (node.data.description as string) ?? (node.data.fileContent as string) ?? '';
336
+ const fullContent = (node.data.content as string) ?? (node.data.agentSummary as string) ?? (node.data.contentSummary as string) ?? (node.data.description as string) ?? (node.data.fileContent as string) ?? '';
335
337
  const matchIdx = fullContent.toLowerCase().indexOf(terms[0]);
336
338
  if (matchIdx >= 0) {
337
339
  const start = Math.max(0, matchIdx - 40);