pmx-canvas 0.6.4 → 0.7.0

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 (204) hide show
  1. package/.github/extensions/pmx-canvas/extension.mjs +67 -3
  2. package/CHANGELOG.md +73 -0
  3. package/Readme.md +35 -5
  4. package/dist/canvas/global.css +498 -28
  5. package/dist/canvas/index.js +110 -95
  6. package/dist/canvas/mermaid-entry.js +258 -258
  7. package/dist/types/cli/agent.d.ts +4 -0
  8. package/dist/types/cli/commands/backup.d.ts +1 -0
  9. package/dist/types/cli/commands/boards.d.ts +1 -0
  10. package/dist/types/cli/commands/export.d.ts +1 -0
  11. package/dist/types/cli/commands/record.d.ts +12 -0
  12. package/dist/types/client/canvas/BoardCopyDialog.d.ts +5 -0
  13. package/dist/types/client/canvas/BoardFolderDialog.d.ts +7 -0
  14. package/dist/types/client/canvas/BoardLinkDialog.d.ts +3 -0
  15. package/dist/types/client/canvas/BoardSwitcher.d.ts +8 -0
  16. package/dist/types/client/canvas/ExportDialog.d.ts +7 -0
  17. package/dist/types/client/canvas/HomeView.d.ts +9 -0
  18. package/dist/types/client/canvas/Presentation.d.ts +13 -0
  19. package/dist/types/client/canvas/use-node-drag.d.ts +2 -1
  20. package/dist/types/client/nodes/AttachmentNode.d.ts +6 -0
  21. package/dist/types/client/nodes/BoardNode.d.ts +5 -0
  22. package/dist/types/client/nodes/McpAppNode.d.ts +24 -1
  23. package/dist/types/client/state/boards-store.d.ts +79 -0
  24. package/dist/types/client/state/canvas-store.d.ts +2 -0
  25. package/dist/types/client/state/intent-bridge.d.ts +1 -0
  26. package/dist/types/client/state/presentation.d.ts +3 -0
  27. package/dist/types/client/state/session-store.d.ts +7 -0
  28. package/dist/types/client/types.d.ts +2 -2
  29. package/dist/types/json-render/catalog.d.ts +3 -3
  30. package/dist/types/json-render/charts/definitions.d.ts +1 -1
  31. package/dist/types/json-render/server.d.ts +8 -0
  32. package/dist/types/mcp/canvas-access.d.ts +10 -3
  33. package/dist/types/mcp/context-uri-template.d.ts +6 -0
  34. package/dist/types/server/agent-presence.d.ts +1 -1
  35. package/dist/types/server/attribution.d.ts +11 -0
  36. package/dist/types/server/ax-context.d.ts +3 -1
  37. package/dist/types/server/ax-interaction.d.ts +2 -2
  38. package/dist/types/server/ax-state.d.ts +13 -3
  39. package/dist/types/server/backup.d.ts +36 -0
  40. package/dist/types/server/board-export.d.ts +45 -0
  41. package/dist/types/server/canvas-db.d.ts +65 -9
  42. package/dist/types/server/canvas-operations.d.ts +45 -0
  43. package/dist/types/server/canvas-provenance.d.ts +1 -1
  44. package/dist/types/server/canvas-schema.d.ts +1 -0
  45. package/dist/types/server/canvas-state.d.ts +146 -3
  46. package/dist/types/server/canvas-validation.d.ts +3 -0
  47. package/dist/types/server/context-brief.d.ts +111 -0
  48. package/dist/types/server/context-reads.d.ts +55 -0
  49. package/dist/types/server/document-import.d.ts +66 -0
  50. package/dist/types/server/html-surface.d.ts +1 -1
  51. package/dist/types/server/index.d.ts +107 -4
  52. package/dist/types/server/mutation-history.d.ts +1 -0
  53. package/dist/types/server/operations/invoker.d.ts +2 -0
  54. package/dist/types/server/operations/mcp.d.ts +7 -1
  55. package/dist/types/server/operations/ops/app.d.ts +2 -0
  56. package/dist/types/server/operations/ops/ax-shared.d.ts +2 -2
  57. package/dist/types/server/operations/ops/backup.d.ts +2 -0
  58. package/dist/types/server/operations/ops/boards.d.ts +10 -0
  59. package/dist/types/server/operations/ops/export.d.ts +2 -0
  60. package/dist/types/server/operations/ops/imports.d.ts +2 -0
  61. package/dist/types/server/operations/ops/json-render.d.ts +2 -0
  62. package/dist/types/server/operations/ops/nodes.d.ts +4 -2
  63. package/dist/types/server/operations/ops/query.d.ts +2 -0
  64. package/dist/types/server/operations/registry.d.ts +9 -1
  65. package/dist/types/server/operations/types.d.ts +1 -1
  66. package/dist/types/server/server.d.ts +1 -0
  67. package/dist/types/server/trace-manager.d.ts +7 -0
  68. package/dist/types/server/web-artifacts.d.ts +3 -0
  69. package/dist/types/server/webpage-node.d.ts +22 -1
  70. package/dist/types/server/workbench-auth.d.ts +3 -0
  71. package/dist/types/shared/boards.d.ts +6 -0
  72. package/dist/types/shared/tour.d.ts +72 -0
  73. package/docs/RELEASE.md +5 -0
  74. package/docs/cli.md +157 -0
  75. package/docs/http-api.md +277 -6
  76. package/docs/mcp.md +96 -7
  77. package/docs/node-types.md +106 -0
  78. package/docs/plans/plan-010-attributed-ask-answers.md +39 -0
  79. package/docs/plans/plan-011-context-read-instrumentation.md +54 -0
  80. package/docs/plans/plan-012-many-boards-stage-1.md +98 -0
  81. package/docs/plans/plan-013-static-board-export.md +47 -0
  82. package/docs/plans/plan-014-connected-board-memory.md +140 -0
  83. package/docs/product-vision-2026-09.md +118 -18
  84. package/docs/sdk.md +50 -0
  85. package/package.json +1 -1
  86. package/skills/pmx-canvas/SKILL.md +62 -5
  87. package/skills/pmx-canvas/references/full-reference.md +44 -8
  88. package/skills/pmx-canvas/references/github-copilot-app-adapter.md +3 -0
  89. package/skills/pmx-canvas/references/installing-pmx-canvas.md +4 -4
  90. package/skills/pmx-canvas-orchestration/SKILL.md +5 -0
  91. package/skills/pmx-canvas-testing/SKILL.md +4 -0
  92. package/src/cli/agent.ts +9 -0
  93. package/src/cli/commands/ax.ts +13 -0
  94. package/src/cli/commands/backup.ts +66 -0
  95. package/src/cli/commands/boards.ts +71 -0
  96. package/src/cli/commands/export.ts +30 -0
  97. package/src/cli/commands/nodes.ts +14 -1
  98. package/src/cli/commands/pins.ts +6 -2
  99. package/src/cli/commands/pump.ts +3 -0
  100. package/src/cli/commands/query.ts +33 -7
  101. package/src/cli/commands/record.ts +218 -0
  102. package/src/cli/commands/view.ts +7 -5
  103. package/src/cli/index.ts +21 -9
  104. package/src/client/App.tsx +28 -4
  105. package/src/client/canvas/AgentPresenceLayer.tsx +1 -1
  106. package/src/client/canvas/BoardCopyDialog.tsx +153 -0
  107. package/src/client/canvas/BoardFolderDialog.tsx +120 -0
  108. package/src/client/canvas/BoardLinkDialog.tsx +93 -0
  109. package/src/client/canvas/BoardSwitcher.tsx +149 -0
  110. package/src/client/canvas/CanvasNode.tsx +8 -0
  111. package/src/client/canvas/CanvasViewport.tsx +3 -0
  112. package/src/client/canvas/CommandPalette.tsx +107 -4
  113. package/src/client/canvas/ContextMenu.tsx +4 -0
  114. package/src/client/canvas/ExpandedNodeOverlay.tsx +3 -0
  115. package/src/client/canvas/ExportDialog.tsx +197 -0
  116. package/src/client/canvas/HomeView.tsx +277 -0
  117. package/src/client/canvas/IntentLayer.tsx +9 -2
  118. package/src/client/canvas/Presentation.tsx +108 -0
  119. package/src/client/canvas/SessionPanel.tsx +28 -1
  120. package/src/client/canvas/ToolRail.tsx +38 -3
  121. package/src/client/canvas/TopBar.tsx +49 -7
  122. package/src/client/canvas/import-files.ts +19 -1
  123. package/src/client/canvas/kind-colors.ts +1 -0
  124. package/src/client/canvas/use-node-drag.ts +8 -3
  125. package/src/client/icons.tsx +2 -0
  126. package/src/client/mermaid-entry.ts +39 -0
  127. package/src/client/nodes/AttachmentNode.tsx +148 -0
  128. package/src/client/nodes/BoardNode.tsx +35 -0
  129. package/src/client/nodes/FileNode.tsx +6 -0
  130. package/src/client/nodes/HtmlNode.tsx +12 -13
  131. package/src/client/nodes/MarkdownNode.tsx +47 -19
  132. package/src/client/nodes/McpAppNode.tsx +105 -13
  133. package/src/client/nodes/MermaidNode.tsx +3 -2
  134. package/src/client/nodes/ax-node-actions.ts +2 -5
  135. package/src/client/nodes/use-ax-surface-bridge.ts +1 -1
  136. package/src/client/state/boards-store.ts +167 -0
  137. package/src/client/state/canvas-store.ts +22 -0
  138. package/src/client/state/intent-bridge.ts +30 -2
  139. package/src/client/state/intent-store.ts +5 -1
  140. package/src/client/state/node-factory.ts +1 -0
  141. package/src/client/state/presentation.ts +7 -0
  142. package/src/client/state/session-store.ts +8 -0
  143. package/src/client/state/sse-bridge.ts +52 -5
  144. package/src/client/theme/global.css +498 -28
  145. package/src/client/types.ts +2 -0
  146. package/src/json-render/server.ts +45 -9
  147. package/src/mcp/canvas-access.ts +59 -22
  148. package/src/mcp/context-uri-template.ts +28 -0
  149. package/src/mcp/server.ts +122 -15
  150. package/src/server/agent-context.ts +116 -29
  151. package/src/server/agent-presence.ts +15 -9
  152. package/src/server/attribution.ts +29 -0
  153. package/src/server/ax-context.ts +4 -2
  154. package/src/server/ax-interaction.ts +1 -0
  155. package/src/server/ax-state-manager.ts +72 -3
  156. package/src/server/ax-state.ts +41 -0
  157. package/src/server/backup.ts +178 -0
  158. package/src/server/board-export.ts +686 -0
  159. package/src/server/canvas-db.ts +612 -105
  160. package/src/server/canvas-operations.ts +183 -31
  161. package/src/server/canvas-provenance.ts +1 -0
  162. package/src/server/canvas-schema.ts +22 -0
  163. package/src/server/canvas-state.ts +812 -48
  164. package/src/server/canvas-validation.ts +9 -1
  165. package/src/server/context-brief.ts +372 -0
  166. package/src/server/context-reads.ts +238 -0
  167. package/src/server/demo-state.json +670 -9
  168. package/src/server/document-import.ts +223 -0
  169. package/src/server/html-primitives.ts +199 -91
  170. package/src/server/html-surface.ts +8 -3
  171. package/src/server/index.ts +243 -5
  172. package/src/server/mutation-history.ts +10 -13
  173. package/src/server/operations/composites.ts +29 -5
  174. package/src/server/operations/http.ts +9 -3
  175. package/src/server/operations/index.ts +8 -0
  176. package/src/server/operations/invoker.ts +14 -3
  177. package/src/server/operations/mcp.ts +50 -11
  178. package/src/server/operations/ops/app.ts +15 -0
  179. package/src/server/operations/ops/ax-read.ts +10 -6
  180. package/src/server/operations/ops/ax-timeline.ts +75 -0
  181. package/src/server/operations/ops/backup.ts +134 -0
  182. package/src/server/operations/ops/batch.ts +20 -4
  183. package/src/server/operations/ops/boards.ts +351 -0
  184. package/src/server/operations/ops/export.ts +74 -0
  185. package/src/server/operations/ops/ext-app.ts +4 -0
  186. package/src/server/operations/ops/groups.ts +14 -7
  187. package/src/server/operations/ops/imports.ts +220 -0
  188. package/src/server/operations/ops/json-render.ts +37 -5
  189. package/src/server/operations/ops/nodes.ts +99 -21
  190. package/src/server/operations/ops/query.ts +206 -9
  191. package/src/server/operations/ops/viewport.ts +32 -0
  192. package/src/server/operations/registry.ts +129 -31
  193. package/src/server/operations/types.ts +1 -1
  194. package/src/server/scope-fence.ts +1 -1
  195. package/src/server/server.ts +155 -8
  196. package/src/server/spatial-analysis.ts +9 -17
  197. package/src/server/trace-manager.ts +19 -0
  198. package/src/server/web-artifacts.ts +11 -3
  199. package/src/server/webpage-node.ts +173 -13
  200. package/src/server/workbench-auth.ts +11 -0
  201. package/src/server/workboard.ts +3 -3
  202. package/src/shared/boards.ts +18 -0
  203. package/src/shared/node-sizes.ts +1 -0
  204. package/src/shared/tour.ts +87 -0
