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.
- package/CHANGELOG.md +150 -0
- package/dist/canvas/global.css +71 -0
- package/dist/canvas/index.js +94 -60
- package/dist/types/client/nodes/HtmlNode.d.ts +12 -1
- package/dist/types/client/types.d.ts +1 -1
- package/dist/types/server/canvas-serialization.d.ts +1 -0
- package/dist/types/server/html-node-summary.d.ts +2 -0
- package/dist/types/server/html-primitives.d.ts +9 -1
- package/dist/types/server/index.d.ts +8 -1
- package/docs/http-api.md +1 -1
- package/docs/mcp.md +4 -0
- package/docs/node-types.md +27 -5
- package/docs/screenshot.png +0 -0
- package/docs/sdk.md +1 -0
- package/package.json +1 -1
- package/skills/pmx-canvas/SKILL.md +10 -4
- package/skills/pmx-canvas/references/html-primitives.md +132 -0
- package/src/cli/agent.ts +34 -1
- package/src/cli/index.ts +3 -1
- package/src/client/App.tsx +1 -1
- package/src/client/canvas/CommandPalette.tsx +1 -1
- package/src/client/canvas/ExpandedNodeOverlay.tsx +115 -2
- package/src/client/canvas/auto-fit.ts +5 -1
- package/src/client/nodes/HtmlNode.tsx +125 -13
- package/src/client/state/sse-bridge.ts +1 -1
- package/src/client/theme/global.css +71 -0
- package/src/mcp/canvas-access.ts +31 -1
- package/src/mcp/server.ts +17 -3
- package/src/server/agent-context.ts +23 -1
- package/src/server/canvas-operations.ts +18 -5
- package/src/server/canvas-provenance.ts +8 -6
- package/src/server/canvas-schema.ts +11 -0
- package/src/server/canvas-serialization.ts +36 -5
- package/src/server/html-node-summary.ts +141 -0
- package/src/server/html-primitives.ts +328 -8
- package/src/server/index.ts +22 -3
- package/src/server/server.ts +27 -9
- 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 =
|
|
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
|
|
1306
|
+
summary,
|
|
987
1307
|
defaultSize: descriptor.defaultSize,
|
|
988
1308
|
data,
|
|
989
1309
|
};
|
package/src/server/index.ts
CHANGED
|
@@ -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: {
|
|
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,
|
package/src/server/server.ts
CHANGED
|
@@ -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'
|
|
1491
|
-
? {
|
|
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
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
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);
|