agent-whiteboard 1.0.4 → 1.2.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 (202) hide show
  1. package/CHANGELOG.md +76 -0
  2. package/README.md +61 -5
  3. package/dist/client/assets/abnfDiagram-VCTEODGH-Dj8sd_5g.js +1 -0
  4. package/dist/client/assets/{arc-4691b008.js → arc-Aq92EeUK.js} +1 -1
  5. package/dist/client/assets/architectureDiagram-5GKGNRK7-B1QyVL0F.js +36 -0
  6. package/dist/client/assets/blockDiagram-I7D4REHJ-CmU2SSFe.js +129 -0
  7. package/dist/client/assets/{bootstrap.min-21652813.js → bootstrap.min-UtnB-TxT.js} +1 -2
  8. package/dist/client/assets/c4Diagram-7LVT6UL2-DWmhp77c.js +38 -0
  9. package/dist/client/assets/channel-C9ni1Via.js +1 -0
  10. package/dist/client/assets/{chunk-QZHKN3VN-0f554985.js → chunk-2Q5K7J3B-CQLo8Sbv.js} +1 -1
  11. package/dist/client/assets/{chunk-FMBD7UC4-23d48b24.js → chunk-5VM5RSS4-fJTWmTTt.js} +1 -1
  12. package/dist/client/assets/{chunk-ND2GUHAM-1104c326.js → chunk-F27PBJKO-DqzEf5gi.js} +1 -1
  13. package/dist/client/assets/chunk-IMKFNOWR-CMamLTz3.js +231 -0
  14. package/dist/client/assets/{chunk-4BX2VUAB-3c93511d.js → chunk-JWPE2WC7-tyYPyD43.js} +1 -1
  15. package/dist/client/assets/chunk-POPQ4Y6H-XdY4rO_V.js +1 -0
  16. package/dist/client/assets/chunk-SVP7TREG-COvqzqaO.js +88 -0
  17. package/dist/client/assets/chunk-TICWLB2K-BsJSFuPC.js +206 -0
  18. package/dist/client/assets/{chunk-55IACEB6-0243aefd.js → chunk-XXDRQBXY-HfVrYBJb.js} +1 -1
  19. package/dist/client/assets/classDiagram-ZZMXUADV-DktNEiGC.js +1 -0
  20. package/dist/client/assets/classDiagram-v2-VYDZK3BY-DktNEiGC.js +1 -0
  21. package/dist/client/assets/cose-bilkent-JH36ORCC-UXGdwSjS.js +1 -0
  22. package/dist/client/assets/cynefin-OW5HDTMX-PWlPYnpn.js +178 -0
  23. package/dist/client/assets/cynefinDiagram-5FMLGOSQ-CYE5H1wR.js +62 -0
  24. package/dist/client/assets/cytoscape.esm-C5JX3QLV.js +331 -0
  25. package/dist/client/assets/dagre-GXQ25YYZ-DaknBOKq.js +4 -0
  26. package/dist/client/assets/diagram-S7CK7UJ4-Dz7rZHxF.js +30 -0
  27. package/dist/client/assets/diagram-UQ7AKVKN-DzCi6tiS.js +41 -0
  28. package/dist/client/assets/{diagram-KO2AKTUF-81f42eeb.js → diagram-VSXAHHWV-TlUzhXgv.js} +3 -3
  29. package/dist/client/assets/diagram-VX7I27RA-Ds7jdzK7.js +24 -0
  30. package/dist/client/assets/diagram-Z3DM3KII-C--JD7cf.js +24 -0
  31. package/dist/client/assets/ebnfDiagram-PWID7BFC-Dw5OM-dL.js +1 -0
  32. package/dist/client/assets/embed-DcNeILcO.js +211 -0
  33. package/dist/client/assets/erDiagram-RLTQ6QDP-DXalBHjA.js +99 -0
  34. package/dist/client/assets/flowDiagram-HODETNUW-DcI3UqyE.js +168 -0
  35. package/dist/client/assets/ganttDiagram-EL5Y4UJY-DyrkIo6c.js +292 -0
  36. package/dist/client/assets/gitGraphDiagram-WWUBYQGX-fJKfGRcg.js +106 -0
  37. package/dist/client/assets/index-CIhkXT_z.js +19 -0
  38. package/dist/client/assets/index-DEiH2AE7.css +1 -0
  39. package/dist/client/assets/infoDiagram-27XIBGKW-Cijiqswb.js +2 -0
  40. package/dist/client/assets/{ishikawaDiagram-YF4QCWOH-50b43d03.js → ishikawaDiagram-5VMMS53U-C1WqWU2l.js} +2 -2
  41. package/dist/client/assets/{journeyDiagram-JHISSGLW-dce41bdf.js → journeyDiagram-3NMN7TZE-7j05mbLR.js} +4 -4
  42. package/dist/client/assets/{kanban-definition-UN3LZRKU-92401a86.js → kanban-definition-UXKFOSKX-D5mGDfs_.js} +1 -1
  43. package/dist/client/assets/{katex-4c9ea27f.js → katex-BTI-AXqd.js} +3 -3
  44. package/dist/client/assets/{katex-0cba7652.js → katex-C5jXJg4s.js} +3 -3
  45. package/dist/client/assets/layout-KEin06-H.js +1 -0
  46. package/dist/client/assets/linear-D1EL1Azg.js +1 -0
  47. package/dist/client/assets/mermaid.core-CLvSDmrA.js +310 -0
  48. package/dist/client/assets/{mindmap-definition-RKZ34NQL-66fe4544.js → mindmap-definition-YA3MSWOX-BsCqKd_d.js} +12 -12
  49. package/dist/client/assets/{ordinal-db88ae6f.js → ordinal-BeghXfj9.js} +1 -1
  50. package/dist/client/assets/pegDiagram-XKGWAZYB-Du5lDU3W.js +1 -0
  51. package/dist/client/assets/pieDiagram-E7YTZNPT-hjv5Ao6B.js +39 -0
  52. package/dist/client/assets/{quadrantDiagram-W4KKPZXB-f64a7ed7.js → quadrantDiagram-AXDQQJYC-DNon8LWK.js} +4 -4
  53. package/dist/client/assets/railroadDiagram-O6MQD6OU-yNZz2zxo.js +1 -0
  54. package/dist/client/assets/requirementDiagram-BXWQKSXE-CZiiEUHW.js +84 -0
  55. package/dist/client/assets/sankeyDiagram-P5KCCOFB-D-u8ZQUV.js +40 -0
  56. package/dist/client/assets/sequenceDiagram-WJ2MYXX4-BxjTf3q9.js +162 -0
  57. package/dist/client/assets/sizeCapture-INFHLROL-Dt4Wg8C5.js +1 -0
  58. package/dist/client/assets/stateDiagram-D77RDMKH-B8BM6dln.js +1 -0
  59. package/dist/client/assets/stateDiagram-v2-MP3YSRHH-MJauX84T.js +1 -0
  60. package/dist/client/assets/step-BEQzeS8V.js +1 -0
  61. package/dist/client/assets/swimlanes-42K2YHIH-eGWrsWAA.js +1 -0
  62. package/dist/client/assets/swimlanesDiagram-VR7AAH4N-BYFu8tyL.js +8 -0
  63. package/dist/client/assets/{time-043b3464.js → time-BaoZouO5.js} +1 -1
  64. package/dist/client/assets/{timeline-definition-PNZ67QCA-b0c81243.js → timeline-definition-24CTP7MA-CAM-3Qj9.js} +10 -10
  65. package/dist/client/assets/vennDiagram-4TSXK5OY-CuFEwwD0.js +34 -0
  66. package/dist/client/assets/{wardleyDiagram-YWT4CUSO-8d4d7626.js → wardleyDiagram-VM6X3IG4-DI5yCVh0.js} +3 -3
  67. package/dist/client/assets/xychartDiagram-S5SC5T6Z-BI0Bn-OQ.js +7 -0
  68. package/dist/client/index.html +2 -3
  69. package/dist/server/app.js +28 -396
  70. package/dist/server/channel.js +5 -3
  71. package/dist/server/export-html.js +30 -60
  72. package/dist/server/export-zip.js +83 -0
  73. package/dist/server/import-zip.js +196 -0
  74. package/dist/server/index.js +2 -1
  75. package/dist/server/mcp.js +28 -13
  76. package/dist/server/port.js +18 -0
  77. package/dist/server/quiz-submission.js +116 -0
  78. package/dist/server/quiz.js +110 -0
  79. package/dist/server/render-core.js +8 -7
  80. package/dist/server/routes/export.js +95 -0
  81. package/dist/server/routes/import.js +24 -0
  82. package/dist/server/routes/quiz.js +12 -0
  83. package/dist/server/routes/render.js +184 -0
  84. package/dist/server/routes/slideshow.js +52 -0
  85. package/dist/server/routes/snapshots.js +119 -0
  86. package/dist/server/snapshot-reader.js +58 -20
  87. package/dist/server/snapshot-writer.js +18 -16
  88. package/dist/server/validate.js +14 -0
  89. package/dist/server/viewport-cache.js +97 -25
  90. package/package.json +13 -7
  91. package/dist/client/assets/architecture-7EHR7CIX-1221aa61.js +0 -1
  92. package/dist/client/assets/architectureDiagram-3BPJPVTR-1eef4d3c.js +0 -36
  93. package/dist/client/assets/blockDiagram-GPEHLZMM-313fb06c.js +0 -132
  94. package/dist/client/assets/c4Diagram-AAUBKEIU-92b023ad.js +0 -10
  95. package/dist/client/assets/channel-5ee4a8a7.js +0 -1
  96. package/dist/client/assets/chunk-2J33WTMH-fb021fdc.js +0 -1
  97. package/dist/client/assets/chunk-727SXJPM-8e0e0228.js +0 -206
  98. package/dist/client/assets/chunk-AQP2D5EJ-2ccc13c2.js +0 -231
  99. package/dist/client/assets/classDiagram-4FO5ZUOK-42ffecb6.js +0 -1
  100. package/dist/client/assets/classDiagram-v2-Q7XG4LA2-42ffecb6.js +0 -1
  101. package/dist/client/assets/cose-bilkent-S5V4N54A-45ec2e52.js +0 -1
  102. package/dist/client/assets/cytoscape.esm-1491e259.js +0 -331
  103. package/dist/client/assets/dagre-BM42HDAG-ed748885.js +0 -4
  104. package/dist/client/assets/diagram-2AECGRRQ-ccc0ddec.js +0 -43
  105. package/dist/client/assets/diagram-5GNKFQAL-342efdea.js +0 -10
  106. package/dist/client/assets/diagram-LMA3HP47-39e66739.js +0 -24
  107. package/dist/client/assets/diagram-OG6HWLK6-b3e61903.js +0 -24
  108. package/dist/client/assets/embed-2b4adf34.js +0 -201
  109. package/dist/client/assets/erDiagram-TEJ5UH35-281f5cc7.js +0 -85
  110. package/dist/client/assets/eventmodeling-FCH6USID-6508120e.js +0 -1
  111. package/dist/client/assets/flowDiagram-I6XJVG4X-c3a4269e.js +0 -162
  112. package/dist/client/assets/ganttDiagram-6RSMTGT7-013b07ec.js +0 -292
  113. package/dist/client/assets/gitGraph-WXDBUCRP-02827359.js +0 -1
  114. package/dist/client/assets/gitGraphDiagram-PVQCEYII-de184acb.js +0 -106
  115. package/dist/client/assets/graph-f3dfaf53.js +0 -1
  116. package/dist/client/assets/index-d674a6a4.css +0 -1
  117. package/dist/client/assets/index-ffc5af4b.js +0 -5
  118. package/dist/client/assets/info-J43DQDTF-81e32294.js +0 -1
  119. package/dist/client/assets/infoDiagram-5YYISTIA-196101ee.js +0 -2
  120. package/dist/client/assets/layout-3a4eff80.js +0 -1
  121. package/dist/client/assets/linear-fed00341.js +0 -1
  122. package/dist/client/assets/mermaid-parser.core-52f9cd92.js +0 -173
  123. package/dist/client/assets/mermaid.core-7dfc221a.js +0 -306
  124. package/dist/client/assets/packet-YPE3B663-278a272a.js +0 -1
  125. package/dist/client/assets/pie-LRSECV5Y-2e55d9ae.js +0 -1
  126. package/dist/client/assets/pieDiagram-4H26LBE5-65b1bcbd.js +0 -30
  127. package/dist/client/assets/radar-GUYGQ44K-608e459f.js +0 -1
  128. package/dist/client/assets/requirementDiagram-4Y6WPE33-90ebf889.js +0 -84
  129. package/dist/client/assets/sankeyDiagram-5OEKKPKP-2d1a83b3.js +0 -40
  130. package/dist/client/assets/sequenceDiagram-3UESZ5HK-aece3633.js +0 -162
  131. package/dist/client/assets/stateDiagram-AJRCARHV-9bd5b5f4.js +0 -1
  132. package/dist/client/assets/stateDiagram-v2-BHNVJYJU-7c16c251.js +0 -1
  133. package/dist/client/assets/step-171ad54a.js +0 -1
  134. package/dist/client/assets/treeView-BLDUP644-e54b9ddb.js +0 -1
  135. package/dist/client/assets/treemap-LRROVOQU-6a81775b.js +0 -1
  136. package/dist/client/assets/vennDiagram-CIIHVFJN-8eaf0464.js +0 -34
  137. package/dist/client/assets/wardley-L42UT6IY-7abd921f.js +0 -1
  138. package/dist/client/assets/xychartDiagram-2RQKCTM6-450f333c.js +0 -7
  139. /package/dist/client/assets/{KaTeX_AMS-Regular-0cdd387c.woff2 → KaTeX_AMS-Regular-BQhdFMY1.woff2} +0 -0
  140. /package/dist/client/assets/{KaTeX_AMS-Regular-30da91e8.woff → KaTeX_AMS-Regular-DMm9YOAa.woff} +0 -0
  141. /package/dist/client/assets/{KaTeX_AMS-Regular-68534840.ttf → KaTeX_AMS-Regular-DRggAlZN.ttf} +0 -0
  142. /package/dist/client/assets/{KaTeX_Caligraphic-Bold-07d8e303.ttf → KaTeX_Caligraphic-Bold-ATXxdsX0.ttf} +0 -0
  143. /package/dist/client/assets/{KaTeX_Caligraphic-Bold-1ae6bd74.woff → KaTeX_Caligraphic-Bold-BEiXGLvX.woff} +0 -0
  144. /package/dist/client/assets/{KaTeX_Caligraphic-Bold-de7701e4.woff2 → KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2} +0 -0
  145. /package/dist/client/assets/{KaTeX_Caligraphic-Regular-3398dd02.woff → KaTeX_Caligraphic-Regular-CTRA-rTL.woff} +0 -0
  146. /package/dist/client/assets/{KaTeX_Caligraphic-Regular-5d53e70a.woff2 → KaTeX_Caligraphic-Regular-Di6jR-x-.woff2} +0 -0
  147. /package/dist/client/assets/{KaTeX_Caligraphic-Regular-ed0b7437.ttf → KaTeX_Caligraphic-Regular-wX97UBjC.ttf} +0 -0
  148. /package/dist/client/assets/{KaTeX_Fraktur-Bold-9163df9c.ttf → KaTeX_Fraktur-Bold-BdnERNNW.ttf} +0 -0
  149. /package/dist/client/assets/{KaTeX_Fraktur-Bold-9be7ceb8.woff → KaTeX_Fraktur-Bold-BsDP51OF.woff} +0 -0
  150. /package/dist/client/assets/{KaTeX_Fraktur-Bold-74444efd.woff2 → KaTeX_Fraktur-Bold-CL6g_b3V.woff2} +0 -0
  151. /package/dist/client/assets/{KaTeX_Fraktur-Regular-1e6f9579.ttf → KaTeX_Fraktur-Regular-CB_wures.ttf} +0 -0
  152. /package/dist/client/assets/{KaTeX_Fraktur-Regular-51814d27.woff2 → KaTeX_Fraktur-Regular-CTYiF6lA.woff2} +0 -0
  153. /package/dist/client/assets/{KaTeX_Fraktur-Regular-5e28753b.woff → KaTeX_Fraktur-Regular-Dxdc4cR9.woff} +0 -0
  154. /package/dist/client/assets/{KaTeX_Main-Bold-0f60d1b8.woff2 → KaTeX_Main-Bold-Cx986IdX.woff2} +0 -0
  155. /package/dist/client/assets/{KaTeX_Main-Bold-c76c5d69.woff → KaTeX_Main-Bold-Jm3AIy58.woff} +0 -0
  156. /package/dist/client/assets/{KaTeX_Main-Bold-138ac28d.ttf → KaTeX_Main-Bold-waoOVXN0.ttf} +0 -0
  157. /package/dist/client/assets/{KaTeX_Main-BoldItalic-99cd42a3.woff2 → KaTeX_Main-BoldItalic-DxDJ3AOS.woff2} +0 -0
  158. /package/dist/client/assets/{KaTeX_Main-BoldItalic-70ee1f64.ttf → KaTeX_Main-BoldItalic-DzxPMmG6.ttf} +0 -0
  159. /package/dist/client/assets/{KaTeX_Main-BoldItalic-a6f7ec0d.woff → KaTeX_Main-BoldItalic-SpSLRI95.woff} +0 -0
  160. /package/dist/client/assets/{KaTeX_Main-Italic-0d85ae7c.ttf → KaTeX_Main-Italic-3WenGoN9.ttf} +0 -0
  161. /package/dist/client/assets/{KaTeX_Main-Italic-f1d6ef86.woff → KaTeX_Main-Italic-BMLOBm91.woff} +0 -0
  162. /package/dist/client/assets/{KaTeX_Main-Italic-97479ca6.woff2 → KaTeX_Main-Italic-NWA7e6Wa.woff2} +0 -0
  163. /package/dist/client/assets/{KaTeX_Main-Regular-c2342cd8.woff2 → KaTeX_Main-Regular-B22Nviop.woff2} +0 -0
  164. /package/dist/client/assets/{KaTeX_Main-Regular-c6368d87.woff → KaTeX_Main-Regular-Dr94JaBh.woff} +0 -0
  165. /package/dist/client/assets/{KaTeX_Main-Regular-d0332f52.ttf → KaTeX_Main-Regular-ypZvNtVU.ttf} +0 -0
  166. /package/dist/client/assets/{KaTeX_Math-BoldItalic-f9377ab0.ttf → KaTeX_Math-BoldItalic-B3XSjfu4.ttf} +0 -0
  167. /package/dist/client/assets/{KaTeX_Math-BoldItalic-dc47344d.woff2 → KaTeX_Math-BoldItalic-CZnvNsCZ.woff2} +0 -0
  168. /package/dist/client/assets/{KaTeX_Math-BoldItalic-850c0af5.woff → KaTeX_Math-BoldItalic-iY-2wyZ7.woff} +0 -0
  169. /package/dist/client/assets/{KaTeX_Math-Italic-8a8d2445.woff → KaTeX_Math-Italic-DA0__PXp.woff} +0 -0
  170. /package/dist/client/assets/{KaTeX_Math-Italic-08ce98e5.ttf → KaTeX_Math-Italic-flOr_0UB.ttf} +0 -0
  171. /package/dist/client/assets/{KaTeX_Math-Italic-7af58c5e.woff2 → KaTeX_Math-Italic-t53AETM-.woff2} +0 -0
  172. /package/dist/client/assets/{KaTeX_SansSerif-Bold-1ece03f7.ttf → KaTeX_SansSerif-Bold-CFMepnvq.ttf} +0 -0
  173. /package/dist/client/assets/{KaTeX_SansSerif-Bold-e99ae511.woff2 → KaTeX_SansSerif-Bold-D1sUS0GD.woff2} +0 -0
  174. /package/dist/client/assets/{KaTeX_SansSerif-Bold-ece03cfd.woff → KaTeX_SansSerif-Bold-DbIhKOiC.woff} +0 -0
  175. /package/dist/client/assets/{KaTeX_SansSerif-Italic-00b26ac8.woff2 → KaTeX_SansSerif-Italic-C3H0VqGB.woff2} +0 -0
  176. /package/dist/client/assets/{KaTeX_SansSerif-Italic-91ee6750.woff → KaTeX_SansSerif-Italic-DN2j7dab.woff} +0 -0
  177. /package/dist/client/assets/{KaTeX_SansSerif-Italic-3931dd81.ttf → KaTeX_SansSerif-Italic-YYjJ1zSn.ttf} +0 -0
  178. /package/dist/client/assets/{KaTeX_SansSerif-Regular-f36ea897.ttf → KaTeX_SansSerif-Regular-BNo7hRIc.ttf} +0 -0
  179. /package/dist/client/assets/{KaTeX_SansSerif-Regular-11e4dc8a.woff → KaTeX_SansSerif-Regular-CS6fqUqJ.woff} +0 -0
  180. /package/dist/client/assets/{KaTeX_SansSerif-Regular-68e8c73e.woff2 → KaTeX_SansSerif-Regular-DDBCnlJ7.woff2} +0 -0
  181. /package/dist/client/assets/{KaTeX_Script-Regular-1c67f068.ttf → KaTeX_Script-Regular-C5JkGWo-.ttf} +0 -0
  182. /package/dist/client/assets/{KaTeX_Script-Regular-036d4e95.woff2 → KaTeX_Script-Regular-D3wIWfF6.woff2} +0 -0
  183. /package/dist/client/assets/{KaTeX_Script-Regular-d96cdf2b.woff → KaTeX_Script-Regular-D5yQViql.woff} +0 -0
  184. /package/dist/client/assets/{KaTeX_Size1-Regular-c943cc98.woff → KaTeX_Size1-Regular-C195tn64.woff} +0 -0
  185. /package/dist/client/assets/{KaTeX_Size1-Regular-95b6d2f1.ttf → KaTeX_Size1-Regular-Dbsnue_I.ttf} +0 -0
  186. /package/dist/client/assets/{KaTeX_Size1-Regular-6b47c401.woff2 → KaTeX_Size1-Regular-mCD8mA8B.woff2} +0 -0
  187. /package/dist/client/assets/{KaTeX_Size2-Regular-a6b2099f.ttf → KaTeX_Size2-Regular-B7gKUWhC.ttf} +0 -0
  188. /package/dist/client/assets/{KaTeX_Size2-Regular-d04c5421.woff2 → KaTeX_Size2-Regular-Dy4dx90m.woff2} +0 -0
  189. /package/dist/client/assets/{KaTeX_Size2-Regular-2014c523.woff → KaTeX_Size2-Regular-oD1tc_U0.woff} +0 -0
  190. /package/dist/client/assets/{KaTeX_Size3-Regular-6ab6b62e.woff → KaTeX_Size3-Regular-CTq5MqoE.woff} +0 -0
  191. /package/dist/client/assets/{KaTeX_Size3-Regular-500e04d5.ttf → KaTeX_Size3-Regular-DgpXs0kz.ttf} +0 -0
  192. /package/dist/client/assets/{KaTeX_Size4-Regular-99f9c675.woff → KaTeX_Size4-Regular-BF-4gkZK.woff} +0 -0
  193. /package/dist/client/assets/{KaTeX_Size4-Regular-c647367d.ttf → KaTeX_Size4-Regular-DWFBv043.ttf} +0 -0
  194. /package/dist/client/assets/{KaTeX_Size4-Regular-a4af7d41.woff2 → KaTeX_Size4-Regular-Dl5lxZxV.woff2} +0 -0
  195. /package/dist/client/assets/{KaTeX_Typewriter-Regular-e14fed02.woff → KaTeX_Typewriter-Regular-C0xS9mPB.woff} +0 -0
  196. /package/dist/client/assets/{KaTeX_Typewriter-Regular-71d517d6.woff2 → KaTeX_Typewriter-Regular-CO6r4hn1.woff2} +0 -0
  197. /package/dist/client/assets/{KaTeX_Typewriter-Regular-f01f3e87.ttf → KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf} +0 -0
  198. /package/dist/client/assets/{colors-98f238a0.js → colors-Cc3OSVma.js} +0 -0
  199. /package/dist/client/assets/{defaultLocale-a8476f12.js → defaultLocale-CzDG__Ur.js} +0 -0
  200. /package/dist/client/assets/{index-a1656aec.js → index-Bi7T_FA4.js} +0 -0
  201. /package/dist/client/assets/{init-a5b10ee5.js → init-Dmth1JHB.js} +0 -0
  202. /package/dist/client/assets/{range-6bc2a9a0.js → range-OtVwhkKS.js} +0 -0