@@ -497,7 +497,7 @@ function isRecord(value: unknown): value is Record<string, unknown> {
497
497
  function text(value: unknown, fallback = ''): string {
498
498
  if (typeof value === 'string') return value;
499
499
  if (typeof value === 'number' || typeof value === 'boolean') return String(value);
500
- return fallback;
500
+ return value === undefined ? fallback : '';
501
501
  }
502
502
 
503
503
  function records(value: unknown): Record<string, unknown>[] {
@@ -514,8 +514,9 @@ function fieldRecords(
514
514
  key: string,
515
515
  fallback: Record<string, unknown>[],
516
516
  ): Record<string, unknown>[] {
517
- const found = records(data[key]);
518
- return found.length > 0 ? found : fallback;
517
+ // Document rule: examples fill an omitted field only. An explicitly present
518
+ // empty (or invalid) value is caller intent and must not resurrect examples.
519
+ return Object.hasOwn(data, key) ? records(data[key]) : fallback;
519
520
  }
520
521
 
521
522
  const DEFAULT_DECK_SLIDES: Record<string, unknown>[] = [
@@ -573,8 +574,22 @@ function enrichPresentationData(kind: HtmlPrimitiveKind, data: Record<string, un
573
574
  }
574
575
 
575
576
  function fieldStrings(data: Record<string, unknown>, key: string, fallback: string[]): string[] {
576
- const found = strings(data[key]);
577
- return found.length > 0 ? found : fallback;
577
+ return Object.hasOwn(data, key) ? strings(data[key]) : fallback;
578
+ }
579
+
580
+ /** Omitted fields may use examples; explicitly empty fields omit their entire UI section. */
581
+ function fieldSection(data: Record<string, unknown>, keys: string | string[], markup: string): string {
582
+ const empty = (key: string) => {
583
+ if (!Object.hasOwn(data, key)) return false;
584
+ const value = data[key];
585
+ return (
586
+ value == null ||
587
+ (typeof value === 'string' && !value.trim()) ||
588
+ (Array.isArray(value) && value.length === 0) ||
589
+ (isRecord(value) && Object.keys(value).length === 0)
590
+ );
591
+ };
592
+ return (Array.isArray(keys) ? keys : [keys]).every(empty) ? '' : markup;
578
593
  }
579
594
 
580
595
  function escapeHtml(value: string): string {
@@ -910,19 +925,22 @@ function renderChoiceGrid({ title, data, descriptor }: Parameters<PrimitiveRende
910
925
  cons: ['More maintenance'],
911
926
  },
912
927
  ]);
913
- const body = `<section class="grid">${items
914
- .map(
915
- (item, index) => `
928
+ const body =
929
+ items.length > 0
930
+ ? `<section class="grid">${items
931
+ .map(
932
+ (item, index) => `
916
933
  <article class="card ${index === 0 ? 'emphasis' : ''}">
917
934
  <div class="small">Choice ${index + 1}</div>
918
935
  <h2>${escapeHtml(itemTitle(item, `Option ${index + 1}`))}</h2>
919
- <p>${escapeHtml(text(item.summary, 'Summarize the approach here.'))}</p>
936
+ ${fieldSection(item, 'summary', `<p>${escapeHtml(text(item.summary, 'Summarize the approach here.'))}</p>`)}
920
937
  ${text(item.tradeoff) ? `<p><strong>Tradeoff:</strong> ${escapeHtml(text(item.tradeoff))}</p>` : ''}
921
- <div class="two"><div><h3>Pros</h3>${list(strings(item.pros))}</div><div><h3>Cons</h3>${list(strings(item.cons))}</div></div>
938
+ ${fieldSection(item, ['pros', 'cons'], `<div class="two">${fieldSection(item, 'pros', `<div><h3>Pros</h3>${list(strings(item.pros))}</div>`)}${fieldSection(item, 'cons', `<div><h3>Cons</h3>${list(strings(item.cons))}</div>`)}</div>`)}
922
939
  ${codeBlock(item.code)}
923
940
  </article>`,
924
- )
925
- .join('')}</section>`;
941
+ )
942
+ .join('')}</section>`
943
+ : '';
926
944
  return page({ title, kind: 'choice-grid', summary: descriptor.description, data, body });
927
945
  }
928
946
 
@@ -942,13 +960,20 @@ function renderPlanTimeline({ title, data, descriptor }: Parameters<PrimitiveRen
942
960
  { risk: 'Overly generic output', mitigation: 'Use named primitives with clear use cases.' },
943
961
  ]);
944
962
  const snippets = fieldRecords(data, 'snippets', []);
945
- const body = `<section class="two"><div class="panel"><h2>Milestones</h2><div class="timeline">${milestones
946
- .map(
947
- (item, index) => `
963
+ const milestonesSection =
964
+ milestones.length > 0
965
+ ? `<div class="panel"><h2>Milestones</h2><div class="timeline">${milestones
966
+ .map(
967
+ (item, index) => `
948
968
  <div class="step"><div class="dot">${index + 1}</div><div class="card"><h3>${escapeHtml(itemTitle(item, `Milestone ${index + 1}`))} ${badge(text(item.status, 'planned'))}</h3><p>${escapeHtml(text(item.detail, 'Add implementation detail.'))}</p></div></div>`,
949
- )
950
- .join('')}</div></div>
951
- <div class="panel"><h2>Data Flow</h2><div class="flow">${flow.map((item, index) => `<div class="flow-node"><h3>${escapeHtml(text(item.from, `Step ${index + 1}`))}</h3><p>${escapeHtml(text(item.label, 'flows to'))}</p><strong>${escapeHtml(text(item.to, 'Next'))}</strong></div>${index < flow.length - 1 ? '<div class="arrow">-></div>' : ''}`).join('')}</div><h2 style="margin-top:18px">Risks</h2>${risks.map((item) => `<div class="card"><strong>${escapeHtml(text(item.risk, 'Risk'))}</strong><p>${escapeHtml(text(item.mitigation, 'Mitigation'))}</p></div>`).join('')}</div></section>
969
+ )
970
+ .join('')}</div></div>`
971
+ : '';
972
+ const flowAndRisksSection =
973
+ flow.length > 0 || risks.length > 0
974
+ ? `<div class="panel">${flow.length > 0 ? `<h2>Data Flow</h2><div class="flow">${flow.map((item, index) => `<div class="flow-node"><h3>${escapeHtml(text(item.from, `Step ${index + 1}`))}</h3><p>${escapeHtml(text(item.label, 'flows to'))}</p><strong>${escapeHtml(text(item.to, 'Next'))}</strong></div>${index < flow.length - 1 ? '<div class="arrow">-></div>' : ''}`).join('')}</div>` : ''}${risks.length > 0 ? `<h2 style="margin-top:18px">Risks</h2>${risks.map((item) => `<div class="card"><strong>${escapeHtml(text(item.risk, 'Risk'))}</strong><p>${escapeHtml(text(item.mitigation, 'Mitigation'))}</p></div>`).join('')}` : ''}</div>`
975
+ : '';
976
+ const body = `${milestonesSection || flowAndRisksSection ? `<section class="two">${milestonesSection}${flowAndRisksSection}</section>` : ''}
952
977
  ${snippets.length > 0 ? `<section class="panel" style="margin-top:14px"><h2>Code Checkpoints</h2>${snippets.map((item) => `<h3>${escapeHtml(itemTitle(item, 'Snippet'))}</h3>${codeBlock(item.code)}`).join('')}</section>` : ''}`;
953
978
  return page({ title, kind: 'plan-timeline', summary: descriptor.description, data, body });
954
979
  }
@@ -965,13 +990,21 @@ function renderReviewSheet({ title, data, descriptor }: Parameters<PrimitiveRend
965
990
  ]);
966
991
  const files = fieldRecords(data, 'files', []);
967
992
  const diff = text(data.diff);
968
- const body = `<section class="two"><div class="panel"><h2>Findings</h2>${findings
969
- .map(
970
- (item) => `
993
+ const body = `${fieldSection(
994
+ data,
995
+ ['findings', 'files'],
996
+ `<section class="two">${fieldSection(
997
+ data,
998
+ 'findings',
999
+ `<div class="panel"><h2>Findings</h2>${findings
1000
+ .map(
1001
+ (item) => `
971
1002
  <article class="card"><h3>${badge(text(item.severity, 'info'))} ${escapeHtml(itemTitle(item, 'Finding'))}</h3><p class="small">${escapeHtml([text(item.file), text(item.line)].filter(Boolean).join(':'))}</p><p>${escapeHtml(text(item.detail, 'Add review note.'))}</p></article>`,
972
- )
973
- .join('')}</div>
974
- <div class="panel"><h2>Review Tour</h2>${files.length > 0 ? files.map((item) => `<article class="card"><h3>${escapeHtml(text(item.path, 'File'))}</h3><p>${escapeHtml(text(item.why, 'Why this file matters.'))}</p></article>`).join('') : '<p class="muted">Add files with path and why fields for a guided review.</p>'}</div></section>
1003
+ )
1004
+ .join('')}</div>`,
1005
+ )}
1006
+ ${fieldSection(data, 'files', `<div class="panel"><h2>Review Tour</h2>${files.length > 0 ? files.map((item) => `<article class="card"><h3>${escapeHtml(text(item.path, 'File'))}</h3><p>${escapeHtml(text(item.why, 'Why this file matters.'))}</p></article>`).join('') : '<p class="muted">Add files with path and why fields for a guided review.</p>'}</div>`)}</section>`,
1007
+ )}
975
1008
  ${diff ? `<section class="panel" style="margin-top:14px"><h2>Diff Excerpt</h2>${codeBlock(diff)}</section>` : ''}`;
976
1009
  return page({ title, kind: 'review-sheet', summary: descriptor.description, data, body });
977
1010
  }
@@ -980,11 +1013,15 @@ function renderPrWriteup({ title, data, descriptor }: Parameters<PrimitiveRender
980
1013
  const files = fieldRecords(data, 'files', [
981
1014
  { path: 'src/example.ts', why: 'Core behavior changed here.', focus: 'Review edge cases and tests.' },
982
1015
  ]);
983
- const body = `<section class="panel"><h2>Summary</h2><p>${escapeHtml(text(data.summary, 'Summarize the change in one reviewer-friendly paragraph.'))}</p><p>${escapeHtml(text(data.why, 'Explain why this change matters now.'))}</p></section>
984
- <section class="two" style="margin-top:14px"><div class="card"><h2>Before</h2>${list(fieldStrings(data, 'before', ['Current behavior or pain point.']))}</div><div class="card emphasis"><h2>After</h2>${list(fieldStrings(data, 'after', ['New behavior or reviewer-visible outcome.']))}</div></section>
985
- <section class="three" style="margin-top:14px"><div class="panel"><h2>File Tour</h2>${files.map((file) => `<details open><summary><strong>${escapeHtml(text(file.path, 'File'))}</strong></summary><p>${escapeHtml(text(file.why, 'Why this file matters.'))}</p><p class="small">Focus: ${escapeHtml(text(file.focus, 'Review behavior and tests.'))}</p></details>`).join('')}</div>
986
- <div class="panel"><h2>Review Focus</h2>${list(fieldStrings(data, 'reviewFocus', ['Correctness of changed behavior.', 'Missing regression coverage.']))}<h2 style="margin-top:18px">Tests</h2>${list(fieldStrings(data, 'tests', ['Add or run targeted tests.']))}</div>
987
- <div class="panel sticky"><h2>Rollout</h2>${list(fieldStrings(data, 'rollout', ['Merge behind normal release flow.']))}<button type="button" data-copy-markdown style="margin-top:12px">Copy PR markdown</button></div></section>`;
1016
+ const body = `${fieldSection(data, ['summary', 'why'], `<section class="panel">${fieldSection(data, 'summary', `<h2>Summary</h2><p>${escapeHtml(text(data.summary, 'Summarize the change in one reviewer-friendly paragraph.'))}</p>`)}${fieldSection(data, 'why', `<p>${escapeHtml(text(data.why, 'Explain why this change matters now.'))}</p>`)}</section>`)}
1017
+ ${fieldSection(data, ['before', 'after'], `<section class="two" style="margin-top:14px">${fieldSection(data, 'before', `<div class="card"><h2>Before</h2>${list(fieldStrings(data, 'before', ['Current behavior or pain point.']))}</div>`)}${fieldSection(data, 'after', `<div class="card emphasis"><h2>After</h2>${list(fieldStrings(data, 'after', ['New behavior or reviewer-visible outcome.']))}</div>`)}</section>`)}
1018
+ ${fieldSection(
1019
+ data,
1020
+ ['files', 'reviewFocus', 'tests', 'rollout'],
1021
+ `<section class="three" style="margin-top:14px">${fieldSection(data, 'files', `<div class="panel"><h2>File Tour</h2>${files.map((file) => `<details open><summary><strong>${escapeHtml(text(file.path, 'File'))}</strong></summary><p>${escapeHtml(text(file.why, 'Why this file matters.'))}</p>${fieldSection(file, 'focus', `<p class="small">Focus: ${escapeHtml(text(file.focus, 'Review behavior and tests.'))}</p>`)}</details>`).join('')}</div>`)}
1022
+ ${fieldSection(data, ['reviewFocus', 'tests'], `<div class="panel">${fieldSection(data, 'reviewFocus', `<h2>Review Focus</h2>${list(fieldStrings(data, 'reviewFocus', ['Correctness of changed behavior.', 'Missing regression coverage.']))}`)}${fieldSection(data, 'tests', `<h2 style="margin-top:18px">Tests</h2>${list(fieldStrings(data, 'tests', ['Add or run targeted tests.']))}`)}</div>`)}
1023
+ ${fieldSection(data, 'rollout', `<div class="panel sticky"><h2>Rollout</h2>${list(fieldStrings(data, 'rollout', ['Merge behind normal release flow.']))}<button type="button" data-copy-markdown style="margin-top:12px">Copy PR markdown</button></div>`)}</section>`,
1024
+ )}`;
988
1025
  return page({
989
1026
  title,
990
1027
  kind: 'pr-writeup',
@@ -1020,9 +1057,9 @@ function renderSystemMap({ title, data, descriptor }: Parameters<PrimitiveRender
1020
1057
  { from: 'state', to: 'browser', label: 'SSE update' },
1021
1058
  ]);
1022
1059
  const entryPoints = fieldStrings(data, 'entryPoints', ['MCP tools', 'CLI commands', 'HTTP API']);
1023
- const body = `<section class="panel"><h2>Entry Points</h2><div class="swatches">${entryPoints.map((entry) => badge(entry)).join('')}</div></section>
1024
- <section class="grid" style="margin-top:14px">${modules.map((item) => `<article class="card"><div class="small">${escapeHtml(text(item.role, text(item.id, 'module')))}</div><h2>${escapeHtml(itemTitle(item, 'Module'))}</h2><p>${escapeHtml(text(item.detail, 'Describe this module.'))}</p></article>`).join('')}</section>
1025
- <section class="panel" style="margin-top:14px"><h2>Relationships</h2><div class="flow">${edges.map((item) => `<div class="flow-node"><strong>${escapeHtml(text(item.from, 'from'))}</strong><p>${escapeHtml(text(item.label, 'connects'))}</p><strong>${escapeHtml(text(item.to, 'to'))}</strong></div>`).join('<div class="arrow">+</div>')}</div></section>`;
1060
+ const body = `${fieldSection(data, 'entryPoints', `<section class="panel"><h2>Entry Points</h2><div class="swatches">${entryPoints.map((entry) => badge(entry)).join('')}</div></section>`)}
1061
+ ${fieldSection(data, 'modules', `<section class="grid" style="margin-top:14px">${modules.map((item) => `<article class="card"><div class="small">${escapeHtml(text(item.role, text(item.id, 'module')))}</div><h2>${escapeHtml(itemTitle(item, 'Module'))}</h2><p>${escapeHtml(text(item.detail, 'Describe this module.'))}</p></article>`).join('')}</section>`)}
1062
+ ${fieldSection(data, 'edges', `<section class="panel" style="margin-top:14px"><h2>Relationships</h2><div class="flow">${edges.map((item) => `<div class="flow-node"><strong>${escapeHtml(text(item.from, 'from'))}</strong><p>${escapeHtml(text(item.label, 'connects'))}</p><strong>${escapeHtml(text(item.to, 'to'))}</strong></div>`).join('<div class="arrow">+</div>')}</div></section>`)}`;
1026
1063
  return page({ title, kind: 'system-map', summary: descriptor.description, data, body });
1027
1064
  }
1028
1065
 
@@ -1037,10 +1074,14 @@ function renderCodeWalkthrough({ title, data, descriptor }: Parameters<Primitive
1037
1074
  ]);
1038
1075
  const keyFiles = fieldRecords(data, 'keyFiles', []);
1039
1076
  const edges = fieldRecords(data, 'edges', []);
1040
- const body = `<section class="panel"><h2>Path Summary</h2><p>${escapeHtml(text(data.summary, 'Explain the code path this walkthrough covers.'))}</p><div class="flow" style="margin-top:12px">${modules.map((module) => `<div class="flow-node"><div class="small">${escapeHtml(text(module.role, text(module.id, 'module')))}</div><h3>${escapeHtml(itemTitle(module, 'Module'))}</h3><p>${escapeHtml(text(module.detail, ''))}</p></div>`).join('<div class="arrow">-></div>')}</div>${edges.length > 0 ? `<p class="small" style="margin-top:10px">Edges: ${escapeHtml(edges.map((edge) => `${text(edge.from)} -> ${text(edge.to)}${text(edge.label) ? ` (${text(edge.label)})` : ''}`).join(', '))}</p>` : ''}</section>
1041
- <section class="three" style="margin-top:14px"><div class="panel"><h2>Walkthrough</h2>${steps.map((step, index) => `<details ${index === 0 ? 'open' : ''}><summary><strong>${index + 1}. ${escapeHtml(itemTitle(step, 'Step'))}</strong></summary><p class="small">${escapeHtml(text(step.file, ''))}</p><p>${escapeHtml(text(step.detail, ''))}</p>${codeBlock(step.code)}</details>`).join('')}</div>
1042
- <div class="panel"><h2>Key Files</h2>${keyFiles.length > 0 ? keyFiles.map((file) => `<article class="card"><h3>${escapeHtml(text(file.path, 'File'))}</h3><p>${escapeHtml(text(file.description, text(file.why, '')))}</p></article>`).join('') : '<p class="muted">No key files listed.</p>'}</div>
1043
- <div class="panel sticky"><h2>Gotchas</h2>${list(fieldStrings(data, 'gotchas', ['Watch for hidden state, async boundaries, and validation gaps.']))}</div></section>`;
1077
+ const body = `${fieldSection(data, ['summary', 'modules', 'edges'], `<section class="panel">${fieldSection(data, 'summary', `<h2>Path Summary</h2><p>${escapeHtml(text(data.summary, 'Explain the code path this walkthrough covers.'))}</p>`)}${fieldSection(data, 'modules', `<div class="flow" style="margin-top:12px">${modules.map((module) => `<div class="flow-node"><div class="small">${escapeHtml(text(module.role, text(module.id, 'module')))}</div><h3>${escapeHtml(itemTitle(module, 'Module'))}</h3><p>${escapeHtml(text(module.detail, ''))}</p></div>`).join('<div class="arrow">-></div>')}</div>`)}${edges.length > 0 ? `<p class="small" style="margin-top:10px">Edges: ${escapeHtml(edges.map((edge) => `${text(edge.from)} -> ${text(edge.to)}${text(edge.label) ? ` (${text(edge.label)})` : ''}`).join(', '))}</p>` : ''}</section>`)}
1078
+ ${fieldSection(
1079
+ data,
1080
+ ['steps', 'keyFiles', 'gotchas'],
1081
+ `<section class="three" style="margin-top:14px">${fieldSection(data, 'steps', `<div class="panel"><h2>Walkthrough</h2>${steps.map((step, index) => `<details ${index === 0 ? 'open' : ''}><summary><strong>${index + 1}. ${escapeHtml(itemTitle(step, 'Step'))}</strong></summary><p class="small">${escapeHtml(text(step.file, ''))}</p><p>${escapeHtml(text(step.detail, ''))}</p>${codeBlock(step.code)}</details>`).join('')}</div>`)}
1082
+ ${fieldSection(data, 'keyFiles', `<div class="panel"><h2>Key Files</h2>${keyFiles.length > 0 ? keyFiles.map((file) => `<article class="card"><h3>${escapeHtml(text(file.path, 'File'))}</h3><p>${escapeHtml(text(file.description, text(file.why, '')))}</p></article>`).join('') : '<p class="muted">No key files listed.</p>'}</div>`)}
1083
+ ${fieldSection(data, 'gotchas', `<div class="panel sticky"><h2>Gotchas</h2>${list(fieldStrings(data, 'gotchas', ['Watch for hidden state, async boundaries, and validation gaps.']))}</div>`)}</section>`,
1084
+ )}`;
1044
1085
  return page({ title, kind: 'code-walkthrough', summary: descriptor.description, data, body });
1045
1086
  }
1046
1087
 
@@ -1060,18 +1101,22 @@ function renderDesignSheet({ title, data, descriptor }: Parameters<PrimitiveRend
1060
1101
  },
1061
1102
  ]);
1062
1103
  const tokens = fieldRecords(data, 'tokens', []);
1063
- const body = `<section class="grid">${directions
1064
- .map((item) => {
1065
- const palette = strings(item.palette);
1066
- return `<article class="card"><h2>${escapeHtml(itemTitle(item, 'Direction'))}</h2><p>${escapeHtml(text(item.tone, 'Tone'))}</p><div class="swatches">${palette.map((color) => `<span class="swatch" title="${escapeHtml(color)}" style="background:${safeCssColor(color)}"></span>`).join('')}</div><p>${escapeHtml(text(item.rationale, 'Rationale'))}</p></article>`;
1067
- })
1068
- .join('')}</section>
1104
+ const body = `${fieldSection(
1105
+ data,
1106
+ 'directions',
1107
+ `<section class="grid">${directions
1108
+ .map((item) => {
1109
+ const palette = strings(item.palette);
1110
+ return `<article class="card"><h2>${escapeHtml(itemTitle(item, 'Direction'))}</h2><p>${escapeHtml(text(item.tone, 'Tone'))}</p><div class="swatches">${palette.map((color) => `<span class="swatch" title="${escapeHtml(color)}" style="background:${safeCssColor(color)}"></span>`).join('')}</div><p>${escapeHtml(text(item.rationale, 'Rationale'))}</p></article>`;
1111
+ })
1112
+ .join('')}</section>`,
1113
+ )}
1069
1114
  ${tokens.length > 0 ? `<section class="panel" style="margin-top:14px"><h2>Tokens</h2><div class="grid">${tokens.map((item) => `<div class="card"><strong>${escapeHtml(itemTitle(item, 'Token'))}</strong><p>${escapeHtml(text(item.value, ''))}</p></div>`).join('')}</div></section>` : ''}`;
