@legionworks/facet 1.9.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 (327) hide show
  1. package/LICENSE-APACHE +202 -0
  2. package/LICENSE-MIT +23 -0
  3. package/README.md +158 -0
  4. package/dist/gallery/chunk-4g8rem85.css +656 -0
  5. package/dist/gallery/chunk-c6s9k9ty.js +5970 -0
  6. package/dist/gallery/frame/artifact.css +8424 -0
  7. package/dist/gallery/frame/chunks/abnfDiagram-N423BO3Z-jj74szbm.js +155 -0
  8. package/dist/gallery/frame/chunks/architecture-TIHT7OUA-cvdjn7dt.js +10 -0
  9. package/dist/gallery/frame/chunks/architectureDiagram-T3A2C74G-n4z77eg6.js +8675 -0
  10. package/dist/gallery/frame/chunks/blockDiagram-VBNYF7ZC-7xsqzrd1.js +3529 -0
  11. package/dist/gallery/frame/chunks/c4Diagram-5PPSVZJV-z2zjs7wx.js +2413 -0
  12. package/dist/gallery/frame/chunks/classDiagram-JCYQIIEL-ve8rngqp.js +47 -0
  13. package/dist/gallery/frame/chunks/classDiagram-v2-OCEON4UE-8cfaxb8z.js +47 -0
  14. package/dist/gallery/frame/chunks/cose-bilkent-JH36ORCC-sn92qexx.js +4822 -0
  15. package/dist/gallery/frame/chunks/cynefin-VYW2F7L2-tqjv913d.js +10 -0
  16. package/dist/gallery/frame/chunks/cynefinDiagram-MW4NZA55-c1c0dj12.js +500 -0
  17. package/dist/gallery/frame/chunks/dagre-VZM6K2ZE-7tyg4yt2.js +476 -0
  18. package/dist/gallery/frame/chunks/diagram-7IWD3JNH-y4eng9sc.js +526 -0
  19. package/dist/gallery/frame/chunks/diagram-B4RE2ZJO-rn3p7j5h.js +673 -0
  20. package/dist/gallery/frame/chunks/diagram-LBJQPF4R-w4a2cm9q.js +255 -0
  21. package/dist/gallery/frame/chunks/diagram-Q27KOJAE-aqc2v7wy.js +575 -0
  22. package/dist/gallery/frame/chunks/diagram-UB23O5K3-xncy8nyd.js +355 -0
  23. package/dist/gallery/frame/chunks/ebnfDiagram-BXEA7PRR-acwgxz16.js +175 -0
  24. package/dist/gallery/frame/chunks/erDiagram-JOGREHBK-mzp5nswt.js +1332 -0
  25. package/dist/gallery/frame/chunks/eventmodeling-45OFAUF4-sg07qr0c.js +10 -0
  26. package/dist/gallery/frame/chunks/flowDiagram-UKHOOZJN-2e5ygs44.js +29 -0
  27. package/dist/gallery/frame/chunks/ganttDiagram-PKOTCBZU-p2hhh7hr.js +2620 -0
  28. package/dist/gallery/frame/chunks/gitGraph-TEB2WS4Q-j6kw0naa.js +10 -0
  29. package/dist/gallery/frame/chunks/gitGraphDiagram-DS77QQ5N-4z9z8wjm.js +1347 -0
  30. package/dist/gallery/frame/chunks/info-DKCQHKI2-mpt80jd3.js +10 -0
  31. package/dist/gallery/frame/chunks/infoDiagram-6WML65LV-5rqm3mgg.js +65 -0
  32. package/dist/gallery/frame/chunks/ishikawaDiagram-WSZJBQD7-b51z96jy.js +975 -0
  33. package/dist/gallery/frame/chunks/journeyDiagram-NVQOT4AX-9jbfp0pk.js +1272 -0
  34. package/dist/gallery/frame/chunks/kanban-definition-27J2QSJJ-4x0vx4se.js +1116 -0
  35. package/dist/gallery/frame/chunks/katex-xfgk95tv.js +14034 -0
  36. package/dist/gallery/frame/chunks/markdown-1s5vwkf2.js +9 -0
  37. package/dist/gallery/frame/chunks/markdown-2h6bwsms.js +3239 -0
  38. package/dist/gallery/frame/chunks/markdown-2x113yh8.js +107 -0
  39. package/dist/gallery/frame/chunks/markdown-30b7rwmp.js +1943 -0
  40. package/dist/gallery/frame/chunks/markdown-356bfj4g.js +2031 -0
  41. package/dist/gallery/frame/chunks/markdown-44n9ea69.js +2406 -0
  42. package/dist/gallery/frame/chunks/markdown-56enrf2r.js +39 -0
  43. package/dist/gallery/frame/chunks/markdown-5avymm8t.js +725 -0
  44. package/dist/gallery/frame/chunks/markdown-6r4svars.js +33 -0
  45. package/dist/gallery/frame/chunks/markdown-6zz8efx2.js +24 -0
  46. package/dist/gallery/frame/chunks/markdown-7jp6prnb.js +19 -0
  47. package/dist/gallery/frame/chunks/markdown-809k7q41.js +55 -0
  48. package/dist/gallery/frame/chunks/markdown-82xmzhzd.js +1076 -0
  49. package/dist/gallery/frame/chunks/markdown-92n37gd4.js +7627 -0
  50. package/dist/gallery/frame/chunks/markdown-ag2sssf8.js +36 -0
  51. package/dist/gallery/frame/chunks/markdown-b4s7ntq2.js +19 -0
  52. package/dist/gallery/frame/chunks/markdown-c9k0fvp1.js +100 -0
  53. package/dist/gallery/frame/chunks/markdown-ceefymn8.js +125 -0
  54. package/dist/gallery/frame/chunks/markdown-d1wfbs70.js +243 -0
  55. package/dist/gallery/frame/chunks/markdown-dy5sgjhc.js +11022 -0
  56. package/dist/gallery/frame/chunks/markdown-dz3z5w30.js +30868 -0
  57. package/dist/gallery/frame/chunks/markdown-eg6ngz7y.js +30340 -0
  58. package/dist/gallery/frame/chunks/markdown-ff5wvb43.js +1149 -0
  59. package/dist/gallery/frame/chunks/markdown-ffg06nfz.js +86 -0
  60. package/dist/gallery/frame/chunks/markdown-fvfncadf.js +472 -0
  61. package/dist/gallery/frame/chunks/markdown-gvyszqpv.js +19 -0
  62. package/dist/gallery/frame/chunks/markdown-gx1p0ew0.js +88 -0
  63. package/dist/gallery/frame/chunks/markdown-gxye5frz.js +65 -0
  64. package/dist/gallery/frame/chunks/markdown-h7pzv34e.js +105 -0
  65. package/dist/gallery/frame/chunks/markdown-hab789t9.js +47 -0
  66. package/dist/gallery/frame/chunks/markdown-hjp6sj8t.js +125 -0
  67. package/dist/gallery/frame/chunks/markdown-jka64qgp.js +1993 -0
  68. package/dist/gallery/frame/chunks/markdown-k8xkn8mw.js +56 -0
  69. package/dist/gallery/frame/chunks/markdown-m7frq8x8.js +112 -0
  70. package/dist/gallery/frame/chunks/markdown-p740v4qm.js +36 -0
  71. package/dist/gallery/frame/chunks/markdown-pxfeh231.js +57 -0
  72. package/dist/gallery/frame/chunks/markdown-pzgd8z3s.js +87 -0
  73. package/dist/gallery/frame/chunks/markdown-qx3cngz2.js +1673 -0
  74. package/dist/gallery/frame/chunks/markdown-rawhfvvc.js +85 -0
  75. package/dist/gallery/frame/chunks/markdown-rd8w0ng9.js +49 -0
  76. package/dist/gallery/frame/chunks/markdown-rm2yxfjj.js +2773 -0
  77. package/dist/gallery/frame/chunks/markdown-s1jatrnk.js +993 -0
  78. package/dist/gallery/frame/chunks/markdown-sgfg5vvq.js +59 -0
  79. package/dist/gallery/frame/chunks/markdown-t4ethv9d.js +409 -0
  80. package/dist/gallery/frame/chunks/markdown-tnmabqvz.js +36 -0
  81. package/dist/gallery/frame/chunks/markdown-tvdpppw8.js +5292 -0
  82. package/dist/gallery/frame/chunks/markdown-v0x27twr.js +22 -0
  83. package/dist/gallery/frame/chunks/markdown-v14td68x.js +36 -0
  84. package/dist/gallery/frame/chunks/markdown-w6sgd54z.js +36 -0
  85. package/dist/gallery/frame/chunks/markdown-xw8dsk8b.js +2125 -0
  86. package/dist/gallery/frame/chunks/markdown-y18mve73.js +85 -0
  87. package/dist/gallery/frame/chunks/markdown-yengsms5.js +427 -0
  88. package/dist/gallery/frame/chunks/markdown-z1wc0v0d.js +1826 -0
  89. package/dist/gallery/frame/chunks/markdown-zazx9h4n.js +329 -0
  90. package/dist/gallery/frame/chunks/mermaid-de7p4v0x.js +30 -0
  91. package/dist/gallery/frame/chunks/mindmap-definition-FAOFIHXS-ra44cbem.js +1216 -0
  92. package/dist/gallery/frame/chunks/packet-7NZHBO7P-xppm5913.js +10 -0
  93. package/dist/gallery/frame/chunks/pegDiagram-VL7TDLO6-a2a4wg03.js +163 -0
  94. package/dist/gallery/frame/chunks/pie-RZYD4A2V-hnbxk152.js +10 -0
  95. package/dist/gallery/frame/chunks/pieDiagram-7S7Q4E2Y-f1vxjtmx.js +311 -0
  96. package/dist/gallery/frame/chunks/quadrantDiagram-CIZ2JOQS-ywafa6kh.js +1390 -0
  97. package/dist/gallery/frame/chunks/radar-I7S5WNFK-ewqd27se.js +10 -0
  98. package/dist/gallery/frame/chunks/railroad-3IZDKUUU-5hy4bdx3.js +10 -0
  99. package/dist/gallery/frame/chunks/railroad-abnf-AHOZXSZD-bh5sxz0d.js +10 -0
  100. package/dist/gallery/frame/chunks/railroad-ebnf-EBAXGLYW-6b9c3zeh.js +10 -0
  101. package/dist/gallery/frame/chunks/railroad-peg-LSFZ7HO6-avc8v39s.js +10 -0
  102. package/dist/gallery/frame/chunks/railroadDiagram-AXF67PYL-qc61wax4.js +131 -0
  103. package/dist/gallery/frame/chunks/requirementDiagram-LRYGKXZP-9bs4786c.js +1295 -0
  104. package/dist/gallery/frame/chunks/sankeyDiagram-W5VNT64P-awt1em8b.js +1339 -0
  105. package/dist/gallery/frame/chunks/sequenceDiagram-SI44F4Z6-qaf9mz3t.js +4324 -0
  106. package/dist/gallery/frame/chunks/sizeCapture-X5ZJPWSS-9fq685xd.js +64 -0
  107. package/dist/gallery/frame/chunks/stateDiagram-OKZ733FA-vx7a7hh0.js +456 -0
  108. package/dist/gallery/frame/chunks/stateDiagram-v2-UEYNNEHI-x4m6ws5t.js +46 -0
  109. package/dist/gallery/frame/chunks/swimlanes-SLNWSIFB-e4mxm1gh.js +8287 -0
  110. package/dist/gallery/frame/chunks/swimlanesDiagram-ULZ7WXOC-0h93e2qq.js +42 -0
  111. package/dist/gallery/frame/chunks/timeline-definition-Z64GVDOM-wp0bhbzg.js +1549 -0
  112. package/dist/gallery/frame/chunks/treeView-QDETBFTQ-hcx7n0vt.js +10 -0
  113. package/dist/gallery/frame/chunks/treemap-6X3UGDF4-av02skdw.js +10 -0
  114. package/dist/gallery/frame/chunks/vennDiagram-T6HMQDX7-pmvk1p7n.js +2566 -0
  115. package/dist/gallery/frame/chunks/wardley-OPB4EBWU-njsp6e40.js +10 -0
  116. package/dist/gallery/frame/chunks/wardleyDiagram-T6FBY63Y-eggmnv4z.js +979 -0
  117. package/dist/gallery/frame/chunks/xychartDiagram-ELKLHX3M-q40tnpe5.js +1954 -0
  118. package/dist/gallery/frame/frame.css +183 -0
  119. package/dist/gallery/frame/runtime/chart.js +42744 -0
  120. package/dist/gallery/frame/runtime/html.js +14 -0
  121. package/dist/gallery/frame/runtime/markdown.js +1403 -0
  122. package/dist/gallery/frame/runtime/mermaid.js +32 -0
  123. package/dist/gallery/frame/runtime/svg.js +14 -0
  124. package/dist/gallery/frame/runtime/tsx.js +52 -0
  125. package/dist/gallery/index.html +104 -0
  126. package/docs/reference/cli.md +163 -0
  127. package/docs/reference/export.md +106 -0
  128. package/docs/reference/html.md +215 -0
  129. package/docs/reference/http.md +85 -0
  130. package/docs/reference/mcp.md +78 -0
  131. package/docs/reference/security.md +119 -0
  132. package/docs/reference/storage.md +93 -0
  133. package/docs/reference/tsx.md +101 -0
  134. package/docs/reference/validation.md +212 -0
  135. package/package.json +89 -0
  136. package/scripts/build-gallery.ts +127 -0
  137. package/scripts/launch-netns.sh +67 -0
  138. package/skills/facet/SKILL.md +100 -0
  139. package/src/cli/client.ts +311 -0
  140. package/src/cli/commands/create.ts +49 -0
  141. package/src/cli/commands/doctor.ts +249 -0
  142. package/src/cli/commands/export.ts +179 -0
  143. package/src/cli/commands/instantiate.ts +35 -0
  144. package/src/cli/commands/list.ts +49 -0
  145. package/src/cli/commands/open.ts +75 -0
  146. package/src/cli/commands/pin.ts +39 -0
  147. package/src/cli/commands/promote.ts +49 -0
  148. package/src/cli/commands/publish.ts +122 -0
  149. package/src/cli/commands/read-back.ts +56 -0
  150. package/src/cli/commands/status.ts +114 -0
  151. package/src/cli/commands/watch.ts +146 -0
  152. package/src/cli/main.ts +522 -0
  153. package/src/cli/output.ts +106 -0
  154. package/src/cli/parser.ts +376 -0
  155. package/src/cli/presenter.ts +212 -0
  156. package/src/cli/service-metadata.ts +134 -0
  157. package/src/cli/spawn-service.ts +326 -0
  158. package/src/css.d.ts +4 -0
  159. package/src/gallery-web/app.ts +1581 -0
  160. package/src/gallery-web/export-menu.ts +121 -0
  161. package/src/gallery-web/export.ts +112 -0
  162. package/src/gallery-web/favicon.ts +54 -0
  163. package/src/gallery-web/frame/entries/chart.ts +6 -0
  164. package/src/gallery-web/frame/entries/html.ts +6 -0
  165. package/src/gallery-web/frame/entries/markdown.ts +6 -0
  166. package/src/gallery-web/frame/entries/mermaid.ts +6 -0
  167. package/src/gallery-web/frame/entries/svg.ts +6 -0
  168. package/src/gallery-web/frame/entries/tsx.ts +6 -0
  169. package/src/gallery-web/frame/frame-payload.ts +91 -0
  170. package/src/gallery-web/frame/renderer-validation.ts +13 -0
  171. package/src/gallery-web/frame/renderers/chart.ts +182 -0
  172. package/src/gallery-web/frame/renderers/dompurify-shim.ts +112 -0
  173. package/src/gallery-web/frame/renderers/html.ts +48 -0
  174. package/src/gallery-web/frame/renderers/markdown.ts +81 -0
  175. package/src/gallery-web/frame/renderers/mermaid.ts +120 -0
  176. package/src/gallery-web/frame/renderers/registry.ts +260 -0
  177. package/src/gallery-web/frame/renderers/svg.ts +393 -0
  178. package/src/gallery-web/frame/renderers/tsx.ts +61 -0
  179. package/src/gallery-web/frame/runtime.ts +501 -0
  180. package/src/gallery-web/frame/styles/artifact.css +8424 -0
  181. package/src/gallery-web/frame/styles/frame.css +183 -0
  182. package/src/gallery-web/frame/styles/html-source.css +23 -0
  183. package/src/gallery-web/frame/view-box.ts +33 -0
  184. package/src/gallery-web/frame-html.ts +72 -0
  185. package/src/gallery-web/index.html +104 -0
  186. package/src/gallery-web/session.ts +107 -0
  187. package/src/gallery-web/sse-client.ts +212 -0
  188. package/src/gallery-web/styles/app.css +420 -0
  189. package/src/gallery-web/styles/tokens.css +112 -0
  190. package/src/gallery-web/styles/verdict.css +186 -0
  191. package/src/gallery-web/swap.ts +64 -0
  192. package/src/gallery-web/theme.ts +40 -0
  193. package/src/gallery-web/view-state.ts +93 -0
  194. package/src/harness-adapters/claude-code/README.md +10 -0
  195. package/src/harness-adapters/claude-code/facet.sh +4 -0
  196. package/src/harness-adapters/codex/README.md +10 -0
  197. package/src/harness-adapters/codex/facet.sh +4 -0
  198. package/src/harness-adapters/mcp/README.md +13 -0
  199. package/src/harness-adapters/mcp/cli-bridge.ts +216 -0
  200. package/src/harness-adapters/mcp/main.ts +15 -0
  201. package/src/harness-adapters/mcp/server.ts +164 -0
  202. package/src/harness-adapters/mcp/tool-schemas.ts +53 -0
  203. package/src/harness-adapters/opencode/README.md +10 -0
  204. package/src/harness-adapters/opencode/facet.sh +4 -0
  205. package/src/runtime/compiled-entrypoints.ts +69 -0
  206. package/src/service/dispatcher.ts +717 -0
  207. package/src/service/export.ts +218 -0
  208. package/src/service/http-utils.ts +55 -0
  209. package/src/service/lexical/expectations.ts +210 -0
  210. package/src/service/lifecycle/idle-controller.ts +125 -0
  211. package/src/service/lifecycle/orphan-cleanup.ts +128 -0
  212. package/src/service/lifecycle/process-lock.ts +235 -0
  213. package/src/service/main.ts +136 -0
  214. package/src/service/process.ts +240 -0
  215. package/src/service/router-guards.ts +239 -0
  216. package/src/service/router.ts +627 -0
  217. package/src/service/security/auth.ts +168 -0
  218. package/src/service/security/host-origin.ts +138 -0
  219. package/src/service/security/http-guards.ts +13 -0
  220. package/src/service/security/leases.ts +182 -0
  221. package/src/service/security/token-store.ts +138 -0
  222. package/src/service/server.ts +356 -0
  223. package/src/service/store/database.ts +61 -0
  224. package/src/service/store/evidence-retention.ts +234 -0
  225. package/src/service/store/migrations.ts +167 -0
  226. package/src/service/store/repository-lifecycle.ts +121 -0
  227. package/src/service/store/repository.ts +732 -0
  228. package/src/service/store/schema.ts +218 -0
  229. package/src/service/stored-verdict.ts +97 -0
  230. package/src/service/stream.ts +301 -0
  231. package/src/service/verdict-enrichment.ts +45 -0
  232. package/src/shared/build/frame-bundle-plugins.ts +61 -0
  233. package/src/shared/build-mode.ts +28 -0
  234. package/src/shared/config/evidence-read.ts +98 -0
  235. package/src/shared/config/limits.ts +56 -0
  236. package/src/shared/config/paths.ts +96 -0
  237. package/src/shared/contracts/artifact-types.ts +2 -0
  238. package/src/shared/contracts/artifact.ts +98 -0
  239. package/src/shared/contracts/commands/_shared.ts +59 -0
  240. package/src/shared/contracts/commands/guards.ts +86 -0
  241. package/src/shared/contracts/commands/index.ts +204 -0
  242. package/src/shared/contracts/commands/names.ts +35 -0
  243. package/src/shared/contracts/commands/requests.ts +149 -0
  244. package/src/shared/contracts/commands/results.ts +200 -0
  245. package/src/shared/contracts/envelope.ts +140 -0
  246. package/src/shared/contracts/events.ts +41 -0
  247. package/src/shared/contracts/observed-counts.ts +19 -0
  248. package/src/shared/contracts/renderers.ts +6 -0
  249. package/src/shared/contracts/validation.ts +296 -0
  250. package/src/shared/errors/facet-error.ts +127 -0
  251. package/src/shared/errors/store-error.ts +68 -0
  252. package/src/shared/evidence-image.ts +37 -0
  253. package/src/shared/export.ts +31 -0
  254. package/src/shared/html/policy.ts +108 -0
  255. package/src/shared/html/style-vocabulary.ts +352 -0
  256. package/src/shared/logging/logger.ts +108 -0
  257. package/src/shared/logging/redact.ts +49 -0
  258. package/src/shared/security/frozen-csp.ts +13 -0
  259. package/src/shared/storage-version.ts +2 -0
  260. package/src/shared/tsx/execution.ts +29 -0
  261. package/src/shared/tsx/import-policy.ts +94 -0
  262. package/src/shared/util/dir-permissions.ts +94 -0
  263. package/src/shared/util/mermaid-nodes.ts +230 -0
  264. package/src/shared/util/process.ts +67 -0
  265. package/src/shared/util/time.ts +21 -0
  266. package/src/shared/version.ts +3 -0
  267. package/src/validation/sandbox/limits.ts +41 -0
  268. package/src/validation/sandbox/netns.ts +177 -0
  269. package/src/validation/tier0/chart.ts +177 -0
  270. package/src/validation/tier0/dom-shim.ts +54 -0
  271. package/src/validation/tier0/html.ts +408 -0
  272. package/src/validation/tier0/markdown.ts +285 -0
  273. package/src/validation/tier0/mermaid.ts +98 -0
  274. package/src/validation/tier0/runner.ts +504 -0
  275. package/src/validation/tier0/svg.ts +321 -0
  276. package/src/validation/tier0/tsx/allowlist-resolver.ts +77 -0
  277. package/src/validation/tier0/tsx/ast-policy-capabilities.ts +238 -0
  278. package/src/validation/tier0/tsx/ast-policy-shared.ts +71 -0
  279. package/src/validation/tier0/tsx/ast-policy.ts +181 -0
  280. package/src/validation/tier0/tsx/compiler.ts +156 -0
  281. package/src/validation/tier0/worker-dispatch.ts +211 -0
  282. package/src/validation/tier0/worker-entry.ts +96 -0
  283. package/src/validation/tier0/worker-input.ts +75 -0
  284. package/src/validation/tier1/browser-process.ts +147 -0
  285. package/src/validation/tier1/cdp-pipe.ts +281 -0
  286. package/src/validation/tier1/entries/chart.ts +5 -0
  287. package/src/validation/tier1/entries/html.ts +6 -0
  288. package/src/validation/tier1/entries/markdown.ts +5 -0
  289. package/src/validation/tier1/entries/mermaid.ts +5 -0
  290. package/src/validation/tier1/entries/svg.ts +5 -0
  291. package/src/validation/tier1/entries/tsx.ts +5 -0
  292. package/src/validation/tier1/frame-target.ts +162 -0
  293. package/src/validation/tier1/harness-entry.ts +261 -0
  294. package/src/validation/tier1/harness.ts +215 -0
  295. package/src/validation/tier1/isolated-probe.ts +94 -0
  296. package/src/validation/tier1/launcher.ts +177 -0
  297. package/src/validation/tier1/limits.ts +80 -0
  298. package/src/validation/tier1/nonce.ts +17 -0
  299. package/src/validation/tier1/protocol-probe.ts +519 -0
  300. package/src/validation/tier1/runner.ts +1122 -0
  301. package/src/validation/tier1/verdict.ts +246 -0
  302. package/src/validation/tier1/webp.ts +69 -0
  303. package/templates/README.md +88 -0
  304. package/templates/bar-compare.vl.json +63 -0
  305. package/templates/capacity-report.tsx +102 -0
  306. package/templates/decision-record.md +24 -0
  307. package/templates/deployment-state.mmd +40 -0
  308. package/templates/exemplar.md +91 -0
  309. package/templates/facet-story.tsx +799 -0
  310. package/templates/fleet-dashboard.html +141 -0
  311. package/templates/html-release-ledger.html +91 -0
  312. package/templates/html-status-report.html +114 -0
  313. package/templates/incident-console.tsx +147 -0
  314. package/templates/legion-boundaries.mmd +23 -0
  315. package/templates/legion-flow.mmd +15 -0
  316. package/templates/legion-sequence.mmd +14 -0
  317. package/templates/legion-state.mmd +7 -0
  318. package/templates/metric-card.svg +29 -0
  319. package/templates/observability-map.svg +101 -0
  320. package/templates/pipeline-audit.md +108 -0
  321. package/templates/release-metrics.vl.json +98 -0
  322. package/templates/service-topology.mmd +47 -0
  323. package/templates/status-report.md +32 -0
  324. package/templates/system-map.svg +53 -0
  325. package/templates/timeseries.vl.json +71 -0
  326. package/templates/tsx-interactive-counter.tsx +19 -0
  327. package/templates/tsx-status-report.tsx +31 -0