@@ -0,0 +1,116 @@
1
+ // Quiz submit → wait_quiz handoff (F48, F49 in docs/03 §10).
2
+ //
3
+ // POST /quiz/submit stores the user's answers; wait_quiz / POST /wait-quiz
4
+ // long-poll for them. Modeled on wait_click's single-flight interaction, but
5
+ // carrying a payload. A submission made while no wait is armed is retained and
6
+ // handed to the next wait_quiz — but only while the same quiz is still on the
7
+ // canvas (identity of the Presentation object, which every render / history
8
+ // load replaces).
9
+ import { createSingleFlightInteraction } from "./interaction.js";
10
+ import { parseQuizPayload } from "./quiz.js";
11
+ import { getCanvas } from "./session.js";
12
+ const interaction = createSingleFlightInteraction({ type: "timeout" }, { type: "superseded" });
13
+ let waiting = false;
14
+ /** Id of the newest wait — a superseded wait's cleanup must not clear `waiting` for its successor. */
15
+ let latestWait = 0;
16
+ let retained = null;
17
+ function getActiveQuiz() {
18
+ const presentation = getCanvas().presentation;
19
+ const frame = presentation?.frames[0];
20
+ if (!presentation || !frame || frame.type !== "quiz")
21
+ return null;
22
+ const parsed = parseQuizPayload(frame.payload);
23
+ if (!parsed.ok)
24
+ return null;
25
+ return { presentation, quiz: parsed.quiz, mode: parsed.mode };
26
+ }
27
+ function isRecord(v) {
28
+ return typeof v === "object" && v !== null && !Array.isArray(v);
29
+ }
30
+ export function submitQuiz(rawAnswers, rawScore) {
31
+ const active = getActiveQuiz();
32
+ if (!active)
33
+ return { ok: false, error: "no quiz on the canvas" };
34
+ if (!Array.isArray(rawAnswers))
35
+ return { ok: false, error: "answers must be an array" };
36
+ const byQuestion = new Map();
37
+ for (const a of rawAnswers) {
38
+ if (!isRecord(a) || typeof a.questionId !== "string" || !Array.isArray(a.selected) || !a.selected.every((s) => typeof s === "string")) {
39
+ return { ok: false, error: "each answer must be { questionId: string, selected: string[] }" };
40
+ }
41
+ if (byQuestion.has(a.questionId))
42
+ return { ok: false, error: `duplicate answer for question "${a.questionId}"` };
43
+ byQuestion.set(a.questionId, a.selected);
44
+ }
45
+ const answers = [];
46
+ for (const q of active.quiz.questions) {
47
+ const selected = byQuestion.get(q.id);
48
+ if (!selected || selected.length === 0)
49
+ return { ok: false, error: `question "${q.id}" is unanswered` };
50
+ if (new Set(selected).size !== selected.length)
51
+ return { ok: false, error: `duplicate options selected for question "${q.id}"` };
52
+ const known = new Set(q.options.map((o) => o.id));
53
+ if (!selected.every((s) => known.has(s)))
54
+ return { ok: false, error: `unknown option for question "${q.id}"` };
55
+ if (q.kind === "single" && selected.length !== 1)
56
+ return { ok: false, error: `question "${q.id}" is single-choice` };
57
+ answers.push({ questionId: q.id, selected });
58
+ }
59
+ for (const id of byQuestion.keys()) {
60
+ if (!active.quiz.questions.some((q) => q.id === id))
61
+ return { ok: false, error: `unknown question "${id}"` };
62
+ }
63
+ let score;
64
+ if (active.mode === "inline" && rawScore !== undefined) {
65
+ if (!isRecord(rawScore) || typeof rawScore.earned !== "number" || typeof rawScore.possible !== "number"
66
+ || !Number.isFinite(rawScore.earned) || !Number.isFinite(rawScore.possible)
67
+ || rawScore.earned < 0 || rawScore.possible < rawScore.earned) {
68
+ return { ok: false, error: "score must be { earned, possible } with 0 <= earned <= possible" };
69
+ }
70
+ score = { earned: rawScore.earned, possible: rawScore.possible };
71
+ }
72
+ const submission = {
73
+ presentation: active.presentation,
74
+ questions: active.quiz,
75
+ answers,
76
+ ...(score ? { score } : {}),
77
+ };
78
+ if (waiting) {
79
+ interaction.resolve({ type: "submitted", submission });
80
+ }
81
+ else {
82
+ retained = submission;
83
+ }
84
+ return { ok: true };
85
+ }
86
+ function toResult(s) {
87
+ return { ok: true, type: "submitted", questions: s.questions, answers: s.answers, ...(s.score ? { score: s.score } : {}) };
88
+ }
89
+ /** Long-polls for the user's submission (10-minute timeout, shared with wait_done/wait_click). */
90
+ export async function waitForQuiz() {
91
+ const active = getActiveQuiz();
92
+ if (!active)
93
+ return { ok: false, error: "no quiz on the canvas — call render({ type: \"quiz\", ... }) first" };
94
+ if (retained) {
95
+ const r = retained;
96
+ retained = null;
97
+ if (r.presentation === active.presentation)
98
+ return toResult(r);
99
+ }
100
+ const myWait = ++latestWait;
101
+ waiting = true;
102
+ try {
103
+ const event = await interaction.await();
104
+ return event.type === "submitted" ? toResult(event.submission) : { ok: true, type: event.type };
105
+ }
106
+ finally {
107
+ if (myWait === latestWait)
108
+ waiting = false;
109
+ }
110
+ }
111
+ /** Drops retained/pending state — for tests only. */
112
+ export function resetQuizSubmission() {
113
+ retained = null;
114
+ interaction.reset();
115
+ waiting = false;
116
+ }
@@ -0,0 +1,110 @@
1
+ // Quiz payload parsing + validation (F44, F45, F47 in docs/03 §10).
2
+ //
3
+ // A quiz is a single-frame `render()` payload: JSON describing questions with
4
+ // predefined options. Mode is derived from the answer key (F45): every
5
+ // question has a `correct` option → "inline" (client scores); none do →
6
+ // "deferred" (the agent scores via wait_quiz); a mix is rejected.
7
+ const WEIGHT_EPSILON = 1e-6;
8
+ function isRecord(v) {
9
+ return typeof v === "object" && v !== null && !Array.isArray(v);
10
+ }
11
+ function fail(error) {
12
+ return { ok: false, error: `invalid quiz payload: ${error}` };
13
+ }
14
+ export function parseQuizPayload(payload) {
15
+ let raw;
16
+ try {
17
+ raw = JSON.parse(payload);
18
+ }
19
+ catch {
20
+ return fail("payload must be valid JSON");
21
+ }
22
+ if (!isRecord(raw))
23
+ return fail("payload must be a JSON object");
24
+ if (raw.title !== undefined && typeof raw.title !== "string")
25
+ return fail("title must be a string");
26
+ if (!Array.isArray(raw.questions) || raw.questions.length === 0) {
27
+ return fail("questions must be a non-empty array");
28
+ }
29
+ const questionIds = new Set();
30
+ let keyedCount = 0;
31
+ for (const [qi, q] of raw.questions.entries()) {
32
+ const where = `questions[${qi}]`;
33
+ if (!isRecord(q))
34
+ return fail(`${where} must be an object`);
35
+ if (typeof q.id !== "string" || q.id === "")
36
+ return fail(`${where}.id must be a non-empty string`);
37
+ if (questionIds.has(q.id))
38
+ return fail(`duplicate question id "${q.id}"`);
39
+ questionIds.add(q.id);
40
+ if (typeof q.text !== "string" || q.text === "")
41
+ return fail(`${where}.text must be a non-empty string`);
42
+ if (q.kind !== "single" && q.kind !== "multiple")
43
+ return fail(`${where}.kind must be "single" or "multiple"`);
44
+ if (q.scoring !== undefined && q.scoring !== "all_or_nothing" && q.scoring !== "partial") {
45
+ return fail(`${where}.scoring must be "all_or_nothing" or "partial"`);
46
+ }
47
+ if (q.scoring === "partial" && q.kind !== "multiple") {
48
+ return fail(`${where}: partial scoring is only valid for kind "multiple"`);
49
+ }
50
+ if (q.points !== undefined && (typeof q.points !== "number" || !Number.isFinite(q.points) || q.points <= 0)) {
51
+ return fail(`${where}.points must be a positive number`);
52
+ }
53
+ if (!Array.isArray(q.options) || q.options.length < 2) {
54
+ return fail(`${where}.options must be an array of at least 2 options`);
55
+ }
56
+ const optionIds = new Set();
57
+ let correctCount = 0;
58
+ let weightSum = 0;
59
+ let weightedCount = 0;
60
+ for (const [oi, o] of q.options.entries()) {
61
+ const ow = `${where}.options[${oi}]`;
62
+ if (!isRecord(o))
63
+ return fail(`${ow} must be an object`);
64
+ if (typeof o.id !== "string" || o.id === "")
65
+ return fail(`${ow}.id must be a non-empty string`);
66
+ if (optionIds.has(o.id))
67
+ return fail(`duplicate option id "${o.id}" in question "${q.id}"`);
68
+ optionIds.add(o.id);
69
+ if (typeof o.text !== "string" || o.text === "")
70
+ return fail(`${ow}.text must be a non-empty string`);
71
+ if (o.correct !== undefined && typeof o.correct !== "boolean")
72
+ return fail(`${ow}.correct must be a boolean`);
73
+ if (o.weight !== undefined) {
74
+ if (typeof o.weight !== "number" || !Number.isFinite(o.weight) || o.weight <= 0) {
75
+ return fail(`${ow}.weight must be a positive number`);
76
+ }
77
+ if (o.correct !== true)
78
+ return fail(`${ow}: weight is only valid on a correct option`);
79
+ if (q.scoring !== "partial")
80
+ return fail(`${ow}: weight requires scoring "partial"`);
81
+ weightSum += o.weight;
82
+ weightedCount++;
83
+ }
84
+ if (o.correct === true)
85
+ correctCount++;
86
+ }
87
+ if (correctCount > 0)
88
+ keyedCount++;
89
+ if (correctCount > 0 && q.kind === "single" && correctCount !== 1) {
90
+ return fail(`question "${q.id}" is single-choice but has ${correctCount} correct options`);
91
+ }
92
+ if (correctCount > 0 && q.scoring === "partial") {
93
+ if (weightedCount !== correctCount) {
94
+ return fail(`question "${q.id}": every correct option needs a weight under partial scoring`);
95
+ }
96
+ if (Math.abs(weightSum - 1) > WEIGHT_EPSILON) {
97
+ return fail(`question "${q.id}": correct option weights must sum to 1 (got ${weightSum})`);
98
+ }
99
+ }
100
+ }
101
+ if (keyedCount !== 0 && keyedCount !== raw.questions.length) {
102
+ return fail("answer key is mixed: either every question has a correct option (inline) or none does (deferred)");
103
+ }
104
+ return { ok: true, quiz: raw, mode: keyedCount === 0 ? "deferred" : "inline" };
105
+ }
106
+ /** Returns null on success, an error string on failure (validateFrame convention). */
107
+ export function validateQuizPayload(payload) {
108
+ const result = parseQuizPayload(payload);
109
+ return result.ok ? null : result.error;
110
+ }
@@ -5,7 +5,7 @@ import { cancelSlideshow } from "./slideshow.js";
5
5
  import { broadcastReplace, broadcastStepFrames } from "./ws.js";