1070
1115
  return page({ title, kind: 'design-sheet', summary: descriptor.description, data, body });
1071
1116
  }
1072
1117
 
1073
1118
  function renderComponentGallery({ title, data, descriptor }: Parameters<PrimitiveRenderer>[0]): string {
1074
- const component = text(data.component, 'Component');
1119
+ const component = text(data.component, 'Component').trim();
1075
1120
  const variants = fieldRecords(data, 'variants', [
1076
1121
  { label: 'Primary', state: 'default', intent: 'Main action', example: 'Continue', note: 'High emphasis.' },
1077
1122
  { label: 'Secondary', state: 'hover', intent: 'Alternative action', example: 'Back', note: 'Lower emphasis.' },
@@ -1083,8 +1128,8 @@ function renderComponentGallery({ title, data, descriptor }: Parameters<Primitiv
1083
1128
  note: 'Requires confirmation.',
1084
1129
  },
1085
1130
  ]);
1086
- const body = `<section class="panel"><h2>${escapeHtml(component)}</h2><p>Variant contact sheet for fast visual review.</p></section>
1087
- <section class="grid" style="margin-top:14px">${variants.map((item) => `<article class="card"><div class="small">${escapeHtml(text(item.state, 'state'))} / ${escapeHtml(text(item.intent, 'intent'))}</div><h2>${escapeHtml(itemTitle(item, 'Variant'))}</h2><button type="button">${escapeHtml(text(item.example, itemTitle(item, 'Example')))}</button><p>${escapeHtml(text(item.note, ''))}</p></article>`).join('')}</section>`;
1131
+ const body = `${component ? `<section class="panel"><h2>${escapeHtml(component)}</h2><p>Variant contact sheet for fast visual review.</p></section>` : ''}
1132
+ ${variants.length > 0 ? `<section class="grid" style="margin-top:14px">${variants.map((item) => `<article class="card"><div class="small">${escapeHtml(text(item.state, 'state'))} / ${escapeHtml(text(item.intent, 'intent'))}</div><h2>${escapeHtml(itemTitle(item, 'Variant'))}</h2><button type="button">${escapeHtml(text(item.example, itemTitle(item, 'Example')))}</button><p>${escapeHtml(text(item.note, ''))}</p></article>`).join('')}</section>` : ''}`;
1088
1133
  return page({ title, kind: 'component-gallery', summary: descriptor.description, data, body });
1089
1134
  }