@@ -0,0 +1,1581 @@
1
+ /**
2
+ * Gallery shell controller — DOM-touching surface.
3
+ *
4
+ * The shell is the loopback user's browser surface — title + revision
5
+ * + status line, zoom/reset/fullscreen controls, the iframe canvas,
6
+ * a terse error badge. ONE active-artifact view (no sidebar, no list).
7
+ * The shell transforms the iframe ELEMENT for zoom/pan; fine pan/zoom
8
+ * lands on the frame's render-result view-state handle (same-origin call).
9
+ *
10
+ * Pure helpers live in sibling modules (`frame-html.ts`, `swap.ts`,
11
+ * `frame/bootstrap.ts`) so the security invariants are testable
12
+ * without a browser harness. The DOM-mutating surface here is small
13
+ * and thin over those helpers.
14
+ *
15
+ * The shell rejects a non-loopback hostname BEFORE any capability-bearing
16
+ * code runs — DNS-rebinding defense at the trust boundary, not after.
17
+ *
18
+ * Swap model (double-buffered HMR): the new frame is built OFF-SCREEN,
19
+ * the shell awaits the iframe `load` event, calls the frame's direct
20
+ * `__facetFrame.render(payload)` promise, and only then swaps
21
+ * visibility, transfers the preserved view state, and removes the old
22
+ * frame last. A failed new frame keeps the last-good frame visible and
23
+ * surfaces an error badge.
24
+ */
25
+
26
+ import { assertLoopbackHostname, buildFrameAttributes, type FrameAttributes } from "./frame-html";
27
+ import { planSwap, type SwapPlanStep } from "./swap";
28
+ import { connectRevisionStream } from "./sse-client";
29
+ import type { VerdictObserved } from "../shared/contracts/validation";
30
+ import type { ObservedCountKey } from "../shared/contracts/observed-counts";
31
+ import {
32
+ evidenceImageFormatForMediaType,
33
+ type EvidenceImageFormat,
34
+ } from "../shared/evidence-image";
35
+ import {
36
+ clampZoom,
37
+ EMPTY_VIEW_STATE,
38
+ MAX_ZOOM,
39
+ MIN_ZOOM,
40
+ nextViewStateForKey,
41
+ normalizeViewState,
42
+ resetViewState,
43
+ zoomAtPoint,
44
+ type ViewState,
45
+ } from "./view-state";
46
+ import type { FrameRenderPayload, GestureMode } from "./frame/frame-payload";
47
+ import {
48
+ resolveGalleryTheme,
49
+ type GalleryMatchMedia,
50
+ type GalleryThemeMode,
51
+ type ResolvedGalleryTheme,
52
+ } from "./theme";
53
+ import type { TsxExecutionMode, Verdict } from "../shared/contracts/validation";
54
+ import { faviconTint, renderFavicon } from "./favicon";
55
+ import { exportMenuStateFrom, type GalleryExportState } from "./export";
56
+ import {
57
+ disableGalleryExportMenu,
58
+ installGalleryExportMenu,
59
+ type GalleryExportMenuController,
60
+ } from "./export-menu";
61
+
62
+ // Re-exports — the gate test + sibling modules import these from `app`
63
+ // for the v0.1 public surface.
64
+ export {
65
+ assertLoopbackHostname,
66
+ buildFrameAttributes,
67
+ buildFrameDocument,
68
+ isLoopbackHostname,
69
+ type FrameAttributes,
70
+ } from "./frame-html";
71
+ export { planSwap, type SwapPlanStep } from "./swap";
72
+ export { connectRevisionStream } from "./sse-client";
73
+
74
+ import {
75
+ clearSession,
76
+ persistSession,
77
+ readPersistedSession,
78
+ validatePersistedSession,
79
+ type GallerySession,
80
+ type SessionStorageLike,
81
+ } from "./session";
82
+
83
+ export interface BootstrapHandoff {
84
+ readonly authorization: string;
85
+ readonly artifactId: string;
86
+ readonly revisionSha: string;
87
+ readonly lease: { readonly leaseId: string; readonly expiresAt: number };
88
+ readonly theme: GalleryThemeMode;
89
+ readonly headers: Headers;
90
+ }
91
+
92
+ class GallerySessionExpiredError extends Error {}
93
+
94
+ const noOp = (): void => undefined;
95
+
96
+ function nextGalleryThemeMode(mode: GalleryThemeMode): GalleryThemeMode {
97
+ return mode === "system" ? "dark" : mode === "dark" ? "light" : "system";
98
+ }
99
+
100
+ function isLeaseUnauthorized(response: Response): boolean {
101
+ return response.status === 401;
102
+ }
103
+
104
+ export type GalleryBootstrap =
105
+ | {
106
+ readonly outcome: "bootstrapped";
107
+ readonly session: BootstrapHandoff;
108
+ readonly storage: SessionStorageLike;
109
+ }
110
+ | {
111
+ readonly outcome: "reused";
112
+ readonly session: BootstrapHandoff;
113
+ readonly storage: SessionStorageLike;
114
+ }
115
+ | { readonly outcome: "expired"; readonly reason: "missing" | "expired" | "invalid" };
116
+
117
+ export interface ResolveGalleryBootstrapOptions {
118
+ readonly location: string;
119
+ readonly storage: SessionStorageLike;
120
+ readonly fetchImpl?: typeof fetch;
121
+ readonly clearFragment?: () => void;
122
+ /** Cheap authed probe — true if the live lease still services the artifact. */
123
+ readonly validateLease: (session: GallerySession) => Promise<boolean> | boolean;
124
+ }
125
+
126
+ /**
127
+ * Resolve the shell's bootstrap state on load.
128
+ *
129
+ * 1. Live `bootstrap=` token in the URL fragment → exchange it for a
130
+ * lease and persist the granted session to `sessionStorage`. The
131
+ * consumed token is stripped from the URL.
132
+ * 2. No token, but a persisted session is still in `sessionStorage` →
133
+ * reuse the stored lease. The caller validates the lease via a
134
+ * authed round-trip; the URL is left untouched.
135
+ * 3. No token and no persisted session, or the stored lease is
136
+ * expired/invalid → exhaustive fail states surface the typed
137
+ * "session expired — run `facet open` again" message instead of
138
+ * throwing on the missing-token path.
139
+ *
140
+ * The shell treats a service restart as an expired lease: the lease
141
+ * manager is in-memory, so every outstanding lease evaporates the
142
+ * moment the service comes back. The honest route is the expired
143
+ * state, not a longer-lived credential.
144
+ */
145
+ export async function resolveGalleryBootstrap(
146
+ options: ResolveGalleryBootstrapOptions,
147
+ ): Promise<GalleryBootstrap> {
148
+ const url = new URL(options.location);
149
+ assertLoopbackHostname(url.hostname);
150
+ const fragment = new URLSearchParams(url.hash.replace(/^#/, ""));
151
+ const liveToken = fragment.get("bootstrap");
152
+
153
+ if (liveToken !== null && liveToken.length > 0) {
154
+ const replacedSession = readPersistedSession(options.storage);
155
+ options.clearFragment?.();
156
+ const fetcher = options.fetchImpl ?? fetch;
157
+ const response = await fetcher(`${url.origin}/api/v1/gallery/bootstrap`, {
158
+ method: "POST",
159
+ headers: { "content-type": "application/json" },
160
+ body: JSON.stringify({ token: liveToken }),
161
+ });
162
+ if (isLeaseUnauthorized(response)) return { outcome: "expired", reason: "invalid" };
163
+ if (!response.ok) throw new Error(`Gallery bootstrap failed (${response.status})`);
164
+ const payload = (await response.json()) as Omit<BootstrapHandoff, "headers" | "theme">;
165
+ const headers = new Headers({
166
+ authorization: payload.authorization,
167
+ "x-gallery-lease": payload.lease.leaseId,
168
+ "x-gallery-artifact": payload.artifactId,
169
+ });
170
+ const session: BootstrapHandoff = { ...payload, theme: "system", headers };
171
+ const persisted: GallerySession = {
172
+ authorization: payload.authorization,
173
+ artifactId: payload.artifactId,
174
+ revisionSha: payload.revisionSha,
175
+ lease: payload.lease,
176
+ theme: "system",
177
+ };
178
+ persistSession(options.storage, persisted);
179
+ if (replacedSession !== null && replacedSession.lease.leaseId !== payload.lease.leaseId) {
180
+ void releaseDisplayLease({
181
+ baseUrl: url.origin,
182
+ authorization: replacedSession.authorization,
183
+ artifactId: replacedSession.artifactId,
184
+ leaseId: replacedSession.lease.leaseId,
185
+ fetchImpl: fetcher,
186
+ }).catch(noOp);
187
+ }
188
+ return { outcome: "bootstrapped", session, storage: options.storage };
189
+ }
190
+
191
+ const persisted = readPersistedSession(options.storage);
192
+ if (persisted === null) return { outcome: "expired", reason: "missing" };
193
+ const validity = validatePersistedSession(persisted);
194
+ if (!validity.valid) {
195
+ // Stored lease is past its nominal expiry. Even if the service
196
+ // restarted inside the same window, the persisted session is no
197
+ // longer the source of truth — wipe it so the next refresh starts
198
+ // from a clean state.
199
+ clearSession(options.storage);
200
+ return { outcome: "expired", reason: "expired" };
201
+ }
202
+ const leaseStillValid = await options.validateLease(persisted);
203
+ if (!leaseStillValid) {
204
+ clearSession(options.storage);
205
+ return { outcome: "expired", reason: "invalid" };
206
+ }
207
+ const headers = new Headers({
208
+ authorization: persisted.authorization,
209
+ "x-gallery-lease": persisted.lease.leaseId,
210
+ "x-gallery-artifact": persisted.artifactId,
211
+ });
212
+ const session: BootstrapHandoff = {
213
+ authorization: persisted.authorization,
214
+ artifactId: persisted.artifactId,
215
+ revisionSha: persisted.revisionSha,
216
+ lease: persisted.lease,
217
+ theme: persisted.theme,
218
+ headers,
219
+ };
220
+ return { outcome: "reused", session, storage: options.storage };
221
+ }
222
+
223
+ export function buildGalleryUrl(baseUrl: string, bootstrapToken: string): string {
224
+ const url = new URL(`${baseUrl.replace(/\/$/, "")}/gallery`);
225
+ if (url.hostname !== "127.0.0.1") throw new Error("Gallery URL must be loopback-only");
226
+ url.hash = `bootstrap=${encodeURIComponent(bootstrapToken)}`;
227
+ return url.toString();
228
+ }
229
+
230
+ export async function consumeBootstrapHandoff(options: {
231
+ readonly location: string;
232
+ readonly fetchImpl?: typeof fetch;
233
+ readonly clearFragment?: () => void;
234
+ }): Promise<BootstrapHandoff> {
235
+ const location = new URL(options.location);
236
+ assertLoopbackHostname(location.hostname);
237
+ const params = new URLSearchParams(location.hash.replace(/^#/, ""));
238
+ const token = params.get("bootstrap");
239
+ if (token === null || token.length === 0)
240
+ throw new Error("Gallery bootstrap capability is missing");
241
+ options.clearFragment?.();
242
+ const fetcher = options.fetchImpl ?? fetch;
243
+ const response = await fetcher(`${location.origin}/api/v1/gallery/bootstrap`, {
244
+ method: "POST",
245
+ headers: { "content-type": "application/json" },
246
+ body: JSON.stringify({ token }),
247
+ });
248
+ if (!response.ok) throw new Error(`Gallery bootstrap failed (${response.status})`);
249
+ const payload = (await response.json()) as Omit<BootstrapHandoff, "headers" | "theme">;
250
+ const headers = new Headers({
251
+ authorization: payload.authorization,
252
+ "x-gallery-lease": payload.lease.leaseId,
253
+ "x-gallery-artifact": payload.artifactId,
254
+ });
255
+ return { ...payload, theme: "system", headers };
256
+ }
257
+
258
+ export async function releaseDisplayLease(options: {
259
+ readonly baseUrl: string;
260
+ readonly authorization: string;
261
+ readonly artifactId: string;
262
+ readonly leaseId: string;
263
+ readonly fetchImpl?: typeof fetch;
264
+ }): Promise<void> {
265
+ const url = new URL(options.baseUrl);
266
+ assertLoopbackHostname(url.hostname);
267
+ const fetcher = options.fetchImpl ?? fetch;
268
+ await fetcher(`${url.origin}/api/v1/gallery/release`, {
269
+ method: "POST",
270
+ headers: {
271
+ authorization: options.authorization,
272
+ "x-gallery-lease": options.leaseId,
273
+ "x-gallery-artifact": options.artifactId,
274
+ },
275
+ });
276
+ }
277
+
278
+ /**
279
+ * Public surface of the shell — names only. Used by the gate test to
280
+ * assert no list/sidebar/multi-frame APIs exist (single-artifact view).
281
+ */
282
+ export const SHELL_EXPORTS = [
283
+ "createArtifactFrame",
284
+ "replaceArtifactFrame",
285
+ "connectRevisionStream",
286
+ ] as const;
287
+
288
+ /**
289
+ * Minimal DOM interface the shell needs. Injected so the DOM-touching
290
+ * code is testable without a real document; in production the shell
291
+ * passes `document` from the browser.
292
+ */
293
+ export interface ShellDom {
294
+ readonly document: Document;
295
+ readonly hostname: string;
296
+ }
297
+
298
+ /** View state the shell preserves across a swap. */
299
+ export type { ViewState } from "./view-state";
300
+
301
+ /**
302
+ * Page-shim counts the frame reports after a render settles. The
303
+ * direct render promise resolves with these; the shell treats any
304
+ * non-zero error count as a failed render.
305
+ */
306
+ type FrameObserved = Pick<VerdictObserved, ObservedCountKey | "html" | "errorCount">;
307
+
308
+ export interface CreateArtifactFrameOptions {
309
+ readonly artifactType: string;
310
+ readonly theme: ResolvedGalleryTheme;
311
+ /** Legacy direct callers omit this; revision source fetches always make it explicit. */
312
+ readonly renderer?: string;
313
+ readonly frameUrl?: string;
314
+ readonly dom: ShellDom;
315
+ }
316
+
317
+ /**
318
+ * Host-side element handle. `raw` is the real HTMLIFrameElement in
319
+ * production; the FrameHost adapter is the only surface that touches
320
+ * it, so test hosts can substitute a recording stub.
321
+ */
322
+ export interface FrameElementHandle {
323
+ setAttribute(name: string, value: string): void;
324
+ readonly raw: unknown;
325
+ }
326
+
327
+ export type { FrameRenderPayload };
328
+
329
+ /**
330
+ * Shell-side handle over the frame's direct `RenderResult`. The frame
331
+ * promise resolves with these after the render settles; the handle
332
+ * keeps the view-state surface callable for the frame's lifetime.
333
+ */
334
+ export interface FrameRenderResultHandle {
335
+ /** Post-settlement page-shim counts reported by the frame's render. */
336
+ readonly observed: FrameObserved;
337
+ /** The view mode the render selected (native SVG vs shell CSS fallback). */
338
+ readonly viewMode: "native" | "css";
339
+ /** The frame's last-applied view state (its own zoom/pan). */
340
+ readonly readViewState: () => ViewState;
341
+ /** Apply the shell's preserved view state inside the frame (same-origin). */
342
+ readonly applyViewState: (state: ViewState) => void;
343
+ /** Gesture mode this render started in — standalone diagrams default to `panzoom`, documents to `native`. */
344
+ readonly defaultGestureMode: GestureMode;
345
+ /** The frame's current wheel/drag gesture mode. */
346
+ readonly gestureMode: () => GestureMode;
347
+ /** Switch the frame between native scroll/select and wheel-zoom/drag-pan. */
348
+ readonly setGestureMode: (mode: GestureMode) => void;
349
+ /** Restore every embedded diagram region to its natural size and idle state. */
350
+ readonly resetDiagramRegions: () => void;
351
+ }
352
+
353
+ export interface CreatedArtifactFrame {
354
+ readonly frameId: string;
355
+ readonly attrs: FrameAttributes;
356
+ /**
357
+ * Element wired with the attributes above. The caller mounts it via
358
+ * a FrameHost. Kept abstract so the test gate can stub.
359
+ */
360
+ readonly element: FrameElementHandle;
361
+ /**
362
+ * Resolve true once the iframe `load` event has fired (the runtime
363
+ * module executed and installed `__facetFrame`), or false on
364
+ * timeout. The listener is armed at creation, BEFORE any mount — a
365
+ * detached iframe never loads, so arming inside a mount callback
366
+ * would deadlock on an event that can only fire after mount.
367
+ */
368
+ readonly awaitLoad: (timeoutMs: number) => Promise<boolean>;
369
+ /**
370
+ * Invoke the frame's direct render API with the exact revision
371
+ * payload. Resolves with a handle over the frame-side RenderResult,
372
+ * or rejects on timeout / missing API / frame-side render failure.
373
+ * Only meaningful after `awaitLoad` resolved.
374
+ */
375
+ readonly render: (
376
+ payload: FrameRenderPayload,
377
+ timeoutMs: number,
378
+ ) => Promise<FrameRenderResultHandle>;
379
+ /** Handle from the last successful render (null before the first). */
380
+ readonly renderResult: FrameRenderResultHandle | null;
381
+ }
382
+
383
+ /**
384
+ * DOM adapter the swap executes against. Production binds it to the
385
+ * real document; tests bind a recording stub. The shell never touches
386
+ * the iframe element outside this surface.
387
+ */
388
+ export interface FrameHost {
389
+ /** Mount a frame element off-screen (loaded, but not visible). */
390
+ mountOffScreen(frameId: string, element: unknown): void;
391
+ /** Swap visibility. */
392
+ setVisibility(frameId: string, visible: boolean): void;
393
+ /** Remove the frame element from the document. */
394
+ unmount(frameId: string): void;
395
+ /** Terse error badge — failed swaps keep the last-good frame. */
396
+ showErrorBadge(message: string): void;
397
+ }
398
+
399
+ /**
400
+ * Create a fresh artifact frame: build the loopback document URL and
401
+ * an ordinary same-origin iframe element. The load listener is armed
402
+ * HERE (before any mount); the frame's direct render API is invoked
403
+ * through the returned `render` handle once `awaitLoad` resolves.
404
+ */
405
+ export function createArtifactFrame(options: CreateArtifactFrameOptions): CreatedArtifactFrame {
406
+ const dom = options.dom;
407
+ // DNS-rebinding guard — fires BEFORE the iframe is appended or any
408
+ // capability code runs.
409
+ assertLoopbackHostname(dom.hostname);
410
+ const frameUrl = new URL(options.frameUrl ?? "/gallery/frame", `http://${dom.hostname}`);
411
+ frameUrl.searchParams.set("type", options.artifactType);
412
+ frameUrl.searchParams.set("renderer", options.renderer ?? "svg");
413
+ frameUrl.searchParams.set("theme", options.theme);
414
+ const attrs = buildFrameAttributes(frameUrl.toString());
415
+ const element = dom.document.createElement("iframe");
416
+ element.setAttribute("referrerpolicy", attrs.referrerpolicy);
417
+ element.setAttribute("allow", attrs.allow);
418
+ element.setAttribute("title", attrs.title);
419
+ element.setAttribute("src", attrs.src);
420
+ const frameId = `frame-${crypto.randomUUID()}`;
421
+ const raw = element as HTMLIFrameElement;
422
+
423
+ let loaded = false;
424
+ const loadWaiters: ((isLoaded: boolean) => void)[] = [];
425
+ // Armed BEFORE any mount: the load event can only fire once the
426
+ // element is in the document, and rendering before the runtime
427
+ // bundle executes would race the __facetFrame install (the old
428
+ // boot flake reborn).
429
+ raw.addEventListener(
430
+ "load",
431
+ () => {
432
+ loaded = true;
433
+ for (const waiter of loadWaiters) waiter(true);
434
+ loadWaiters.length = 0;
435
+ },
436
+ { once: true },
437
+ );
438
+
439
+ let renderResult: FrameRenderResultHandle | null = null;
440
+
441
+ return {
442
+ frameId,
443
+ attrs,
444
+ element: {
445
+ setAttribute(name: string, value: string): void {
446
+ element.setAttribute(name, value);
447
+ },
448
+ raw: element,
449
+ },
450
+ awaitLoad(timeoutMs) {
451
+ if (loaded) return Promise.resolve(true);
452
+ return new Promise<boolean>((resolve) => {
453
+ const waiter = (isLoaded: boolean): void => {
454
+ clearTimeout(timer);
455
+ resolve(isLoaded);
456
+ };
457
+ const timer = setTimeout(() => {
458
+ const index = loadWaiters.indexOf(waiter);
459
+ if (index >= 0) loadWaiters.splice(index, 1);
460
+ resolve(false);
461
+ }, timeoutMs);
462
+ loadWaiters.push(waiter);
463
+ });
464
+ },
465
+ async render(payload, timeoutMs) {
466
+ // oxlint-disable-next-line no-underscore-dangle
467
+ const api = raw.contentWindow?.__facetFrame;
468
+ if (api === null || api === undefined || typeof api.render !== "function") {
469
+ throw new Error("frame render API unavailable");
470
+ }
471
+ // The frame-side promise is the render authority; the timer only
472
+ // bounds a never-settling render (a dead runtime bundle).
473
+ const frameResult = await withTimeout(
474
+ Promise.resolve(api.render(payload)),
475
+ timeoutMs,
476
+ "frame render timed out",
477
+ );
478
+ const handle: FrameRenderResultHandle = {
479
+ observed: frameResult.observed,
480
+ viewMode: frameResult.viewMode,
481
+ readViewState: () => ({ ...frameResult.readViewState() }),
482
+ applyViewState: (state) => frameResult.applyViewState(normalizeViewState(state)),
483
+ defaultGestureMode: frameResult.defaultGestureMode,
484
+ gestureMode: () => frameResult.gestureMode(),
485
+ setGestureMode: (mode) => frameResult.setGestureMode(mode),
486
+ resetDiagramRegions: () => frameResult.resetDiagramRegions(),
487
+ };
488
+ renderResult = handle;
489
+ return handle;
490
+ },
491
+ get renderResult(): FrameRenderResultHandle | null {
492
+ return renderResult;
493
+ },
494
+ };
495
+ }
496
+
497
+ export interface ReplaceArtifactFrameOptions {
498
+ readonly current: CreatedArtifactFrame;
499
+ readonly next: CreatedArtifactFrame;
500
+ readonly dom: ShellDom;
501
+ readonly host: FrameHost;
502
+ readonly viewState: ViewState;
503
+ /** Artifact payload rendered by the new frame exactly once. */
504
+ readonly source?: FrameRenderPayload;
505
+ /** Per-barrier wait window (load, then render). */
506
+ readonly readyTimeoutMs?: number;
507
+ readonly onProgress?: (state: "ready" | "complete") => void;
508
+ /** Reject a completed replacement before it mutates the visible frame. */
509
+ readonly canCommit?: () => boolean;
510
+ }
511
+
512
+ export interface ReplaceArtifactFrameResult {
513
+ readonly executedSteps: readonly SwapPlanStep["name"][];
514
+ readonly failedNewFrameReady: boolean;
515
+ readonly cancelled?: boolean;
516
+ }
517
+
518
+ const DEFAULT_READY_TIMEOUT_MS = 10_000;
519
+
520
+ /** Bound an already-started promise with a wall-clock timeout. */
521
+ function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
522
+ return new Promise<T>((resolve, reject) => {
523
+ const timer = setTimeout(() => reject(new Error(message)), timeoutMs);
524
+ promise.then(
525
+ (value) => {
526
+ clearTimeout(timer);
527
+ resolve(value);
528
+ },
529
+ (error: unknown) => {
530
+ clearTimeout(timer);
531
+ reject(error);
532
+ },
533
+ );
534
+ });
535
+ }
536
+
537
+ /**
538
+ * Execute a double-buffered HMR swap against the real DOM. Ordering
539
+ * invariant: the new frame is built off-screen, loads, renders through
540
+ * the direct frame API, and only THEN swaps in — the old frame is
541
+ * removed last. View state is preserved across the swap. If the new
542
+ * frame fails to load or render within the wait window, the old frame
543
+ * stays visible with an error badge and the failed new frame's element
544
+ * is torn down.
545
+ */
546
+ export async function replaceArtifactFrame(
547
+ options: ReplaceArtifactFrameOptions,
548
+ ): Promise<ReplaceArtifactFrameResult> {
549
+ const { current, next, dom, host, viewState } = options;
550
+ const readyTimeoutMs = options.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS;
551
+ assertLoopbackHostname(dom.hostname);
552
+ const plan = planSwap({
553
+ currentFrameId: current.frameId,
554
+ nextFrameId: next.frameId,
555
+ viewState,
556
+ });
557
+ const executed: SwapPlanStep["name"][] = [];
558
+ let renderResult: FrameRenderResultHandle | null = null;
559
+ const runners: Record<SwapPlanStep["name"], () => void> = {
560
+ "build-new": () => host.mountOffScreen(next.frameId, next.element.raw),
561
+ "load-new": () => {
562
+ // Awaited below; this step records the barrier passed.
563
+ },
564
+ "render-new": () => {
565
+ // Awaited below; this step records the barrier passed.
566
+ },
567
+ swap: () => {
568
+ host.setVisibility(next.frameId, true);
569
+ host.setVisibility(current.frameId, false);
570
+ },
571
+ "apply-view-state": () => {
572
+ renderResult!.applyViewState(viewState);
573
+ },
574
+ "remove-old": () => {
575
+ host.unmount(current.frameId);
576
+ },
577
+ };
578
+ const runStep = (step: SwapPlanStep): void => {
579
+ runners[step.name]();
580
+ executed.push(step.name);
581
+ };
582
+
583
+ // build-new + load-new run before the render barrier: the iframe
584
+ // must be mounted off-screen to load. The load listener was armed at
585
+ // frame creation, so the detached-iframe deadlock cannot occur.
586
+ runStep(plan[0]!);
587
+ runStep(plan[1]!);
588
+ const loaded = await next.awaitLoad(readyTimeoutMs);
589
+
590
+ runStep(plan[2]!);
591
+ if (loaded) {
592
+ try {
593
+ if (options.source !== undefined) {
594
+ const result = await next.render(options.source, readyTimeoutMs);
595
+ if (result.observed.errorCount === 0) renderResult = result;
596
+ }
597
+ } catch {
598
+ renderResult = null;
599
+ }
600
+ }
601
+
602
+ if (renderResult === null) {
603
+ // Failed new frame: keep the last-good frame visible, surface the
604
+ // badge, and tear the failed frame's element down.
605
+ host.showErrorBadge("new revision failed to render; keeping last good revision");
606
+ host.unmount(next.frameId);
607
+ return { executedSteps: executed, failedNewFrameReady: true };
608
+ }
609
+
610
+ if (options.canCommit?.() === false) {
611
+ host.unmount(next.frameId);
612
+ return { executedSteps: executed, failedNewFrameReady: false, cancelled: true };
613
+ }
614
+
615
+ // Render succeeded: swap → apply-view-state → remove-old, in order.
616
+ // The viewState passed in options is the caller's preserved state
617
+ // (read from the current frame before the swap started).
618
+ options.onProgress?.("ready");
619
+ // Yield one microtask so the 80% progress state flushes before the
620
+ // swap applies — the swap steps below are synchronous, and a caller
621
+ // (or the perf instrumentation) that keys on the bar width must be
622
+ // able to observe the barrier passing.
623
+ await Promise.resolve();
624
+ if (options.canCommit?.() === false) {
625
+ host.unmount(next.frameId);
626
+ return { executedSteps: executed, failedNewFrameReady: false, cancelled: true };
627
+ }
628
+ for (const step of plan.slice(3)) runStep(step);
629
+ options.onProgress?.("complete");
630
+ return { executedSteps: executed, failedNewFrameReady: false };
631
+ }
632
+
633
+ export interface RevisionFetchResult {
634
+ readonly artifactId: string;
635
+ readonly revisionSha: string;
636
+ readonly slug: string;
637
+ readonly title: string;
638
+ readonly artifactType: string;
639
+ readonly renderer: string;
640
+ /** Bytes displayed by the frame; TSX may use compiled render bytes. */
641
+ readonly bytes: Uint8Array;
642
+ /** Published source bytes retained for source export. */
643
+ readonly sourceBytes: Uint8Array;
644
+ readonly execution?: TsxExecutionMode;
645
+ readonly verdict?: Verdict | null;
646
+ }
647
+
648
+ interface GalleryEvidenceResponse {
649
+ readonly bytes: Uint8Array | null;
650
+ readonly renderFormat: EvidenceImageFormat | null;
651
+ }
652
+
653
+ export interface SwapToRevisionDeps {
654
+ readonly dom: ShellDom;
655
+ readonly host: FrameHost;
656
+ readonly theme: ResolvedGalleryTheme;
657
+ readonly frameUrl?: string;
658
+ /** Fetch the exact revision bytes the SSE event named. */
659
+ readonly fetchRevision: (artifactId: string, revisionSha: string) => Promise<RevisionFetchResult>;
660
+ readonly readyTimeoutMs?: number;
661
+ readonly onProgress?: (state: "ready" | "complete") => void;
662
+ }
663
+
664
+ export interface RevisionEvent {
665
+ readonly artifactId: string;
666
+ readonly revisionSha: string;
667
+ }
668
+
669
+ type GallerySwapEvent = RevisionEvent & {
670
+ readonly kind: "revision";
671
+ };
672
+
673
+ /**
674
+ * publish→visible, one revision at a time: fetch the exact revision
675
+ * the committed event named, build a fresh frame for it, and
676
+ * run the double-buffered swap. The returned frame becomes `current`
677
+ * for the next revision. Bytes enter the frame exactly once through
678
+ * the direct render call; every revision gets its own document — no
679
+ * artifact-JS carryover between revisions.
680
+ */
681
+ export async function swapToRevision(
682
+ deps: SwapToRevisionDeps,
683
+ current: CreatedArtifactFrame,
684
+ event: RevisionEvent,
685
+ viewState: ViewState,
686
+ ): Promise<{
687
+ readonly frame: CreatedArtifactFrame;
688
+ readonly result: ReplaceArtifactFrameResult;
689
+ readonly revision: RevisionFetchResult;
690
+ }> {
691
+ const revision = await deps.fetchRevision(event.artifactId, event.revisionSha);
692
+ const next = createArtifactFrame({
693
+ artifactType: revision.artifactType,
694
+ renderer: revision.renderer,
695
+ theme: deps.theme,
696
+ dom: deps.dom,
697
+ ...(deps.frameUrl === undefined ? {} : { frameUrl: deps.frameUrl }),
698
+ });
699
+ const result = await replaceArtifactFrame({
700
+ current,
701
+ next,
702
+ dom: deps.dom,
703
+ host: deps.host,
704
+ viewState,
705
+ source: {
706
+ artifactType: revision.artifactType,
707
+ renderer: revision.renderer,
708
+ bytes: revision.bytes,
709
+ theme: deps.theme,
710
+ ...(revision.execution === undefined ? {} : { execution: revision.execution }),
711
+ },
712
+ ...(deps.onProgress === undefined ? {} : { onProgress: deps.onProgress }),
713
+ ...(deps.readyTimeoutMs !== undefined ? { readyTimeoutMs: deps.readyTimeoutMs } : {}),
714
+ });
715
+ return {
716
+ frame: result.failedNewFrameReady ? current : next,
717
+ result,
718
+ revision,
719
+ };
720
+ }
721
+
722
+ interface GallerySourceResponse {
723
+ readonly artifactId: string;
724
+ readonly revisionSha: string;
725
+ readonly slug: string;
726
+ readonly title: string;
727
+ readonly artifactType: string;
728
+ readonly renderer?: string;
729
+ readonly source: string;
730
+ readonly renderBytesBase64?: string;
731
+ readonly execution?: TsxExecutionMode;
732
+ readonly verdict: Verdict | null;
733
+ }
734
+
735
+ export async function fetchGallerySource(
736
+ baseUrl: string,
737
+ handoff: BootstrapHandoff,
738
+ revisionSha: string,
739
+ fetchImpl: typeof fetch = fetch,
740
+ ): Promise<RevisionFetchResult> {
741
+ const url = new URL(`${baseUrl.replace(/\/$/, "")}/api/v1/gallery/source`);
742
+ assertLoopbackHostname(url.hostname);
743
+ url.searchParams.set("revisionSha", revisionSha);
744
+ const response = await fetchImpl(url, {
745
+ headers: {
746
+ authorization: handoff.authorization,
747
+ "x-gallery-lease": handoff.lease.leaseId,
748
+ "x-gallery-artifact": handoff.artifactId,
749
+ },
750
+ });
751
+ if (isLeaseUnauthorized(response)) throw new GallerySessionExpiredError("Gallery lease expired");
752
+ if (!response.ok) throw new Error(`Gallery source fetch failed (${response.status})`);
753
+ const payload = (await response.json()) as GallerySourceResponse;
754
+ const sourceBytes = new TextEncoder().encode(payload.source);
755
+ return {
756
+ artifactId: payload.artifactId,
757
+ revisionSha: payload.revisionSha,
758
+ slug: payload.slug,
759
+ title: payload.title,
760
+ artifactType: payload.artifactType,
761
+ renderer: payload.renderer ?? "svg",
762
+ bytes:
763
+ payload.renderBytesBase64 === undefined
764
+ ? sourceBytes
765
+ : Uint8Array.from(atob(payload.renderBytesBase64), (char) => char.charCodeAt(0)),
766
+ sourceBytes,
767
+ ...(payload.execution === undefined ? {} : { execution: payload.execution }),
768
+ verdict: payload.verdict ?? null,
769
+ };
770
+ }
771
+
772
+ export async function fetchGalleryEvidence(
773
+ baseUrl: string,
774
+ handoff: BootstrapHandoff,
775
+ revisionSha: string,
776
+ fetchImpl: typeof fetch = fetch,
777
+ ): Promise<GalleryEvidenceResponse> {
778
+ const url = new URL(`${baseUrl.replace(/\/$/, "")}/api/v1/gallery/evidence`);
779
+ assertLoopbackHostname(url.hostname);
780
+ url.searchParams.set("revisionSha", revisionSha);
781
+ const response = await fetchImpl(url, {
782
+ headers: {
783
+ authorization: handoff.authorization,
784
+ "x-gallery-lease": handoff.lease.leaseId,
785
+ "x-gallery-artifact": handoff.artifactId,
786
+ },
787
+ });
788
+ if (isLeaseUnauthorized(response)) throw new GallerySessionExpiredError("Gallery lease expired");
789
+ if (response.status === 404) {
790
+ let body: unknown;
791
+ try {
792
+ body = await response.json();
793
+ } catch {
794
+ body = null;
795
+ }
796
+ const code =
797
+ typeof body === "object" &&
798
+ body != null &&
799
+ "error" in body &&
800
+ typeof body.error === "object" &&
801
+ body.error != null &&
802
+ "code" in body.error
803
+ ? body.error.code
804
+ : undefined;
805
+ if (code === "evidence_unavailable") return { bytes: null, renderFormat: null };
806
+ throw new Error(`Gallery evidence fetch failed (${response.status})`);
807
+ }
808
+ if (!response.ok) throw new Error(`Gallery evidence fetch failed (${response.status})`);
809
+ const mediaType = response.headers.get("content-type")?.split(";", 1)[0]?.trim();
810
+ const renderFormat = mediaType === undefined ? null : evidenceImageFormatForMediaType(mediaType);
811
+ if (renderFormat === null)
812
+ throw new Error("Gallery evidence response has unsupported content type");
813
+ return { bytes: new Uint8Array(await response.arrayBuffer()), renderFormat };
814
+ }
815
+
816
+ function setGalleryTitle(document: Document, artifactTitle: string | null): void {
817
+ const normalized = artifactTitle?.trim() ?? "";
818
+ const displayTitle = normalized.length === 0 ? null : normalized;
819
+ const shellTitle = document.getElementById("facet-artifact-title");
820
+ if (shellTitle !== null) shellTitle.textContent = displayTitle ?? "";
821
+ document.title = displayTitle === null ? "facet gallery" : `${displayTitle} · facet`;
822
+ }
823
+
824
+ export function setGalleryFavicon(
825
+ document: Document,
826
+ state: Parameters<typeof faviconTint>[0],
827
+ ): void {
828
+ // `as HTMLLinkElement` rather than `instanceof` — the shell's other
829
+ // set* helpers assign DOM properties directly, and the test-harness DOM
830
+ // shims do not universally expose HTMLLinkElement as a global guard.
831
+ const link = document.getElementById("facet-favicon") as HTMLLinkElement | null;
832
+ if (link === null) return;
833
+ const dataUrl = renderFavicon(faviconTint(state), document);
834
+ if (dataUrl !== null) link.href = dataUrl;
835
+ }
836
+
837
+ function setGalleryStatus(document: Document, status: string): void {
838
+ const target = document.getElementById("facet-status-line");
839
+ if (target !== null) target.textContent = status;
840
+ if (status !== "session expired") {
841
+ const expired = document.getElementById("facet-expired");
842
+ if (expired !== null) expired.hidden = true;
843
+ }
844
+ }
845
+
846
+ function setGalleryError(document: Document, message: string): void {
847
+ const target = document.getElementById("facet-error");
848
+ if (target !== null) {
849
+ target.textContent = message;
850
+ target.classList.toggle("facet-visible", message.length > 0);
851
+ }
852
+ }
853
+
854
+ function renderSessionExpired(
855
+ document: Document,
856
+ setError: (message: string) => void,
857
+ setStatus: (status: string) => void,
858
+ ): void {
859
+ const empty = document.getElementById("facet-empty");
860
+ if (empty !== null) empty.hidden = true;
861
+ const expired = document.getElementById("facet-expired");
862
+ if (expired !== null) expired.hidden = false;
863
+ setStatus("session expired");
864
+ setError("session expired — run facet open again");
865
+ }
866
+
867
+ function setGalleryVerdict(document: Document, verdict: Verdict | null): void {
868
+ const badge = document.getElementById("facet-verdict");
869
+ const evidence = document.getElementById("facet-evidence");
870
+ if (badge === null || badge === undefined) return;
871
+ const tier = badge.querySelector<HTMLElement>(".tier");
872
+ if (verdict === null) {
873
+ badge.dataset.status = "unverified";
874
+ badge.childNodes[0]!.textContent = "unverified";
875
+ if (tier !== null) tier.textContent = "";
876
+ if (evidence !== null) evidence.hidden = true;
877
+ return;
878
+ }
879
+ badge.dataset.status = verdict.status;
880
+ badge.childNodes[0]!.textContent = verdict.status.split(":")[0] ?? verdict.status;
881
+ if (tier !== null) {
882
+ const detail =
883
+ verdict.status === "partial:opaque_content"
884
+ ? "opaque"
885
+ : verdict.status === "partial:layout_unverified"
886
+ ? "layout"
887
+ : verdict.status === "partial:external_resources"
888
+ ? "external"
889
+ : verdict.status === "partial:unstable"
890
+ ? "unstable"
891
+ : null;
892
+ const insecure = verdict.insecure === undefined ? null : `INSECURE L${verdict.insecure.level}`;
893
+ const suffix = insecure === null ? `T${verdict.tier}` : `${insecure} · T${verdict.tier}`;
894
+ tier.textContent = detail === null ? `· ${suffix}` : `· ${detail} · ${suffix}`;
895
+ }
896
+ const observed = verdict.observed;
897
+ const counts = document.getElementById("facet-evidence-counts");
898
+ if (counts !== null)
899
+ counts.textContent =
900
+ observed.html === undefined
901
+ ? `svg ${observed.rendererRootSvgCount} · graphs ${observed.graphCount} · nodes ${observed.mermaidNodeCount} · opaque ${observed.opaqueRegionCount} · errors ${observed.errorCount}`
902
+ : `roots ${observed.html.rendererRootCount} · headings ${observed.html.headingCount} · tables ${observed.html.tableCount} · lists ${observed.html.listCount} · images ${observed.html.imageCount} · canvas ${observed.html.canvasCount} · external ${observed.html.externalImageCount}`;
903
+ const channels = document.getElementById("facet-evidence-channels");
904
+ if (channels !== null) {
905
+ channels.dataset.agree = verdict.status === "tampered" ? "false" : "true";
906
+ channels.textContent =
907
+ verdict.status === "tampered"
908
+ ? "●≠●"
909
+ : verdict.status === "timeout"
910
+ ? "○○○"
911
+ : verdict.status === "shim_only"
912
+ ? "○○●"
913
+ : verdict.status === "probe_only"
914
+ ? "●○○"
915
+ : "●●●";
916
+ }
917
+ const sha = document.getElementById("facet-evidence-sha");
918
+ if (sha !== null) sha.textContent = verdict.revisionSha.slice(0, 8);
919
+ const divergence = document.getElementById("facet-evidence-divergence");
920
+ if (divergence !== null) {
921
+ const detail = verdict.observed.discriminativeErrors?.[0]?.message;
922
+ divergence.textContent = verdict.status === "tampered" ? (detail ?? "channels disagree") : "";
923
+ }
924
+ if (evidence !== null) evidence.hidden = false;
925
+ }
926
+
927
+ function setLiveState(document: Document, state: "idle" | "connecting" | "live"): void {
928
+ const live = document.getElementById("facet-live");
929
+ const label = document.getElementById("facet-live-label");
930
+ if (live !== null) live.dataset.state = state;
931
+ if (label !== null) label.textContent = state;
932
+ }
933
+
934
+ /**
935
+ * Disable the zoom button that sits at its clamp bound (0.25x /
936
+ * 8x from view-state.ts) so a clamped click reads as a no-op instead
937
+ * of silently eating the input. Pure + testable against a fake
938
+ * document, same shape as the other set* helpers above.
939
+ */
940
+ function setZoomButtonState(document: Document, zoom: number): void {
941
+ // `as HTMLButtonElement` rather than `instanceof` — the shell's other
942
+ // set* helpers (setGalleryStatus, setLiveState, ...) all assign DOM
943
+ // properties directly without a runtime type guard, and this file's
944
+ // test-harness DOM shims don't universally expose HTMLButtonElement
945
+ // as a global to guard against.
946
+ const zoomOut = document.getElementById("facet-zoom-out") as HTMLButtonElement | null;
947
+ const zoomIn = document.getElementById("facet-zoom-in") as HTMLButtonElement | null;
948
+ if (zoomOut !== null) zoomOut.disabled = zoom <= MIN_ZOOM;
949
+ if (zoomIn !== null) zoomIn.disabled = zoom >= MAX_ZOOM;
950
+ }
951
+
952
+ function setSwapBar(
953
+ document: Document,
954
+ window: Window,
955
+ state: "start" | "ready" | "complete" | "failed",
956
+ ): void {
957
+ const swapbar = document.getElementById("facet-swapbar");
958
+ const bar = swapbar?.querySelector<HTMLElement>(".bar");
959
+ if (swapbar === null || swapbar === undefined || bar === null || bar === undefined) return;
960
+ if (state === "start") {
961
+ delete swapbar.dataset.failed;
962
+ swapbar.hidden = false;
963
+ bar.style.width = "34%";
964
+ } else if (state === "ready") {
965
+ bar.style.width = "80%";
966
+ } else if (state === "complete") {
967
+ bar.style.width = "100%";
968
+ window.setTimeout(() => {
969
+ swapbar.hidden = true;
970
+ }, 200);
971
+ } else {
972
+ swapbar.dataset.failed = "";
973
+ bar.style.width = "0%";
974
+ window.setTimeout(() => {
975
+ swapbar.hidden = true;
976
+ }, 2_000);
977
+ }
978
+ }
979
+
980
+ export interface SerializedSwapQueue<T> {
981
+ /** Latest-wins enqueue: an in-flight swap absorbs newer events; intermediates are skipped. */
982
+ readonly enqueue: (event: T) => void;
983
+ /** Resolve when no swap is in flight and nothing is queued. */
984
+ readonly settle: () => Promise<void>;
985
+ /** Drop queued work; an already-running task may finish but callers gate its writes. */
986
+ readonly close: () => void;
987
+ }
988
+
989
+ /**
990
+ * Serialize revision swaps: at most one swap runs at a time; a commit
991
+ * that lands mid-swap is queued latest-wins. Two concurrent swaps
992
+ * would leave two visible frames and a leaked document realm.
993
+ */
994
+ export function createSerializedSwapQueue<T>(
995
+ run: (event: T) => Promise<void>,
996
+ mergePending: (pending: T, next: T) => T = (_pending, next) => next,
997
+ ): SerializedSwapQueue<T> {
998
+ let inFlight: Promise<void> | null = null;
999
+ let pending: T | null = null;
1000
+ let closed = false;
1001
+ let tail: Promise<void> = Promise.resolve();
1002
+
1003
+ const drain = async (): Promise<void> => {
1004
+ while (pending !== null) {
1005
+ const event = pending;
1006
+ pending = null;
1007
+ inFlight = run(event);
1008
+ try {
1009
+ await inFlight;
1010
+ } catch {
1011
+ // swapToRevision failures are handled by the caller's run
1012
+ // wrapper; a throw here must not strand a queued revision.
1013
+ } finally {
1014
+ inFlight = null;
1015
+ }
1016
+ }
1017
+ };
1018
+
1019
+ return {
1020
+ enqueue(event: T): void {
1021
+ if (closed) return;
1022
+ pending = pending === null ? event : mergePending(pending, event);
1023
+ if (inFlight === null) {
1024
+ tail = drain().catch(() => undefined);
1025
+ }
1026
+ },
1027
+ settle(): Promise<void> {
1028
+ return tail;
1029
+ },
1030
+ close(): void {
1031
+ closed = true;
1032
+ pending = null;
1033
+ },
1034
+ };
1035
+ }
1036
+
1037
+ export interface GalleryRuntime {
1038
+ readonly window: Window;
1039
+ readonly document: Document;
1040
+ readonly history: History;
1041
+ readonly HTMLElement: typeof HTMLElement;
1042
+ readonly fetch: typeof fetch;
1043
+ readonly currentResolvedTheme?: () => ResolvedGalleryTheme;
1044
+ readonly matchMedia?: GalleryMatchMedia;
1045
+ }
1046
+
1047
+ function browserGalleryRuntime(): GalleryRuntime {
1048
+ return {
1049
+ window,
1050
+ document,
1051
+ history,
1052
+ HTMLElement,
1053
+ fetch,
1054
+ matchMedia: window.matchMedia.bind(window),
1055
+ };
1056
+ }
1057
+
1058
+ export async function startGallery(runtime = browserGalleryRuntime()): Promise<void> {
1059
+ const { window, document, history, HTMLElement, fetch } = runtime;
1060
+ let themeMode: GalleryThemeMode = "system";
1061
+ let requestedThemeMode: GalleryThemeMode = "system";
1062
+ const matchMedia = runtime.matchMedia;
1063
+ const currentResolvedTheme =
1064
+ runtime.currentResolvedTheme ?? (() => resolveGalleryTheme(requestedThemeMode, matchMedia));
1065
+ let expired = false;
1066
+ const updateGalleryStatus = (status: string): void => {
1067
+ if (!expired) setGalleryStatus(document, status);
1068
+ };
1069
+ const updateGalleryError = (message: string): void => {
1070
+ if (!expired) setGalleryError(document, message);
1071
+ };
1072
+ const updateGalleryVerdict = (verdict: Verdict | null): void =>
1073
+ expired ? undefined : setGalleryVerdict(document, verdict);
1074
+ const updateGalleryTitle = (artifactTitle: string | null): void => {
1075
+ if (!expired) setGalleryTitle(document, artifactTitle);
1076
+ };
1077
+ const updateGalleryFavicon = (state: Parameters<typeof faviconTint>[0]): void => {
1078
+ if (!expired) setGalleryFavicon(document, state);
1079
+ };
1080
+ const updateLiveState = (state: "idle" | "connecting" | "live"): void =>
1081
+ expired ? undefined : setLiveState(document, state);
1082
+ const updateSwapBar = (state: "start" | "ready" | "complete" | "failed"): void =>
1083
+ expired ? undefined : setSwapBar(document, window, state);
1084
+ const updateZoomButtons = (zoom: number): void => setZoomButtonState(document, zoom);
1085
+ let exportMenu: GalleryExportMenuController | null = null;
1086
+ let swaps: SerializedSwapQueue<GallerySwapEvent | ThemeRerenderEvent> | null = null;
1087
+ type ThemeRerenderEvent = {
1088
+ readonly kind: "theme";
1089
+ readonly generation: number;
1090
+ };
1091
+ let removeThemePreferenceListener: () => void = noOp;
1092
+ let generation = 0;
1093
+ let activeFrame: CreatedArtifactFrame | null = null;
1094
+ const expireSession = (): void => {
1095
+ if (expired) return;
1096
+ generation += 1;
1097
+ setGalleryFavicon(document, "expired");
1098
+ expired = true;
1099
+ exportMenu?.close();
1100
+ exportMenu?.sync();
1101
+ activeFrame?.renderResult?.setGestureMode("native");
1102
+ swaps?.close();
1103
+ removeThemePreferenceListener();
1104
+ clearSession(window.sessionStorage);
1105
+ renderSessionExpired(
1106
+ document,
1107
+ (message) => setGalleryError(document, message),
1108
+ (status) => setGalleryStatus(document, status),
1109
+ );
1110
+ setLiveState(document, "idle");
1111
+ };
1112
+ const baseUrl = window.location.origin;
1113
+ updateGalleryFavicon("idle");
1114
+ const bootstrap = await resolveGalleryBootstrap({
1115
+ location: window.location.href,
1116
+ storage: window.sessionStorage,
1117
+ fetchImpl: fetch,
1118
+ clearFragment: () => history.replaceState(null, "", window.location.pathname),
1119
+ validateLease: async (session) => {
1120
+ try {
1121
+ const probe = new URL(`${baseUrl.replace(/\/$/, "")}/api/v1/gallery/source`);
1122
+ probe.searchParams.set("revisionSha", session.revisionSha);
1123
+ const response = await fetch(probe, {
1124
+ headers: {
1125
+ authorization: session.authorization,
1126
+ "x-gallery-lease": session.lease.leaseId,
1127
+ "x-gallery-artifact": session.artifactId,
1128
+ },
1129
+ });
1130
+ return response.ok;
1131
+ } catch {
1132
+ return false;
1133
+ }
1134
+ },
1135
+ });
1136
+ if (bootstrap.outcome === "expired") {
1137
+ expireSession();
1138
+ disableGalleryExportMenu(document);
1139
+ return;
1140
+ }
1141
+ const handoff = bootstrap.session;
1142
+ themeMode = handoff.theme;
1143
+ requestedThemeMode = handoff.theme;
1144
+ exportMenu = installGalleryExportMenu({ document, isExpired: () => expired });
1145
+ const title = document.getElementById("facet-title");
1146
+ const revisionLabel = document.getElementById("facet-revision");
1147
+ if (title !== null) title.textContent = "facet";
1148
+ if (revisionLabel !== null) revisionLabel.textContent = handoff.revisionSha.slice(0, 12);
1149
+ updateGalleryStatus("idle");
1150
+ updateLiveState("connecting");
1151
+ const frameUrl = `${baseUrl}/gallery/frame`;
1152
+ const canvas = document.getElementById("facet-canvas");
1153
+ if (!(canvas instanceof HTMLElement)) throw new Error("Gallery canvas is missing");
1154
+ const host: FrameHost = {
1155
+ mountOffScreen(frameId, element) {
1156
+ const iframe = element as HTMLIFrameElement;
1157
+ iframe.dataset.frameId = frameId;
1158
+ iframe.style.visibility = "hidden";
1159
+ iframe.style.transformOrigin = "top left";
1160
+ iframe.classList.add("facet-ready");
1161
+ canvas.appendChild(iframe);
1162
+ const empty = document.getElementById("facet-empty");
1163
+ if (empty !== null) empty.hidden = true;
1164
+ },
1165
+ setVisibility(frameId, visible) {
1166
+ const iframe = canvas.querySelector<HTMLIFrameElement>(`[data-frame-id="${frameId}"]`);
1167
+ if (iframe !== null) iframe.style.visibility = visible ? "visible" : "hidden";
1168
+ },
1169
+ unmount(frameId) {
1170
+ canvas.querySelector<HTMLIFrameElement>(`[data-frame-id="${frameId}"]`)?.remove();
1171
+ },
1172
+ showErrorBadge: updateGalleryError,
1173
+ };
1174
+ const dom: ShellDom = { document, hostname: window.location.hostname };
1175
+ let source: RevisionFetchResult;
1176
+ try {
1177
+ source = await fetchGallerySource(baseUrl, handoff, handoff.revisionSha, fetch);
1178
+ } catch (error) {
1179
+ if (error instanceof GallerySessionExpiredError) {
1180
+ expireSession();
1181
+ return;
1182
+ }
1183
+ throw error;
1184
+ }
1185
+ let current = createArtifactFrame({
1186
+ artifactType: source.artifactType,
1187
+ renderer: source.renderer,
1188
+ theme: currentResolvedTheme(),
1189
+ frameUrl,
1190
+ dom,
1191
+ });
1192
+ activeFrame = current;
1193
+ host.mountOffScreen(current.frameId, current.element.raw);
1194
+ const loaded = await current.awaitLoad(DEFAULT_READY_TIMEOUT_MS);
1195
+ if (!loaded) throw new Error("Gallery frame failed to load");
1196
+ const initialResult = await current.render(
1197
+ {
1198
+ artifactType: source.artifactType,
1199
+ renderer: source.renderer,
1200
+ bytes: source.bytes,
1201
+ theme: currentResolvedTheme(),
1202
+ ...(source.execution === undefined ? {} : { execution: source.execution }),
1203
+ },
1204
+ DEFAULT_READY_TIMEOUT_MS,
1205
+ );
1206
+ if (initialResult.observed.errorCount !== 0) {
1207
+ throw new Error("Gallery artifact failed to render");
1208
+ }
1209
+ let initialEvidence: GalleryEvidenceResponse;
1210
+ try {
1211
+ initialEvidence = await fetchGalleryEvidence(baseUrl, handoff, source.revisionSha, fetch);
1212
+ } catch (error) {
1213
+ if (!(error instanceof GallerySessionExpiredError)) throw error;
1214
+ expireSession();
1215
+ return;
1216
+ }
1217
+ exportMenu!.setState(
1218
+ exportMenuStateFrom(
1219
+ {
1220
+ artifactId: source.artifactId,
1221
+ revisionSha: source.revisionSha,
1222
+ slug: source.slug,
1223
+ title: source.title,
1224
+ artifactType: source.artifactType as GalleryExportState["artifactType"],
1225
+ renderer: source.renderer as GalleryExportState["renderer"],
1226
+ sourceBytes: source.sourceBytes,
1227
+ verdict: source.verdict ?? null,
1228
+ },
1229
+ initialEvidence.bytes,
1230
+ initialEvidence.renderFormat,
1231
+ ),
1232
+ );
1233
+ let displayedExportState = exportMenu!.getState();
1234
+ host.setVisibility(current.frameId, true);
1235
+ document.documentElement.dataset.theme = currentResolvedTheme();
1236
+ updateGalleryTitle(source.title);
1237
+ updateGalleryFavicon(source.verdict?.status ?? "unverified");
1238
+ updateGalleryVerdict(source.verdict ?? null);
1239
+ updateGalleryStatus("displayed");
1240
+ updateLiveState("live");
1241
+ const themeToggle = document.getElementById("facet-theme-toggle");
1242
+ const updateThemeToggle = (mode: GalleryThemeMode): void => {
1243
+ if (themeToggle === null) return;
1244
+ themeToggle.textContent = `theme: ${mode}`;
1245
+ themeToggle.setAttribute("aria-label", `theme: ${mode}`);
1246
+ themeToggle.dataset.themeMode = mode;
1247
+ };
1248
+ const persistTheme = (mode: GalleryThemeMode): void => {
1249
+ persistSession(window.sessionStorage, {
1250
+ authorization: handoff.authorization,
1251
+ artifactId: handoff.artifactId,
1252
+ revisionSha: handoff.revisionSha,
1253
+ lease: handoff.lease,
1254
+ theme: mode,
1255
+ });
1256
+ };
1257
+ const commitTheme = (mode: GalleryThemeMode, resolvedTheme: ResolvedGalleryTheme): void => {
1258
+ if (mode !== requestedThemeMode) return;
1259
+ const modeChanged = themeMode !== mode;
1260
+ themeMode = mode;
1261
+ document.documentElement.dataset.theme = resolvedTheme;
1262
+ if (modeChanged) persistTheme(mode);
1263
+ };
1264
+ const rerenderCurrentRevision = async (
1265
+ nextResolvedTheme: ResolvedGalleryTheme,
1266
+ themeGeneration: number,
1267
+ nextMode: GalleryThemeMode,
1268
+ ): Promise<void> => {
1269
+ const canCommit = (): boolean =>
1270
+ themeGeneration === generation && nextMode === requestedThemeMode;
1271
+ if (!canCommit()) return;
1272
+ const next = createArtifactFrame({
1273
+ artifactType: source.artifactType,
1274
+ renderer: source.renderer,
1275
+ theme: nextResolvedTheme,
1276
+ frameUrl,
1277
+ dom,
1278
+ });
1279
+ const result = await replaceArtifactFrame({
1280
+ current,
1281
+ next,
1282
+ dom,
1283
+ host,
1284
+ viewState: current.renderResult?.readViewState() ?? EMPTY_VIEW_STATE,
1285
+ source: {
1286
+ artifactType: source.artifactType,
1287
+ renderer: source.renderer,
1288
+ bytes: source.bytes,
1289
+ theme: nextResolvedTheme,
1290
+ ...(source.execution === undefined ? {} : { execution: source.execution }),
1291
+ },
1292
+ onProgress: updateSwapBar,
1293
+ canCommit,
1294
+ });
1295
+ if (!canCommit() || result.cancelled || result.failedNewFrameReady) return;
1296
+ current = next;
1297
+ activeFrame = next;
1298
+ commitTheme(nextMode, nextResolvedTheme);
1299
+ syncPanZoomToggle();
1300
+ syncZoomButtons();
1301
+ };
1302
+ const queueThemeRerender = (): void => {
1303
+ swaps?.enqueue({
1304
+ kind: "theme",
1305
+ generation,
1306
+ });
1307
+ };
1308
+ let preferenceQuery: MediaQueryList | null = null;
1309
+ let preferenceListener: ((event: MediaQueryListEvent) => void) | null = null;
1310
+ removeThemePreferenceListener = (): void => {
1311
+ if (preferenceQuery !== null && preferenceListener !== null) {
1312
+ preferenceQuery.removeEventListener("change", preferenceListener);
1313
+ }
1314
+ preferenceQuery = null;
1315
+ preferenceListener = null;
1316
+ };
1317
+ const syncThemePreferenceListener = (): void => {
1318
+ removeThemePreferenceListener();
1319
+ if (requestedThemeMode !== "system" || matchMedia === undefined || matchMedia === null) return;
1320
+ try {
1321
+ const query = matchMedia("(prefers-color-scheme: dark)") as MediaQueryList | null | undefined;
1322
+ if (query === null || query === undefined || typeof query.addEventListener !== "function")
1323
+ return;
1324
+ preferenceQuery = query;
1325
+ preferenceListener = () => queueThemeRerender();
1326
+ query.addEventListener("change", preferenceListener);
1327
+ } catch {
1328
+ removeThemePreferenceListener();
1329
+ }
1330
+ };
1331
+ updateThemeToggle(requestedThemeMode);
1332
+ syncThemePreferenceListener();
1333
+ themeToggle?.addEventListener("click", () => {
1334
+ if (expired) return;
1335
+ requestedThemeMode = nextGalleryThemeMode(requestedThemeMode);
1336
+ updateThemeToggle(requestedThemeMode);
1337
+ syncThemePreferenceListener();
1338
+ queueThemeRerender();
1339
+ });
1340
+ swaps = createSerializedSwapQueue<GallerySwapEvent | ThemeRerenderEvent>(
1341
+ (event) => {
1342
+ if (event.kind === "theme") {
1343
+ const mode = requestedThemeMode;
1344
+ return rerenderCurrentRevision(currentResolvedTheme(), event.generation, mode).catch(
1345
+ (error: unknown) => {
1346
+ if (!expired)
1347
+ updateGalleryError(error instanceof Error ? error.message : String(error));
1348
+ },
1349
+ );
1350
+ }
1351
+ const revisionThemeMode = requestedThemeMode;
1352
+ const revisionTheme = currentResolvedTheme();
1353
+ return swapToRevision(
1354
+ {
1355
+ dom,
1356
+ host,
1357
+ theme: revisionTheme,
1358
+ frameUrl,
1359
+ fetchRevision: (_artifactId, revisionSha) =>
1360
+ fetchGallerySource(baseUrl, handoff, revisionSha, fetch),
1361
+ onProgress: updateSwapBar,
1362
+ },
1363
+ current,
1364
+ event,
1365
+ current.renderResult?.readViewState() ?? EMPTY_VIEW_STATE,
1366
+ )
1367
+ .then(async ({ frame, result, revision }) => {
1368
+ if (!result.failedNewFrameReady) updateGalleryTitle(revision.title);
1369
+ updateGalleryFavicon(
1370
+ result.failedNewFrameReady ? "unverified" : (revision.verdict?.status ?? "unverified"),
1371
+ );
1372
+ if (expired) return;
1373
+ if (!result.failedNewFrameReady) {
1374
+ current = frame;
1375
+ activeFrame = frame;
1376
+ source = revision;
1377
+ commitTheme(revisionThemeMode, revisionTheme);
1378
+ syncPanZoomToggle();
1379
+ syncZoomButtons();
1380
+ const evidence = await fetchGalleryEvidence(
1381
+ baseUrl,
1382
+ handoff,
1383
+ revision.revisionSha,
1384
+ fetch,
1385
+ );
1386
+ if (expired) return;
1387
+ exportMenu!.setState(
1388
+ exportMenuStateFrom(
1389
+ {
1390
+ artifactId: revision.artifactId,
1391
+ revisionSha: revision.revisionSha,
1392
+ slug: revision.slug,
1393
+ title: revision.title,
1394
+ artifactType: revision.artifactType as GalleryExportState["artifactType"],
1395
+ renderer: revision.renderer as GalleryExportState["renderer"],
1396
+ sourceBytes: revision.sourceBytes,
1397
+ verdict: revision.verdict ?? null,
1398
+ },
1399
+ evidence.bytes,
1400
+ evidence.renderFormat,
1401
+ ),
1402
+ );
1403
+ displayedExportState = exportMenu!.getState();
1404
+ if (revisionLabel !== null) revisionLabel.textContent = event.revisionSha.slice(0, 12);
1405
+ updateGalleryVerdict(revision.verdict ?? null);
1406
+ updateGalleryStatus("displayed");
1407
+ updateSwapBar("complete");
1408
+ } else {
1409
+ exportMenu!.setState(displayedExportState);
1410
+ updateSwapBar("failed");
1411
+ updateGalleryVerdict(null);
1412
+ updateGalleryStatus("displayed");
1413
+ }
1414
+ })
1415
+ .catch((error: unknown) => {
1416
+ if (expired) return;
1417
+ if (error instanceof GallerySessionExpiredError) {
1418
+ expireSession();
1419
+ return;
1420
+ }
1421
+ updateSwapBar("failed");
1422
+ updateGalleryStatus("displayed");
1423
+ updateGalleryError(error instanceof Error ? error.message : String(error));
1424
+ });
1425
+ },
1426
+ (pending, next) => (pending.kind === "revision" && next.kind === "theme" ? pending : next),
1427
+ );
1428
+ const stream = connectRevisionStream({
1429
+ baseUrl,
1430
+ bearer: handoff.authorization.replace(/^Bearer\s+/i, ""),
1431
+ leaseId: handoff.lease.leaseId,
1432
+ artifactId: handoff.artifactId,
1433
+ hostname: window.location.hostname,
1434
+ fetchImpl: fetch,
1435
+ onState: updateLiveState,
1436
+ onCommit: (event) => {
1437
+ exportMenu?.clear();
1438
+ updateGalleryStatus("swapping");
1439
+ updateSwapBar("start");
1440
+ updateGalleryFavicon("unverified");
1441
+ updateGalleryVerdict(null);
1442
+ swaps?.enqueue({ kind: "revision", ...event });
1443
+ },
1444
+ onClose: (event) => {
1445
+ // `lease_expired` (the per-lease TTL firing server-side) and a
1446
+ // rejected reconnect (`stream_status_401`, the lease no longer
1447
+ // authenticating) both mean the session itself is gone — the
1448
+ // typed expired screen and a cleared persisted session are the
1449
+ // correct terminal state. Any other close reason is a transient
1450
+ // transport loss; those just go idle, matching the previous
1451
+ // (deliberately unbranched) behavior.
1452
+ if (event.reason === "lease_expired" || event.reason === "stream_status_401") {
1453
+ expireSession();
1454
+ stream.close();
1455
+ return;
1456
+ }
1457
+ updateLiveState("idle");
1458
+ updateGalleryStatus("idle");
1459
+ },
1460
+ });
1461
+ // Set below, once the toolbar wiring installs the wheel-zoom sync
1462
+ // poll — declared here so the one `beforeunload` listener can clear
1463
+ // it alongside closing the stream, instead of a second listener
1464
+ // registration (the shell keeps exactly one shutdown path).
1465
+ let zoomButtonPoll: ReturnType<typeof setInterval> | undefined;
1466
+ const shutdown = (): void => {
1467
+ // The display lease is bound to a per-lease TTL on the service
1468
+ // (see GalleryLeaseManager.schedule). Releasing it eagerly on
1469
+ // beforeunload would defeat the sessionStorage re-attach path on
1470
+ // F5 — the lease would be gone by the time the new shell ran
1471
+ // resolveGalleryBootstrap. The idle controller on the service
1472
+ // releases the lease when the TTL fires, which is the only path
1473
+ // that lets "refresh the tab" reach the same displayed canvas.
1474
+ stream.close();
1475
+ removeThemePreferenceListener();
1476
+ if (zoomButtonPoll !== undefined) clearInterval(zoomButtonPoll);
1477
+ };
1478
+ window.addEventListener("beforeunload", shutdown, { once: true });
1479
+
1480
+ const canvasRect = (): DOMRect => canvas.getBoundingClientRect();
1481
+ // Shell-side listener: keyboard focus can legitimately sit in the
1482
+ // parent document (e.g. the user tabbed to a control) rather than
1483
+ // inside the frame, so this listener stays alongside the frame's
1484
+ // own (frame/runtime.ts) rather than being the only one. Both route
1485
+ // through `nextViewStateForKey` so the key-to-state mapping has one
1486
+ // tested home.
1487
+ document.addEventListener("keydown", (event) => {
1488
+ if (expired) return;
1489
+ const result = current.renderResult;
1490
+ if (!result) return;
1491
+ const next = nextViewStateForKey(
1492
+ result.readViewState(),
1493
+ event.key,
1494
+ event.shiftKey,
1495
+ canvasRect(),
1496
+ );
1497
+ if (next === null) return;
1498
+ event.preventDefault();
1499
+ if (event.key === "0") {
1500
+ result.setGestureMode(result.defaultGestureMode);
1501
+ syncPanZoomToggle();
1502
+ }
1503
+ result.applyViewState(next);
1504
+ syncZoomButtons();
1505
+ });
1506
+ for (const [id, delta] of [
1507
+ ["facet-zoom-in", 0.1],
1508
+ ["facet-zoom-out", -0.1],
1509
+ ] as const) {
1510
+ document.getElementById(id)?.addEventListener("click", () => {
1511
+ if (expired) return;
1512
+ const result = current.renderResult;
1513
+ if (!result) return;
1514
+ const state = result.readViewState();
1515
+ const rect = canvasRect();
1516
+ result.applyViewState(
1517
+ zoomAtPoint(state, clampZoom(state.zoom + delta), rect.width / 2, rect.height / 2),
1518
+ );
1519
+ syncZoomButtons();
1520
+ });
1521
+ }
1522
+ const panZoomToggle = document.getElementById("facet-panzoom-toggle");
1523
+ /** Reflect the frame's actual gesture mode on the toolbar toggle — called after every render/swap so a fresh diagram's default-on panzoom shows latched without a click. */
1524
+ const syncPanZoomToggle = (): void => {
1525
+ if (panZoomToggle === null) return;
1526
+ const active = current.renderResult?.gestureMode() === "panzoom";
1527
+ panZoomToggle.setAttribute("aria-pressed", active ? "true" : "false");
1528
+ };
1529
+ /** Disable the zoom button sitting at its clamp bound — called at every point the zoom value can change (buttons, keyboard, reset, swap, and the wheel-zoom poll below), mirroring syncPanZoomToggle's called-everywhere shape. */
1530
+ const syncZoomButtons = (): void => {
1531
+ updateZoomButtons(current.renderResult?.readViewState().zoom ?? 1);
1532
+ };
1533
+ panZoomToggle?.addEventListener("click", () => {
1534
+ if (expired) return;
1535
+ const result = current.renderResult;
1536
+ if (!result) return;
1537
+ result.setGestureMode(result.gestureMode() === "panzoom" ? "native" : "panzoom");
1538
+ syncPanZoomToggle();
1539
+ });
1540
+ document.getElementById("facet-zoom-reset")?.addEventListener("click", () => {
1541
+ if (expired) return;
1542
+ const result = current.renderResult;
1543
+ if (!result) return;
1544
+ result.setGestureMode(result.defaultGestureMode);
1545
+ result.applyViewState(resetViewState(result.readViewState()));
1546
+ result.resetDiagramRegions();
1547
+ syncPanZoomToggle();
1548
+ syncZoomButtons();
1549
+ });
1550
+ syncPanZoomToggle();
1551
+ syncZoomButtons();
1552
+ document
1553
+ .getElementById("facet-fullscreen")
1554
+ ?.addEventListener("click", () => void canvas.requestFullscreen());
1555
+ // Wheel-zoom happens inside the frame's own document (see the
1556
+ // `onWheel` listener installed by `installGalleryFrameApi` in
1557
+ // frame/runtime.ts) — an iframe's events never bubble to the parent
1558
+ // document, so the shell has no listener to hook a sync call onto.
1559
+ // A callback threaded through the render-result handle (the same
1560
+ // same-origin boundary `applyViewState`/`gestureMode` already cross)
1561
+ // would avoid polling entirely; the poll is simpler for a local
1562
+ // single-tab tool and cheap enough (one unref'd 200ms interval,
1563
+ // cleared on shutdown, not re-created per swap) that the tradeoff
1564
+ // wasn't worth the extra plumbing here. Bare `setInterval`
1565
+ // (globalThis, not `window.*`) so this works whether the injected
1566
+ // runtime's `window` fake implements timers or not — the shell's
1567
+ // own DOM interface never required it.
1568
+ zoomButtonPoll = setInterval(syncZoomButtons, 200);
1569
+ if (typeof zoomButtonPoll.unref === "function") zoomButtonPoll.unref();
1570
+ }
1571
+
1572
+ if (
1573
+ typeof document !== "undefined" &&
1574
+ typeof window !== "undefined" &&
1575
+ document.getElementById("facet-canvas") !== null
1576
+ ) {
1577
+ void startGallery().catch((error: unknown) => {
1578
+ setGalleryStatus(document, "error");
1579
+ setGalleryError(document, error instanceof Error ? error.message : String(error));
1580
+ });
1581
+ }