6
6
  import { generateSnapshotId } from "./snapshot-writer.js";
7
7
  import { assembleStepFramesPayload, persistContent } from "./persist.js";
8
- import { validateFrame } from "./validate.js";
8
+ import { validateFrame, validateRenderPayload } from "./validate.js";
9
9
  import { getCanvas, isStepSequence, seekStepFrame, setCanvas, setLastWorkspace, setStepFrames, stepCursor } from "./session.js";
10
10
  import { appendFrame, commitBuilder, createBuilder } from "./step-frames-builder.js";
11
11
  import { getViewport } from "./viewport-cache.js";
@@ -115,7 +115,8 @@ export function commitStepFramesResult(id, nodeToFrame) {
115
115
  */
116
116
  export async function applyLoadedSnapshotResult(frames, workspace, title, nodeToFrame, snapshotId, rawPayload) {
117
117
  for (const frame of frames) {
118
- const validationError = await validateFrame(frame);
118
+ // A quiz is a single-frame render() type (F44): valid on its own, never inside a multi-frame sequence.
119
+ const validationError = frames.length === 1 ? await validateRenderPayload(frame.type, frame.payload) : await validateFrame(frame);
119
120
  if (validationError) {
120
121
  return { ok: false, error: validationError };
121
122
  }
@@ -125,7 +126,7 @@ export async function applyLoadedSnapshotResult(frames, workspace, title, nodeTo
125
126
  // B19/FR21 — composite id:frameIndex key). Looked up under the snapshot's
126
127
  // own id, not a synthesized one — a synthesized id can never have a cache
127
128
  // entry anyway.
128
- const viewport = snapshotId !== undefined ? getViewport(snapshotId, 0) : undefined;
129
+ const viewport = snapshotId !== undefined ? await getViewport(snapshotId, 0) : undefined;
129
130
  // A concrete id is required on every broadcast (v0.26 Sprint 42) — pre-v0.11
130
131
  // snapshots may lack one (J1, `02`), so synthesize a fresh one here.
131
132
  const resolvedId = snapshotId ?? generateSnapshotId();
@@ -161,7 +162,7 @@ export async function applyLoadedSnapshotResult(frames, workspace, title, nodeTo
161
162
  * verbatim between app.ts and mcp.ts, the one hot-path pair render-core.ts's
162
163
  * own NF12 header comment didn't yet cover).
163
164
  */
164
- export function stepAndBroadcast(direction) {
165
+ export async function stepAndBroadcast(direction) {
165
166
  const result = stepCursor(direction);
166
167
  if (!result) {
167
168
  return { ok: false, error: "no step-frames sequence is loaded" };
@@ -174,7 +175,7 @@ export function stepAndBroadcast(direction) {
174
175
  // restores its own saved viewport independently (v0.26.1, bug B19/FR21) —
175
176
  // no longer "must not re-fit" for the whole sequence.
176
177
  const resolvedId = id ?? generateSnapshotId();
177
- const viewport = getViewport(resolvedId, result.currentFrame);
178
+ const viewport = await getViewport(resolvedId, result.currentFrame);
178
179
  broadcastStepFrames(frames, state.frameType, result.currentFrame, resolvedId, title, state.nodeToFrame, viewport);
179
180
  }
180
181
  return { ok: true, current_frame: result.currentFrame, total_frames: result.totalFrames };
@@ -183,7 +184,7 @@ export function stepAndBroadcast(direction) {
183
184
  * Jumps the step cursor to an arbitrary frame index and broadcasts it.
184
185
  * Shared by POST /seek and the MCP `seek` tool (NF19, see `stepAndBroadcast`).
185
186
  */
186
- export function seekAndBroadcast(frame) {
187
+ export async function seekAndBroadcast(frame) {
187
188
  const state = getCanvas();
188
189
  if (!isStepSequence(state)) {
189
190
  return { ok: false, error: "no step-frames sequence is loaded" };
@@ -207,7 +208,7 @@ export function seekAndBroadcast(frame) {
207
208
  id: resolvedId,
208
209
  // Per-frame restore (v0.26.1, bug B19/FR21) — each frame of a sequence
209
210
  // re-fits or restores independently instead of sharing one viewport.
210
- viewport: getViewport(resolvedId, frame),
211
+ viewport: await getViewport(resolvedId, frame),
211
212
  });
212
213
  return { ok: true, current_frame: frame, total_frames: total };
213
214
  }
@@ -0,0 +1,95 @@
1
+ import { exportCanvas } from "../session.js";
2
+ import { findSnapshotById, findSnapshotByIdInWorkspace } from "../snapshot-reader.js";
3
+ import { validateWorkspaceInput } from "../render-core.js";
4
+ import { generateExportHtml } from "../export-html.js";
5
+ import { generateExportZip } from "../export-zip.js";
6
+ import { getSnapshotsRoot } from "../paths.js";
7
+ export function registerExportRoutes(app) {
8
+ app.get("/export", async (c) => {
9
+ const id = c.req.query("id");
10
+ if (id !== undefined && id !== "") {
11
+ const root = getSnapshotsRoot();
12
+ const payload = await findSnapshotById(id, root);
13
+ if (payload === null) {
14
+ return c.json({ ok: false, error: "graph not found" }, 404);
15
+ }
16
+ return c.json({ ok: true, data: payload });
17
+ }
18
+ return c.json({ ok: true, data: exportCanvas() });
19
+ });
20
+ // ── HTML Export (v0.13) ────────────────────────────────────────────────────
21
+ app.post("/export-html", async (c) => {
22
+ const body = await c.req.json();
23
+ if (!Array.isArray(body.items) || body.items.length === 0) {
24
+ return c.json({ ok: false, error: "items must be a non-empty array" }, 400);
25
+ }
26
+ // Default cdn (v0.32, F23): offline (fully embedded, today's pre-v0.32
27
+ // behavior) is opt-in via mode: "offline"; any other value falls back to cdn.
28
+ const mode = body.mode === "offline" ? "offline" : "cdn";
29
+ const root = getSnapshotsRoot();
30
+ const validItems = [];
31
+ for (const item of body.items) {
32
+ if (typeof item !== "object" || item === null)
33
+ continue;
34
+ const { workspace, id } = item;
35
+ if (typeof workspace !== "string")
36
+ continue;
37
+ const validated = validateWorkspaceInput(workspace);
38
+ if (!validated.ok)
39
+ continue;
40
+ if (typeof id !== "string")
41
+ continue;
42
+ const record = await findSnapshotByIdInWorkspace(validated.workspace, id, root);
43
+ if (record === null)
44
+ continue;
45
+ validItems.push({ workspace: validated.workspace, id, record });
46
+ }
47
+ if (validItems.length === 0) {
48
+ return c.json({ ok: false, error: "no valid items to export" }, 400);
49
+ }
50
+ const { html, downloadFilename } = await generateExportHtml(validItems, mode);
51
+ return new Response(html, {
52
+ status: 200,
53
+ headers: {
54
+ "Content-Type": "text/html; charset=utf-8",
55
+ "Content-Disposition": `attachment; filename="${downloadFilename}"`,
56
+ },
57
+ });
58
+ });
59
+ // ── Zip Export (v1.6, F36) ───────────────────────────────────────────────
60
+ app.post("/export-zip", async (c) => {
61
+ const body = await c.req.json();
62
+ if (!Array.isArray(body.items) || body.items.length === 0) {
63
+ return c.json({ ok: false, error: "items must be a non-empty array" }, 400);
64
+ }
65
+ const zipItems = [];
66
+ for (const item of body.items) {
67
+ if (typeof item !== "object" || item === null)
68
+ continue;
69
+ const { workspace, id } = item;
70
+ if (typeof workspace !== "string")
71
+ continue;
72
+ const validated = validateWorkspaceInput(workspace);
73
+ if (!validated.ok)
74
+ continue;
75
+ if (typeof id !== "string")
76
+ continue;
77
+ zipItems.push({ workspace: validated.workspace, id });
78
+ }
79
+ if (zipItems.length === 0) {
80
+ return c.json({ ok: false, error: "no valid items to export" }, 400);
81
+ }
82
+ const root = getSnapshotsRoot();
83
+ const result = await generateExportZip(zipItems, root);
84
+ if (!result.ok) {
85
+ return c.json({ ok: false, error: result.error }, 400);
86
+ }
87
+ return new Response(new Uint8Array(result.zip), {
88
+ status: 200,
89
+ headers: {
90
+ "Content-Type": "application/zip",
91
+ "Content-Disposition": `attachment; filename="${result.downloadFilename}"`,
92
+ },
93
+ });
94
+ });
95
+ }
@@ -0,0 +1,24 @@
1
+ import { bodyLimit } from "hono/body-limit";
2
+ import { importZip } from "../import-zip.js";
3
+ import { getSnapshotsRoot } from "../paths.js";
4
+ // NF55 — a per-route override, larger than app.ts's global 10MB JSON cap
5
+ // (app.ts excludes this path from that global middleware so the two caps
6
+ // don't stack — the stricter one would otherwise always win).
7
+ export const IMPORT_MAX_BODY_SIZE_BYTES = 50 * 1024 * 1024;
8
+ export function registerImportRoutes(app) {
9
+ app.post("/import", bodyLimit({ maxSize: IMPORT_MAX_BODY_SIZE_BYTES }), async (c) => {
10
+ const body = await c.req.parseBody();
11
+ const file = body["file"];
12
+ if (!(file instanceof File)) {
13
+ return c.json({ ok: false, error: "file is required" }, 400);
14
+ }
15
+ const mode = body["mode"] === "merge" ? "merge" : "create";
16
+ const buffer = Buffer.from(await file.arrayBuffer());
17
+ const root = getSnapshotsRoot();
18
+ const result = await importZip(buffer, body["targetWorkspace"], mode, root);
19
+ if (!result.ok) {
20
+ return c.json({ ok: false, error: result.error }, 400);
21
+ }
22
+ return c.json(result);
23
+ });
24
+ }
@@ -0,0 +1,12 @@
1
+ import { submitQuiz, waitForQuiz } from "../quiz-submission.js";
2
+ export function registerQuizRoutes(app) {
3
+ // Browser → server: the user's answers (and, for an inline quiz, the
4
+ // client-computed score). Resolves a pending wait_quiz, or is retained for the next one.
5
+ app.post("/quiz/submit", async (c) => {
6
+ const body = await c.req.json().catch(() => ({}));
7
+ const result = submitQuiz(body.answers, body.score);
8
+ return c.json(result, result.ok ? 200 : 400);
9
+ });
10
+ // REST twin of the MCP wait_quiz tool.
11
+ app.post("/wait-quiz", async (c) => c.json(await waitForQuiz()));
12
+ }
@@ -0,0 +1,184 @@
1
+ import { signalClick, signalDone, waitForClick, waitForDone } from "../interaction.js";
2
+ import { clearCanvas } from "../session.js";
3
+ import { broadcast } from "../ws.js";
4
+ import { FRAME_TYPES, RENDER_TYPES, nodeActionsSchema, nodeToFrameSchema, validateRenderPayload } from "../validate.js";
5
+ import { cancelSlideshow } from "../slideshow.js";
6
+ import { appendFrameAndBroadcast, commitRenderResult, commitStepFramesResult, initStepFramesResult, seekAndBroadcast, stepAndBroadcast, validateWorkspaceInput, } from "../render-core.js";
7
+ import { setViewport } from "../viewport-cache.js";
8
+ import { parsePort } from "../port.js";
9
+ export function registerRenderRoutes(app) {
10
+ app.post("/render", async (c) => {
11
+ const body = await c.req.json();
12
+ const workspaceResult = validateWorkspaceInput(body.options?.workspace);
13
+ if (!workspaceResult.ok) {
14
+ return c.json({ ok: false, error: workspaceResult.error }, 400);
15
+ }
16
+ const { workspace } = workspaceResult;
17
+ if (typeof body.payload !== "string") {
18
+ return c.json({ ok: false, error: "payload must be a string" }, 400);
19
+ }
20
+ if (!RENDER_TYPES.includes(body.type)) {
21
+ return c.json({ ok: false, error: `type must be one of: ${RENDER_TYPES.join(", ")}` }, 400);
22
+ }
23
+ const type = body.type;
24
+ const { payload } = body;
25
+ const title = body.options?.title;
26
+ const validationError = await validateRenderPayload(type, payload);
27
+ if (validationError) {
28
+ return c.json({ ok: false, error: validationError });
29
+ }
30
+ const result = commitRenderResult(type, payload, workspace, title);
31
+ return c.json(result);
32
+ });
33
+ app.post("/step", async (c) => {
34
+ const body = await c.req.json();
35
+ if (body.direction !== "next" && body.direction !== "prev") {
36
+ return c.json({ ok: false, error: 'direction must be "next" or "prev"' }, 400);
37
+ }
38
+ return c.json(await stepAndBroadcast(body.direction));
39
+ });
40
+ app.post("/seek", async (c) => {
41
+ const body = await c.req.json();
42
+ if (typeof body.frame !== "number" || !Number.isInteger(body.frame)) {
43
+ return c.json({ ok: false, error: "frame must be an integer" }, 400);
44
+ }
45
+ return c.json(await seekAndBroadcast(body.frame));
46
+ });
47
+ app.post("/clear", (c) => {
48
+ cancelSlideshow({ persist: false }); // clear() must never produce a snapshot (F10)
49
+ clearCanvas();
50
+ broadcast({ action: "clear" });
51
+ return c.json({ ok: true });
52
+ });
53
+ // ── Incremental step-frames builder (v0.8) ───────────────────────────────────
54
+ app.post("/step-frames/init", async (c) => {
55
+ const body = await c.req.json();
56
+ if (typeof body.frame_type !== "string" || !FRAME_TYPES.includes(body.frame_type)) {
57
+ return c.json({
58
+ ok: false,
59
+ error: `frame_type must be one of: ${FRAME_TYPES.join(", ")}`,
60
+ }, 400);
61
+ }
62
+ const workspaceResult = validateWorkspaceInput(body.workspace);
63
+ if (!workspaceResult.ok) {
64
+ return c.json({ ok: false, error: workspaceResult.error }, 400);
65
+ }
66
+ const { workspace } = workspaceResult;
67
+ const title = typeof body.title === "string" ? body.title : undefined;
68
+ const { id } = initStepFramesResult(body.frame_type, workspace, title);
69
+ return c.json({ ok: true, id });
70
+ });
71
+ app.post("/step-frames/:id/frame", async (c) => {
72
+ const id = c.req.param("id");
73
+ const body = await c.req.json();
74
+ if (typeof body.payload !== "string") {
75
+ return c.json({ ok: false, error: "payload must be a string" }, 400);
76
+ }
77
+ const label = typeof body.label === "string" ? body.label : undefined;
78
+ const type = typeof body.type === "string" ? body.type : undefined;
79
+ const result = await appendFrameAndBroadcast(id, body.payload, label, type);
80
+ if (!result.ok) {
81
+ return c.json(result, result.error.includes("not found or expired") ? 404 : 400);
82
+ }
83
+ return c.json({ ok: true, frame_count: result.frame_count });
84
+ });
85
+ app.post("/step-frames/:id/commit", async (c) => {
86
+ const id = c.req.param("id");
87
+ let nodeToFrame;
88
+ try {
89
+ const body = await c.req.json();
90
+ if (body.node_to_frame !== undefined) {
91
+ const parsed = nodeToFrameSchema.safeParse(body.node_to_frame);
92
+ if (!parsed.success) {
93
+ return c.json({ ok: false, error: "node_to_frame must be a map of node ID → frame index" }, 400);
94
+ }
95
+ nodeToFrame = parsed.data;
96
+ }
97
+ }
98
+ catch {
99
+ // No body or non-JSON body — commit with no node_to_frame.
100
+ }
101
+ const result = commitStepFramesResult(id, nodeToFrame);
102
+ if (!result.ok) {
103
+ return c.json(result, result.error.includes("not found or expired") ? 404 : 400);
104
+ }
105
+ return c.json(result);
106
+ });
107
+ // ── User events — bidirectionality (Sprint 10 experiment) ───────────────────
108
+ app.post("/user-done", async (c) => {
109
+ signalDone(); // wake any pending wait_done() MCP tool calls
110
+ // Also forward to channel relay if Claude Code was started with the channels flag.
111
+ try {
112
+ const channelPort = parsePort(process.env.CHANNEL_PORT, 3001, "CHANNEL_PORT");
113
+ await fetch(`http://127.0.0.1:${channelPort}/user-done`, { method: "POST" });
114
+ }
115
+ catch {
116
+ // Channel server not running, or CHANNEL_PORT misconfigured — ignore.
117
+ }
118
+ return c.json({ ok: true });
119
+ });
120
+ app.post("/wait-done", async (c) => {
121
+ await waitForDone();
122
+ return c.json({ ok: true });
123
+ });
124
+ // ── Node / edge click events (Phase 2 — Sprint 12) ───────────────────────────
125
+ app.post("/node-click", async (c) => {
126
+ const body = await c.req.json();
127
+ if (body.type !== "node" && body.type !== "edge") {
128
+ return c.json({ ok: false, error: 'type must be "node" or "edge"' }, 400);
129
+ }
130
+ const event = {
131
+ type: body.type,
132
+ id: body.id ?? "",
133
+ label: body.label ?? "",
134
+ action: body.action ?? null,
135
+ };
136
+ signalClick(event); // no-op if no wait_click() is pending
137
+ return c.json({ ok: true });
138
+ });
139
+ app.post("/wait-click", async (c) => {
140
+ let nodeActions = {};
141
+ try {
142
+ const body = await c.req.json();
143
+ if (body.node_actions !== undefined) {
144
+ const parsed = nodeActionsSchema.safeParse(body.node_actions);
145
+ if (!parsed.success) {
146
+ return c.json({ ok: false, error: "node_actions must be a map of node ID → string[]" }, 400);
147
+ }
148
+ nodeActions = parsed.data;
149
+ }
150
+ }
151
+ catch {
152
+ // No body or non-JSON body — treat as plain click (node_actions stays {}).
153
+ }
154
+ broadcast({ action: "set_node_actions", node_actions: nodeActions, enabled: true });
155
+ const event = await waitForClick();
156
+ broadcast({ action: "set_node_actions", enabled: false });
157
+ return c.json({ ok: true, ...event });
158
+ });
159
+ // ── Mermaid viewport persistence (v0.19, F19/C3) ────────────────────────────
160
+ app.post("/viewport", async (c) => {
161
+ const body = await c.req.json();
162
+ if (typeof body.id !== "string" || body.id.length === 0) {
163
+ return c.json({ ok: false, error: "id must be a non-empty string" }, 400);
164
+ }
165
+ // frame (v0.26.1, bug B19/FR21): the cache key is now id:frameIndex, so each
166
+ // frame of a sequence persists its own manual viewport independently.
167
+ if (typeof body.frame !== "number" || !Number.isInteger(body.frame) || body.frame < 0) {
168
+ return c.json({ ok: false, error: "frame must be a non-negative integer" }, 400);
169
+ }
170
+ const { scale, positionX, positionY } = body;
171
+ if (typeof scale !== "number" || !Number.isFinite(scale)) {
172
+ return c.json({ ok: false, error: "scale must be a finite number" }, 400);
173
+ }
174
+ if (typeof positionX !== "number" || !Number.isFinite(positionX)) {
175
+ return c.json({ ok: false, error: "positionX must be a finite number" }, 400);
176
+ }
177
+ if (typeof positionY !== "number" || !Number.isFinite(positionY)) {
178
+ return c.json({ ok: false, error: "positionY must be a finite number" }, 400);
179
+ }
180
+ const viewport = { scale, positionX, positionY };
181
+ await setViewport(body.id, body.frame, viewport);
182
+ return c.json({ ok: true });
183
+ });
184
+ }
@@ -0,0 +1,52 @@
1
+ import { FRAME_TYPES, validateFrame } from "../validate.js";
2
+ import { cancelSlideshow, startSlideshow } from "../slideshow.js";
3
+ import { validateWorkspaceInput } from "../render-core.js";
4
+ export function registerSlideshowRoutes(app) {
5
+ app.post("/slideshow", async (c) => {
6
+ const body = await c.req.json();
7
+ if (!Array.isArray(body.slides) || body.slides.length === 0) {
8
+ return c.json({ ok: false, error: "slides must be a non-empty array" }, 400);
9
+ }
10
+ if (typeof body.delay_ms !== "number" || body.delay_ms <= 0) {
11
+ return c.json({ ok: false, error: "delay_ms must be a positive number" }, 400);
12
+ }
13
+ const workspaceResult = validateWorkspaceInput(body.workspace);
14
+ if (!workspaceResult.ok) {
15
+ return c.json({ ok: false, error: workspaceResult.error }, 400);
16
+ }
17
+ const { workspace } = workspaceResult;
18
+ const rawSlides = body.slides;
19
+ // Validate each slide — same rules as POST /render.
20
+ const validatedSlides = [];
21
+ for (let i = 0; i < rawSlides.length; i++) {
22
+ const s = rawSlides[i];
23
+ if (typeof s.type !== "string" || typeof s.payload !== "string") {
24
+ return c.json({
25
+ ok: false,
26
+ error: `slide[${i}]: "type" and "payload" must be strings`,
27
+ }, 400);
28
+ }
29
+ if (!FRAME_TYPES.includes(s.type)) {
30
+ return c.json({ ok: false, error: `slide[${i}]: type must be one of: ${FRAME_TYPES.join(", ")}` }, 400);
31
+ }
32
+ if (s.title !== undefined && typeof s.title !== "string") {
33
+ return c.json({ ok: false, error: `slide[${i}]: "title" must be a string` }, 400);
34
+ }
35
+ const err = await validateFrame({ type: s.type, payload: s.payload });
36
+ if (err) {
37
+ return c.json({ ok: false, error: `slide[${i}]: ${err}` });
38
+ }
39
+ validatedSlides.push({
40
+ type: s.type,
41
+ payload: s.payload,
42
+ ...(s.title !== undefined ? { title: s.title } : {}),
43
+ });
44
+ }
45
+ startSlideshow(validatedSlides, body.delay_ms, workspace);
46
+ return c.json({ ok: true });
47
+ });
48
+ app.post("/slideshow/stop", (c) => {
49
+ cancelSlideshow();
50
+ return c.json({ ok: true });
51
+ });
52
+ }