1090
1135
 
@@ -1100,9 +1145,13 @@ function renderInteractionPrototype({ title, data, descriptor }: Parameters<Prim
1100
1145
  const annotations = fieldRecords(data, 'annotations', [
1101
1146
  { title: 'Decision', detail: 'Tune the values until this feels right.' },
1102
1147
  ]);
1103
- const body = `<section class="three"><div class="panel"><h2>Stage</h2><p>${escapeHtml(text(data.scenario, 'Describe the interaction this prototype evaluates.'))}</p><div class="flow" style="margin-top:16px">${screens.map((screen, index) => `<button class="flow-node" type="button" data-screen="${index}"><h3>${escapeHtml(itemTitle(screen, 'Screen'))}</h3><p>${escapeHtml(text(screen.detail, ''))}</p></button>`).join('<div class="arrow">-></div>')}</div><div class="card emphasis" id="prototype-readout" style="margin-top:14px">Select a screen to inspect it.</div></div>
1104
- <div class="panel"><h2>Controls</h2>${controls.map((control, index) => `<label class="card"><span class="small">${escapeHtml(text(control.key, `control${index}`))}</span><h3>${escapeHtml(text(control.label, 'Control'))}: <span data-control-value="${index}">${escapeHtml(text(control.value, '0'))}</span>${escapeHtml(text(control.unit, ''))}</h3><input type="range" data-control="${index}" min="${number(control.min, 0)}" max="${number(control.max, 1000)}" value="${number(control.value, 0)}"></label>`).join('')}</div>
1105
- <div class="panel sticky"><h2>Notes</h2>${annotations.map((item) => `<article class="card"><h3>${escapeHtml(itemTitle(item, 'Note'))}</h3><p>${escapeHtml(text(item.detail, ''))}</p></article>`).join('')}<h2 style="margin-top:18px">Questions</h2>${list(fieldStrings(data, 'questions', ['Does the timing feel responsive?', 'What should persist after completion?']))}${codeBlock(data.snippet)}</div></section>`;
1148
+ const body = fieldSection(
1149
+ data,
1150
+ ['scenario', 'screens', 'controls', 'annotations', 'questions', 'snippet'],
1151
+ `<section class="three">${fieldSection(data, ['scenario', 'screens'], `<div class="panel">${fieldSection(data, 'scenario', `<h2>Stage</h2><p>${escapeHtml(text(data.scenario, 'Describe the interaction this prototype evaluates.'))}</p>`)}${fieldSection(data, 'screens', `<div class="flow" style="margin-top:16px">${screens.map((screen, index) => `<button class="flow-node" type="button" data-screen="${index}"><h3>${escapeHtml(itemTitle(screen, 'Screen'))}</h3><p>${escapeHtml(text(screen.detail, ''))}</p></button>`).join('<div class="arrow">-></div>')}</div><div class="card emphasis" id="prototype-readout" style="margin-top:14px">Select a screen to inspect it.</div>`)}</div>`)}
1152
+ ${fieldSection(data, 'controls', `<div class="panel"><h2>Controls</h2>${controls.map((control, index) => `<label class="card"><span class="small">${escapeHtml(text(control.key, `control${index}`))}</span><h3>${escapeHtml(text(control.label, 'Control'))}: <span data-control-value="${index}">${escapeHtml(text(control.value, '0'))}</span>${escapeHtml(text(control.unit, ''))}</h3><input type="range" data-control="${index}" min="${number(control.min, 0)}" max="${number(control.max, 1000)}" value="${number(control.value, 0)}"></label>`).join('')}</div>`)}
1153
+ ${fieldSection(data, ['annotations', 'questions', 'snippet'], `<div class="panel sticky">${fieldSection(data, 'annotations', `<h2>Notes</h2>${annotations.map((item) => `<article class="card"><h3>${escapeHtml(itemTitle(item, 'Note'))}</h3><p>${escapeHtml(text(item.detail, ''))}</p></article>`).join('')}`)}${fieldSection(data, 'questions', `<h2 style="margin-top:18px">Questions</h2>${list(fieldStrings(data, 'questions', ['Does the timing feel responsive?', 'What should persist after completion?']))}`)}${codeBlock(data.snippet)}</div>`)}</section>`,
1154
+ );
1106
1155
  return page({
1107
1156
  title,
1108
1157
  kind: 'interaction-prototype',
@@ -1129,8 +1178,12 @@ function renderFlowchart({ title, data, descriptor }: Parameters<PrimitiveRender
1129
1178
  { title: 'Emit event', detail: 'Notify browser and agents through SSE/resources.', status: 'ok', duration: '5ms' },
1130
1179
  ]);
1131
1180
  const failurePaths = fieldRecords(data, 'failurePaths', []);
1132
- const body = `<section class="flow">${steps.map((item, index) => `<button class="flow-node" type="button" data-step="${index}"><h3>${escapeHtml(itemTitle(item, `Step ${index + 1}`))}</h3><p>${badge(text(item.status, 'step'))} ${escapeHtml(text(item.duration))}</p></button>${index < steps.length - 1 ? '<div class="arrow">-></div>' : ''}`).join('')}</section>
1133
- <section class="panel" style="margin-top:14px"><h2 id="step-title">${escapeHtml(itemTitle(steps[0] ?? {}, 'Step'))}</h2><p id="step-detail">${escapeHtml(text((steps[0] ?? {}).detail, 'Select a step.'))}</p></section>
1181
+ const body = `${
1182
+ steps.length > 0
1183
+ ? `<section class="flow">${steps.map((item, index) => `<button class="flow-node" type="button" data-step="${index}"><h3>${escapeHtml(itemTitle(item, `Step ${index + 1}`))}</h3><p>${badge(text(item.status, 'step'))} ${escapeHtml(text(item.duration))}</p></button>${index < steps.length - 1 ? '<div class="arrow">-></div>' : ''}`).join('')}</section>
1184
+ <section class="panel" style="margin-top:14px"><h2 id="step-title">${escapeHtml(itemTitle(steps[0] ?? {}, 'Step'))}</h2><p id="step-detail">${escapeHtml(text(steps[0]?.detail, 'Select a step.'))}</p></section>`
1185
+ : ''
1186
+ }
1134
1187
  ${failurePaths.length > 0 ? `<section class="panel" style="margin-top:14px"><h2>Failure Paths</h2>${failurePaths.map((item) => `<article class="card"><h3>${escapeHtml(text(item.from, 'Step'))}: ${escapeHtml(text(item.label, 'failure'))}</h3><p>${escapeHtml(text(item.detail, ''))}</p></article>`).join('')}</section>` : ''}`;
1135
1188
  return page({
1136
1189
  title,
@@ -1160,12 +1213,16 @@ function renderIllustrationSet({ title, data, descriptor }: Parameters<Primitive
1160
1213
  ],
1161
1214
  },
1162
1215
  ]);
1163
- const body = `<section class="grid">${figures
1164
- .map((figure, index) => {
1165
- const shapes = records(figure.shapes);
1166
- return `<article class="card figure"><h2>${escapeHtml(itemTitle(figure, `Figure ${index + 1}`))}</h2><svg viewBox="0 0 560 260" role="img" aria-label="${escapeHtml(itemTitle(figure, `Figure ${index + 1}`))}" data-figure="${index}"><defs><marker id="arrow" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto" markerUnits="strokeWidth"><path d="M0,0 L0,6 L9,3 z" fill="currentColor"></path></marker></defs>${shapes.map(inlineSvgShape).join('')}</svg><p>${escapeHtml(text(figure.caption, ''))}</p><button type="button" data-copy-svg="${index}">Copy SVG</button></article>`;
1167
- })
1168
- .join('')}</section>`;
1216
+ const body = fieldSection(
1217
+ data,
1218
+ 'figures',
1219
+ `<section class="grid">${figures
1220
+ .map((figure, index) => {
1221
+ const shapes = records(figure.shapes);
1222
+ return `<article class="card figure"><h2>${escapeHtml(itemTitle(figure, `Figure ${index + 1}`))}</h2><svg viewBox="0 0 560 260" role="img" aria-label="${escapeHtml(itemTitle(figure, `Figure ${index + 1}`))}" data-figure="${index}"><defs><marker id="arrow" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto" markerUnits="strokeWidth"><path d="M0,0 L0,6 L9,3 z" fill="currentColor"></path></marker></defs>${shapes.map(inlineSvgShape).join('')}</svg><p>${escapeHtml(text(figure.caption, ''))}</p><button type="button" data-copy-svg="${index}">Copy SVG</button></article>`;
1223
+ })
1224
+ .join('')}</section>`,
1225
+ );
1169
1226
  return page({
1170
1227
  title,
1171
1228
  kind: 'illustration-set',
@@ -1182,7 +1239,11 @@ document.querySelectorAll('[data-copy-svg]').forEach((button) => button.addEvent
1182
1239
 
1183
1240
  function renderDeck({ title, data, descriptor }: Parameters<PrimitiveRenderer>[0]): string {
1184
1241
  const slides = presentationSlides(data, DEFAULT_DECK_SLIDES);
1185
- 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>`;
1242
+ const body = fieldSection(
1243
+ data,
1244
+ 'slides',
1245
+ `<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>`,
1246
+ );
1186
1247
  return page({
1187
1248
  title,
1188
1249
  kind: 'deck',
@@ -1193,6 +1254,7 @@ function renderDeck({ title, data, descriptor }: Parameters<PrimitiveRenderer>[0
1193
1254
  let currentSlide = 0;
1194
1255
  const slides = Array.from(document.querySelectorAll('[data-slide]'));
1195
1256
  function showSlide(index) {
1257
+ if (!slides.length) return;
1196
1258
  currentSlide = Math.max(0, Math.min(slides.length - 1, index));
1197
1259
  slides.forEach((slide, i) => slide.classList.toggle('active', i === currentSlide));
1198
1260
  document.getElementById('slide-count').textContent = String(currentSlide + 1);
@@ -1281,14 +1343,18 @@ function renderPresentation({ title, data, descriptor }: Parameters<PrimitiveRen
1281
1343
  <body>
1282
1344
  <main class="deck">
1283
1345
  <header class="topbar">
1284
- <div class="brand"><div class="eyebrow">PMX presentation</div><div class="title">${escapeHtml(title)}</div><p>${escapeHtml(subtitle)}</p></div>
1346
+ <div class="brand"><div class="eyebrow">PMX presentation</div><div class="title">${escapeHtml(title)}</div>${fieldSection(data, 'subtitle', `<p>${escapeHtml(subtitle)}</p>`)}</div>
1285
1347
  </header>
1286
- <section class="slides">${slideMarkup}</section>
1348
+ ${fieldSection(
1349
+ data,
1350
+ 'slides',
1351
+ `<section class="slides">${slideMarkup}</section>
1287
1352
  <footer class="bottombar">
1288
1353
  <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>
1289
1354
  <div class="hint"><span id="slide-current">1</span> / ${slides.length} - Arrow keys, Space, Page Up/Down</div>
1290
1355
  <div class="progress" aria-hidden="true"><span id="slide-progress"></span></div>
1291
- </footer>
1356
+ </footer>`,
1357
+ )}
1292
1358
  </main>
1293
1359
  <script type="application/json" id="pmx-data">${safeJson(data)}</script>
1294
1360
  <script>
@@ -1296,6 +1362,7 @@ let currentSlide = 0;
1296
1362
  const slides = Array.from(document.querySelectorAll('[data-slide]'));
1297
1363
  const dots = Array.from(document.querySelectorAll('[data-dot]'));
1298
1364
  function showSlide(index) {
1365
+ if (!slides.length) return;
1299
1366
  currentSlide = Math.max(0, Math.min(slides.length - 1, index));
1300
1367
  slides.forEach((slide, i) => slide.classList.toggle('active', i === currentSlide));
1301
1368
  dots.forEach((dot, i) => dot.classList.toggle('active', i === currentSlide));
@@ -1333,10 +1400,10 @@ function renderExplainer({ title, data, descriptor }: Parameters<PrimitiveRender
1333
1400
  const snippets = fieldRecords(data, 'snippets', []);
1334
1401
  const faq = fieldRecords(data, 'faq', []);
1335
1402
  const glossary = fieldRecords(data, 'glossary', []);
1336
- const body = `<section class="panel"><h2>TLDR</h2><p>${escapeHtml(text(data.summary, 'Add the one-paragraph explanation a reader should remember.'))}</p></section>
1337
- <section class="grid" style="margin-top:14px">${steps.map((item, index) => `<details open><summary><strong>${index + 1}. ${escapeHtml(itemTitle(item, 'Step'))}</strong></summary><p>${escapeHtml(text(item.detail, ''))}</p></details>`).join('')}</section>
1403
+ const body = `${fieldSection(data, 'summary', `<section class="panel"><h2>TLDR</h2><p>${escapeHtml(text(data.summary, 'Add the one-paragraph explanation a reader should remember.'))}</p></section>`)}
1404
+ ${fieldSection(data, 'steps', `<section class="grid" style="margin-top:14px">${steps.map((item, index) => `<details open><summary><strong>${index + 1}. ${escapeHtml(itemTitle(item, 'Step'))}</strong></summary><p>${escapeHtml(text(item.detail, ''))}</p></details>`).join('')}</section>`)}
1338
1405
  ${snippets.length > 0 ? `<section class="panel" style="margin-top:14px"><h2>Annotated Snippets</h2>${snippets.map((item) => `<h3>${escapeHtml(itemTitle(item, 'Snippet'))}</h3><p>${escapeHtml(text(item.note, ''))}</p>${codeBlock(item.code)}`).join('')}</section>` : ''}
1339
- <section class="two" style="margin-top:14px"><div class="panel"><h2>FAQ</h2>${faq.map((item) => `<details><summary>${escapeHtml(text(item.q, 'Question'))}</summary><p>${escapeHtml(text(item.a, 'Answer'))}</p></details>`).join('') || '<p class="muted">No FAQ entries yet.</p>'}</div><div class="panel"><h2>Glossary</h2>${glossary.map((item) => `<div class="card"><strong>${escapeHtml(text(item.term, 'Term'))}</strong><p>${escapeHtml(text(item.definition, 'Definition'))}</p></div>`).join('') || '<p class="muted">No glossary entries yet.</p>'}</div></section>`;
1406
+ ${fieldSection(data, ['faq', 'glossary'], `<section class="two" style="margin-top:14px">${fieldSection(data, 'faq', `<div class="panel"><h2>FAQ</h2>${faq.map((item) => `<details><summary>${escapeHtml(text(item.q, 'Question'))}</summary><p>${escapeHtml(text(item.a, 'Answer'))}</p></details>`).join('') || '<p class="muted">No FAQ entries yet.</p>'}</div>`)}${fieldSection(data, 'glossary', `<div class="panel"><h2>Glossary</h2>${glossary.map((item) => `<div class="card"><strong>${escapeHtml(text(item.term, 'Term'))}</strong><p>${escapeHtml(text(item.definition, 'Definition'))}</p></div>`).join('') || '<p class="muted">No glossary entries yet.</p>'}</div>`)}</section>`)}`;
1340
1407
  return page({ title, kind: 'explainer', summary: descriptor.description, data, body });
1341
1408
  }
1342
1409
 
@@ -1345,8 +1412,8 @@ function renderStatusReport({ title, data, descriptor }: Parameters<PrimitiveRen
1345
1412
  { label: 'Health', value: 'on track', tone: 'ok' },
1346
1413
  { label: 'Risk', value: 'medium', tone: 'warn' },
1347
1414
  ]);
1348
- const body = `<section class="grid">${metrics.map((item) => `<article class="card"><div class="small">${escapeHtml(text(item.label, 'Metric'))}</div><h2>${escapeHtml(text(item.value, 'Value'))}</h2>${badge(text(item.tone, 'info'))}</article>`).join('')}</section>
1349
- <section class="grid" style="margin-top:14px"><article class="card"><h2>Shipped</h2>${list(fieldStrings(data, 'shipped', ['Add shipped items.']))}</article><article class="card"><h2>Slipped</h2>${list(fieldStrings(data, 'slipped', ['Add slipped items.']))}</article><article class="card"><h2>Risks</h2>${list(fieldStrings(data, 'risks', ['Add risks.']))}</article><article class="card"><h2>Next</h2>${list(fieldStrings(data, 'next', ['Add next actions.']))}</article></section>`;
1415
+ const body = `${fieldSection(data, 'metrics', `<section class="grid">${metrics.map((item) => `<article class="card"><div class="small">${escapeHtml(text(item.label, 'Metric'))}</div><h2>${escapeHtml(text(item.value, 'Value'))}</h2>${badge(text(item.tone, 'info'))}</article>`).join('')}</section>`)}
1416
+ ${fieldSection(data, ['shipped', 'slipped', 'risks', 'next'], `<section class="grid" style="margin-top:14px">${fieldSection(data, 'shipped', `<article class="card"><h2>Shipped</h2>${list(fieldStrings(data, 'shipped', ['Add shipped items.']))}</article>`)}${fieldSection(data, 'slipped', `<article class="card"><h2>Slipped</h2>${list(fieldStrings(data, 'slipped', ['Add slipped items.']))}</article>`)}${fieldSection(data, 'risks', `<article class="card"><h2>Risks</h2>${list(fieldStrings(data, 'risks', ['Add risks.']))}</article>`)}${fieldSection(data, 'next', `<article class="card"><h2>Next</h2>${list(fieldStrings(data, 'next', ['Add next actions.']))}</article>`)}</section>`)}`;
1350
1417
  return page({ title, kind: 'status-report', summary: descriptor.description, data, body });
1351
1418
  }
1352
1419
 
@@ -1362,11 +1429,25 @@ function renderIncidentReport({ title, data, descriptor }: Parameters<PrimitiveR
1362
1429
  const actions = fieldRecords(data, 'actions', [
1363
1430
  { done: false, owner: 'Unassigned', description: 'Add follow-up action.', due: 'TBD' },
1364
1431
  ]);
1365
- const body = `<section class="grid">${impact.map((item) => `<article class="card metric"><div class="small">${escapeHtml(text(item.label, 'Metric'))}</div><strong>${escapeHtml(text(item.value, 'Value'))}</strong>${badge(text(item.tone, 'info'))}</article>`).join('')}</section>
1366
- <section class="panel" style="margin-top:14px"><h2>Executive Summary</h2><p>${escapeHtml(text(data.summary, 'Summarize user impact, detection, and resolution.'))}</p></section>
1367
- <section class="three" style="margin-top:14px"><div class="panel"><h2>Timeline</h2><div class="timeline">${timeline.map((item, index) => `<div class="step"><div class="dot">${escapeHtml(text(item.time, String(index + 1)))}</div><div class="card"><h3>${escapeHtml(text(item.event, 'Event'))} ${badge(text(item.tone, 'info'))}</h3><p>${escapeHtml(text(item.detail, ''))}</p></div></div>`).join('')}</div></div>
1368
- <div class="panel"><h2>Root Cause</h2><p>${escapeHtml(text(data.rootCause, 'Add confirmed or suspected root cause.'))}</p>${codeBlock(data.logs)}</div>
1369
- <div class="panel sticky"><h2>Actions</h2>${actions.map((action) => `<label class="card"><h3><input type="checkbox" data-action ${action.done === true ? 'checked' : ''}> ${escapeHtml(text(action.description, 'Action'))}</h3><p class="small">${escapeHtml(text(action.owner, 'Owner'))} / ${escapeHtml(text(action.due, 'Due'))}</p></label>`).join('')}<button type="button" data-copy-actions>Copy actions</button></div></section>`;
1432
+ const body = `${fieldSection(
1433
+ data,
1434
+ 'impact',
1435
+ `<section class="grid">${impact
1436
+ .filter((item) => text(item.value).trim())
1437
+ .map(
1438
+ (item) =>
1439
+ `<article class="card metric"><div class="small">${escapeHtml(text(item.label, 'Metric'))}</div><strong>${escapeHtml(text(item.value, 'Value'))}</strong>${badge(text(item.tone, 'info'))}</article>`,
1440
+ )
1441
+ .join('')}</section>`,
1442
+ )}
1443
+ ${fieldSection(data, 'summary', `<section class="panel" style="margin-top:14px"><h2>Executive Summary</h2><p>${escapeHtml(text(data.summary, 'Summarize user impact, detection, and resolution.'))}</p></section>`)}
1444
+ ${fieldSection(
1445
+ data,
1446
+ ['timeline', 'rootCause', 'logs', 'actions'],
1447
+ `<section class="three" style="margin-top:14px">${fieldSection(data, 'timeline', `<div class="panel"><h2>Timeline</h2><div class="timeline">${timeline.map((item, index) => `<div class="step"><div class="dot">${escapeHtml(text(item.time, String(index + 1)))}</div><div class="card"><h3>${escapeHtml(text(item.event, 'Event'))} ${badge(text(item.tone, 'info'))}</h3><p>${escapeHtml(text(item.detail, ''))}</p></div></div>`).join('')}</div></div>`)}
1448
+ ${fieldSection(data, ['rootCause', 'logs'], `<div class="panel">${fieldSection(data, 'rootCause', `<h2>Root Cause</h2><p>${escapeHtml(text(data.rootCause, 'Add confirmed or suspected root cause.'))}</p>`)}${codeBlock(data.logs)}</div>`)}
1449
+ ${fieldSection(data, 'actions', `<div class="panel sticky"><h2>Actions</h2>${actions.map((action) => `<label class="card"><h3><input type="checkbox" data-action ${action.done === true ? 'checked' : ''}> ${escapeHtml(text(action.description, 'Action'))}</h3><p class="small">${escapeHtml(text(action.owner, 'Owner'))} / ${escapeHtml(text(action.due, 'Due'))}</p></label>`).join('')}<button type="button" data-copy-actions>Copy actions</button></div>`)}</section>`,
1450
+ )}`;
1370
1451
  return page({
1371
1452
  title,
1372
1453
  kind: 'incident-report',
@@ -1398,20 +1479,28 @@ function renderTriageBoard({ title, data, descriptor }: Parameters<PrimitiveRend
1398
1479
  rationale: 'Useful but not blocking.',
1399
1480
  },
1400
1481
  ]);
1401
- const body = `<section class="columns">${columns
1402
- .map(
1403
- (column) =>
1404
- `<div class="column" data-column="${escapeHtml(column)}"><h2>${escapeHtml(column)}</h2>${items
1405
- .filter((item) => text(item.column, columns[0]) === column)
1406
- .map(
1407
- (item, index) =>
1408
- `<article class="card ticket" draggable="true" data-ticket="${index}"><h3>${escapeHtml(itemTitle(item, 'Item'))}</h3><p>${escapeHtml(text(item.detail, ''))}</p><p class="small">${escapeHtml(text(item.rationale, ''))}</p></article>`,
1409
- )
1410
- .join('')}</div>`,
1411
- )
1412
- .join(
1413
- '',
1414
- )}</section><p class="small" style="margin-top:12px">Drag cards between columns, then copy JSON or markdown.</p><button type="button" data-copy-markdown>Copy markdown</button>`;
1482
+ const body = fieldSection(
1483
+ data,
1484
+ 'columns',
1485
+ fieldSection(
1486
+ data,
1487
+ 'items',
1488
+ `<section class="columns">${columns
1489
+ .map(
1490
+ (column) =>
1491
+ `<div class="column" data-column="${escapeHtml(column)}"><h2>${escapeHtml(column)}</h2>${items
1492
+ .filter((item) => text(item.column, columns[0]) === column)
1493
+ .map(
1494
+ (item, index) =>
1495
+ `<article class="card ticket" draggable="true" data-ticket="${index}"><h3>${escapeHtml(itemTitle(item, 'Item'))}</h3><p>${escapeHtml(text(item.detail, ''))}</p><p class="small">${escapeHtml(text(item.rationale, ''))}</p></article>`,
1496
+ )
1497
+ .join('')}</div>`,
1498
+ )
1499
+ .join(
1500
+ '',
1501
+ )}</section><p class="small" style="margin-top:12px">Drag cards between columns, then copy JSON or markdown.</p><button type="button" data-copy-markdown>Copy markdown</button>`,
1502
+ ),
1503
+ );
1415
1504
  return page({
1416
1505
  title,
1417
1506
  kind: 'triage-board',
@@ -1465,12 +1554,16 @@ function renderConfigEditor({ title, data, descriptor }: Parameters<PrimitiveRen
1465
1554
  description: 'Routes users through the new flow.',
1466
1555
  },
1467
1556
  ]);
1468
- const body = `<section class="grid">${flags.map((flag, index) => `<label class="card"><div class="small">${escapeHtml(text(flag.area, 'General'))}</div><h3><input type="checkbox" data-flag="${index}" ${flag.enabled === true ? 'checked' : ''}> ${escapeHtml(text(flag.label, text(flag.key, `Flag ${index + 1}`)))}</h3><p>${escapeHtml(text(flag.description, ''))}</p><p class="small">Key: ${escapeHtml(text(flag.key, 'unknown'))}${strings(flag.requires).length > 0 ? ` / requires: ${escapeHtml(strings(flag.requires).join(', '))}` : ''}</p><p class="small" data-warning="${index}"></p></label>`).join('')}</section><button type="button" data-copy-diff style="margin-top:12px">Copy diff</button>`;
1557
+ const body = fieldSection(
1558
+ data,
1559
+ 'flags',
1560
+ `<section class="grid">${flags.map((flag, index) => `<label class="card"><div class="small">${escapeHtml(text(flag.area, 'General'))}</div><h3><input type="checkbox" data-flag="${index}" ${flag.enabled === true ? 'checked' : ''}> ${escapeHtml(text(flag.label, text(flag.key, `Flag ${index + 1}`)))}</h3><p>${escapeHtml(text(flag.description, ''))}</p><p class="small">Key: ${escapeHtml(text(flag.key, 'unknown'))}${strings(flag.requires).length > 0 ? ` / requires: ${escapeHtml(strings(flag.requires).join(', '))}` : ''}</p><p class="small" data-warning="${index}"></p></label>`).join('')}</section><button type="button" data-copy-diff style="margin-top:12px">Copy diff</button>`,
1561
+ );
1469
1562
  return page({
1470
1563
  title,
1471
1564
  kind: 'config-editor',
1472
1565
  summary: descriptor.description,
1473
- data,
1566
+ data: { ...data, flags },
1474
1567
  body,
1475
1568
  script: `
1476
1569
  const originalFlags = PMX_DATA.flags || [];
@@ -1503,7 +1596,11 @@ function renderPromptTuner({ title, data, descriptor }: Parameters<PrimitiveRend
1503
1596
  const samples = fieldRecords(data, 'samples', [
1504
1597
  { name: 'Default', variables: { feature: 'PMX Canvas pins', audience: 'coding agents' } },
1505
1598
  ]);
1506
- const body = `<section class="two"><div class="panel"><h2>Template</h2><textarea id="template">${escapeHtml(template)}</textarea><p class="small"><span id="char-count">0</span> characters</p></div><div class="panel"><h2>Live Samples</h2><div id="previews"></div><button type="button" data-copy-template>Copy template</button></div></section>`;
1599
+ const body = fieldSection(
1600
+ data,
1601
+ 'template',
1602
+ `<section class="two"><div class="panel"><h2>Template</h2><textarea id="template">${escapeHtml(template)}</textarea><p class="small"><span id="char-count">0</span> characters</p></div>${fieldSection(data, 'samples', `<div class="panel"><h2>Live Samples</h2><div id="previews"></div><button type="button" data-copy-template>Copy template</button></div>`)}</section>`,
1603
+ );
1507
1604
  return page({
1508
1605
  title,
1509
1606
  kind: 'prompt-tuner',
@@ -1518,12 +1615,14 @@ function fill(template, vars) {
1518
1615
  return template.replace(/{{\\s*([\\w.-]+)\\s*}}/g, (_, key) => vars?.[key] ?? '{{' + key + '}}');
1519
1616
  }
1520
1617
  function renderPreviews() {
1618
+ if (!templateEl) return;
1521
1619
  const value = templateEl.value;
1522
1620
  document.getElementById('char-count').textContent = String(value.length);
1621
+ if (!previewsEl) return;
1523
1622
  previewsEl.innerHTML = samples.map((sample) => '<div class="card"><h3>' + (sample.name || 'Sample') + '</h3><div class="preview">' + fill(value, sample.variables || {}).replace(/[&<>]/g, (c) => ({'&':'&amp;','<':'&lt;','>':'&gt;'}[c])) + '</div></div>').join('');
1524
1623
  }
1525
- templateEl.addEventListener('input', renderPreviews);
1526
- window.__pmxGetCopyJson = () => ({ ...PMX_DATA, template: templateEl.value });
1624
+ templateEl?.addEventListener('input', renderPreviews);
1625
+ window.__pmxGetCopyJson = () => ({ ...PMX_DATA, template: templateEl?.value ?? '' });
1527
1626
  document.querySelector('[data-copy-template]')?.addEventListener('click', () => copyText(templateEl.value));
1528
1627
  renderPreviews();`,
1529
1628
  });
@@ -1742,7 +1841,7 @@ function renderAxBoard({ title, data, descriptor }: Parameters<PrimitiveRenderer
1742
1841
  <section class="two">
1743
1842
  <div class="panel">
1744
1843
  <h2>Give the agent a task</h2>
1745
- <p class="small">${escapeHtml(note)}</p>
1844
+ ${fieldSection(data, 'note', `<p class="small">${escapeHtml(note)}</p>`)}
1746
1845
  <div class="ax-field"><input type="text" id="ax-task-title" placeholder="What should the agent do?"></div>
1747
1846
  <div class="ax-field"><textarea id="ax-task-detail" placeholder="Optional detail" style="min-height:64px"></textarea></div>
1748
1847
  <div class="ax-row"><button type="button" id="ax-add-task">Add task</button><span class="small" id="ax-task-status"></span></div>
@@ -1882,6 +1981,15 @@ function renderAxFlow({ title, data, descriptor }: Parameters<PrimitiveRenderer>
1882
1981
  const detail = text(step.detail).trim().slice(0, 2000);
1883
1982
  return detail ? { title: stepTitle, detail } : { title: stepTitle };
1884
1983
  });
1984
+ if (steps.length === 0) {
1985
+ return page({
1986
+ title,
1987
+ kind: 'ax-flow',
1988
+ summary: descriptor.description,
1989
+ data,
1990
+ body: fieldSection(data, 'note', `<p class="small">${escapeHtml(note)}</p>`),
1991
+ });
1992
+ }
1885
1993
  const loopRecord = isRecord(data.loop) ? data.loop : {};
1886
1994
  const loopEnabled = loopRecord.enabled === true;
1887
1995
  const maxRuns = Math.min(20, Math.max(1, Math.round(number(loopRecord.maxRuns, 3))));
@@ -1918,7 +2026,7 @@ function renderAxFlow({ title, data, descriptor }: Parameters<PrimitiveRenderer>
1918
2026
  <section class="panel" id="ax-offline" hidden><h2>AX bridge unavailable</h2><p class="small">This flow is not running as an AX-enabled canvas node, so its controls are inert.</p></section>
1919
2027
  <section class="panel">
1920
2028
  <div class="ax-work-head"><h2>${escapeHtml(title)}</h2><span class="small" id="ax-flow-counts"></span></div>
1921
- <p class="small">${escapeHtml(note)}</p>
2029
+ ${fieldSection(data, 'note', `<p class="small">${escapeHtml(note)}</p>`)}
1922
2030
  <div class="ax-flow-scroll">
1923
2031
  <div class="ax-flow-wrap${loopEnabled ? ' looping' : ''}" id="ax-flow-wrap">
1924
2032
  <div class="ax-flow-diagram" id="ax-flow-diagram"></div>
@@ -20,7 +20,7 @@ import {
20
20
  AX_SURFACE_EMIT_SOURCE,
21
21
  HTML_SURFACE_PUSH_SOURCE,
22
22
  } from '../shared/ax-surface-protocol.js';
23
- import { contentHeightReporterTag } from '../shared/content-height-reporter.js';
23
+ import { contentHeightReporterTag, sanitizeFrameToken } from '../shared/content-height-reporter.js';
24
24
  import { type CanvasThemeName, normalizeCanvasThemeName } from '../shared/themes.js';
25
25
 
26
26
  export type SurfaceTheme = CanvasThemeName;
@@ -218,7 +218,12 @@ function escapeSurfaceHtml(value: string): string {
218
218
  * of the main SPA bundle). Escaping is mandatory: the source is arbitrary node
219
219
  * data and must not be parsed as markup.
220
220
  */
221
- export function buildMermaidSurfaceHtml(source: string, inlineEntry?: string | null): string {
221
+ export function buildMermaidSurfaceHtml(
222
+ source: string,
223
+ inlineEntry?: string | null,
224
+ fit: 'contain' | 'none' = 'contain',
225
+ frameToken = '',
226
+ ): string {
222
227
  // srcdoc-rendered surfaces in hosts that block sub-frame subresources (the
223
228
  // Claude Code desktop browser blocks the script request too, not just the
224
229
  // frame document) get the renderer embedded as a data: URL — no network
@@ -228,7 +233,7 @@ export function buildMermaidSurfaceHtml(source: string, inlineEntry?: string | n
228
233
  const entry = inlineEntry
229
234
  ? `<script src="data:text/javascript;charset=utf-8;base64,${Buffer.from(inlineEntry, 'utf-8').toString('base64')}"></script>`
230
235
  : '<script src="/canvas/mermaid-entry.js"></script>';
231
- return `<pre class="mermaid-source" style="display:none">${escapeSurfaceHtml(source)}</pre>${entry}`;
236
+ return `<pre class="mermaid-source" data-fit="${fit}" data-frame-token="${sanitizeFrameToken(frameToken)}" style="display:none">${escapeSurfaceHtml(source)}</pre>${entry}`;
232
237
  }
233
238
 
234
239
  export interface HtmlSurfaceOptions {