@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.
- package/LICENSE-APACHE +202 -0
- package/LICENSE-MIT +23 -0
- package/README.md +158 -0
- package/dist/gallery/chunk-4g8rem85.css +656 -0
- package/dist/gallery/chunk-c6s9k9ty.js +5970 -0
- package/dist/gallery/frame/artifact.css +8424 -0
- package/dist/gallery/frame/chunks/abnfDiagram-N423BO3Z-jj74szbm.js +155 -0
- package/dist/gallery/frame/chunks/architecture-TIHT7OUA-cvdjn7dt.js +10 -0
- package/dist/gallery/frame/chunks/architectureDiagram-T3A2C74G-n4z77eg6.js +8675 -0
- package/dist/gallery/frame/chunks/blockDiagram-VBNYF7ZC-7xsqzrd1.js +3529 -0
- package/dist/gallery/frame/chunks/c4Diagram-5PPSVZJV-z2zjs7wx.js +2413 -0
- package/dist/gallery/frame/chunks/classDiagram-JCYQIIEL-ve8rngqp.js +47 -0
- package/dist/gallery/frame/chunks/classDiagram-v2-OCEON4UE-8cfaxb8z.js +47 -0
- package/dist/gallery/frame/chunks/cose-bilkent-JH36ORCC-sn92qexx.js +4822 -0
- package/dist/gallery/frame/chunks/cynefin-VYW2F7L2-tqjv913d.js +10 -0
- package/dist/gallery/frame/chunks/cynefinDiagram-MW4NZA55-c1c0dj12.js +500 -0
- package/dist/gallery/frame/chunks/dagre-VZM6K2ZE-7tyg4yt2.js +476 -0
- package/dist/gallery/frame/chunks/diagram-7IWD3JNH-y4eng9sc.js +526 -0
- package/dist/gallery/frame/chunks/diagram-B4RE2ZJO-rn3p7j5h.js +673 -0
- package/dist/gallery/frame/chunks/diagram-LBJQPF4R-w4a2cm9q.js +255 -0
- package/dist/gallery/frame/chunks/diagram-Q27KOJAE-aqc2v7wy.js +575 -0
- package/dist/gallery/frame/chunks/diagram-UB23O5K3-xncy8nyd.js +355 -0
- package/dist/gallery/frame/chunks/ebnfDiagram-BXEA7PRR-acwgxz16.js +175 -0
- package/dist/gallery/frame/chunks/erDiagram-JOGREHBK-mzp5nswt.js +1332 -0
- package/dist/gallery/frame/chunks/eventmodeling-45OFAUF4-sg07qr0c.js +10 -0
- package/dist/gallery/frame/chunks/flowDiagram-UKHOOZJN-2e5ygs44.js +29 -0
- package/dist/gallery/frame/chunks/ganttDiagram-PKOTCBZU-p2hhh7hr.js +2620 -0
- package/dist/gallery/frame/chunks/gitGraph-TEB2WS4Q-j6kw0naa.js +10 -0
- package/dist/gallery/frame/chunks/gitGraphDiagram-DS77QQ5N-4z9z8wjm.js +1347 -0
- package/dist/gallery/frame/chunks/info-DKCQHKI2-mpt80jd3.js +10 -0
- package/dist/gallery/frame/chunks/infoDiagram-6WML65LV-5rqm3mgg.js +65 -0
- package/dist/gallery/frame/chunks/ishikawaDiagram-WSZJBQD7-b51z96jy.js +975 -0
- package/dist/gallery/frame/chunks/journeyDiagram-NVQOT4AX-9jbfp0pk.js +1272 -0
- package/dist/gallery/frame/chunks/kanban-definition-27J2QSJJ-4x0vx4se.js +1116 -0
- package/dist/gallery/frame/chunks/katex-xfgk95tv.js +14034 -0
- package/dist/gallery/frame/chunks/markdown-1s5vwkf2.js +9 -0
- package/dist/gallery/frame/chunks/markdown-2h6bwsms.js +3239 -0
- package/dist/gallery/frame/chunks/markdown-2x113yh8.js +107 -0
- package/dist/gallery/frame/chunks/markdown-30b7rwmp.js +1943 -0
- package/dist/gallery/frame/chunks/markdown-356bfj4g.js +2031 -0
- package/dist/gallery/frame/chunks/markdown-44n9ea69.js +2406 -0
- package/dist/gallery/frame/chunks/markdown-56enrf2r.js +39 -0
- package/dist/gallery/frame/chunks/markdown-5avymm8t.js +725 -0
- package/dist/gallery/frame/chunks/markdown-6r4svars.js +33 -0
- package/dist/gallery/frame/chunks/markdown-6zz8efx2.js +24 -0
- package/dist/gallery/frame/chunks/markdown-7jp6prnb.js +19 -0
- package/dist/gallery/frame/chunks/markdown-809k7q41.js +55 -0
- package/dist/gallery/frame/chunks/markdown-82xmzhzd.js +1076 -0
- package/dist/gallery/frame/chunks/markdown-92n37gd4.js +7627 -0
- package/dist/gallery/frame/chunks/markdown-ag2sssf8.js +36 -0
- package/dist/gallery/frame/chunks/markdown-b4s7ntq2.js +19 -0
- package/dist/gallery/frame/chunks/markdown-c9k0fvp1.js +100 -0
- package/dist/gallery/frame/chunks/markdown-ceefymn8.js +125 -0
- package/dist/gallery/frame/chunks/markdown-d1wfbs70.js +243 -0
- package/dist/gallery/frame/chunks/markdown-dy5sgjhc.js +11022 -0
- package/dist/gallery/frame/chunks/markdown-dz3z5w30.js +30868 -0
- package/dist/gallery/frame/chunks/markdown-eg6ngz7y.js +30340 -0
- package/dist/gallery/frame/chunks/markdown-ff5wvb43.js +1149 -0
- package/dist/gallery/frame/chunks/markdown-ffg06nfz.js +86 -0
- package/dist/gallery/frame/chunks/markdown-fvfncadf.js +472 -0
- package/dist/gallery/frame/chunks/markdown-gvyszqpv.js +19 -0
- package/dist/gallery/frame/chunks/markdown-gx1p0ew0.js +88 -0
- package/dist/gallery/frame/chunks/markdown-gxye5frz.js +65 -0
- package/dist/gallery/frame/chunks/markdown-h7pzv34e.js +105 -0
- package/dist/gallery/frame/chunks/markdown-hab789t9.js +47 -0
- package/dist/gallery/frame/chunks/markdown-hjp6sj8t.js +125 -0
- package/dist/gallery/frame/chunks/markdown-jka64qgp.js +1993 -0
- package/dist/gallery/frame/chunks/markdown-k8xkn8mw.js +56 -0
- package/dist/gallery/frame/chunks/markdown-m7frq8x8.js +112 -0
- package/dist/gallery/frame/chunks/markdown-p740v4qm.js +36 -0
- package/dist/gallery/frame/chunks/markdown-pxfeh231.js +57 -0
- package/dist/gallery/frame/chunks/markdown-pzgd8z3s.js +87 -0
- package/dist/gallery/frame/chunks/markdown-qx3cngz2.js +1673 -0
- package/dist/gallery/frame/chunks/markdown-rawhfvvc.js +85 -0
- package/dist/gallery/frame/chunks/markdown-rd8w0ng9.js +49 -0
- package/dist/gallery/frame/chunks/markdown-rm2yxfjj.js +2773 -0
- package/dist/gallery/frame/chunks/markdown-s1jatrnk.js +993 -0
- package/dist/gallery/frame/chunks/markdown-sgfg5vvq.js +59 -0
- package/dist/gallery/frame/chunks/markdown-t4ethv9d.js +409 -0
- package/dist/gallery/frame/chunks/markdown-tnmabqvz.js +36 -0
- package/dist/gallery/frame/chunks/markdown-tvdpppw8.js +5292 -0
- package/dist/gallery/frame/chunks/markdown-v0x27twr.js +22 -0
- package/dist/gallery/frame/chunks/markdown-v14td68x.js +36 -0
- package/dist/gallery/frame/chunks/markdown-w6sgd54z.js +36 -0
- package/dist/gallery/frame/chunks/markdown-xw8dsk8b.js +2125 -0
- package/dist/gallery/frame/chunks/markdown-y18mve73.js +85 -0
- package/dist/gallery/frame/chunks/markdown-yengsms5.js +427 -0
- package/dist/gallery/frame/chunks/markdown-z1wc0v0d.js +1826 -0
- package/dist/gallery/frame/chunks/markdown-zazx9h4n.js +329 -0
- package/dist/gallery/frame/chunks/mermaid-de7p4v0x.js +30 -0
- package/dist/gallery/frame/chunks/mindmap-definition-FAOFIHXS-ra44cbem.js +1216 -0
- package/dist/gallery/frame/chunks/packet-7NZHBO7P-xppm5913.js +10 -0
- package/dist/gallery/frame/chunks/pegDiagram-VL7TDLO6-a2a4wg03.js +163 -0
- package/dist/gallery/frame/chunks/pie-RZYD4A2V-hnbxk152.js +10 -0
- package/dist/gallery/frame/chunks/pieDiagram-7S7Q4E2Y-f1vxjtmx.js +311 -0
- package/dist/gallery/frame/chunks/quadrantDiagram-CIZ2JOQS-ywafa6kh.js +1390 -0
- package/dist/gallery/frame/chunks/radar-I7S5WNFK-ewqd27se.js +10 -0
- package/dist/gallery/frame/chunks/railroad-3IZDKUUU-5hy4bdx3.js +10 -0
- package/dist/gallery/frame/chunks/railroad-abnf-AHOZXSZD-bh5sxz0d.js +10 -0
- package/dist/gallery/frame/chunks/railroad-ebnf-EBAXGLYW-6b9c3zeh.js +10 -0
- package/dist/gallery/frame/chunks/railroad-peg-LSFZ7HO6-avc8v39s.js +10 -0
- package/dist/gallery/frame/chunks/railroadDiagram-AXF67PYL-qc61wax4.js +131 -0
- package/dist/gallery/frame/chunks/requirementDiagram-LRYGKXZP-9bs4786c.js +1295 -0
- package/dist/gallery/frame/chunks/sankeyDiagram-W5VNT64P-awt1em8b.js +1339 -0
- package/dist/gallery/frame/chunks/sequenceDiagram-SI44F4Z6-qaf9mz3t.js +4324 -0
- package/dist/gallery/frame/chunks/sizeCapture-X5ZJPWSS-9fq685xd.js +64 -0
- package/dist/gallery/frame/chunks/stateDiagram-OKZ733FA-vx7a7hh0.js +456 -0
- package/dist/gallery/frame/chunks/stateDiagram-v2-UEYNNEHI-x4m6ws5t.js +46 -0
- package/dist/gallery/frame/chunks/swimlanes-SLNWSIFB-e4mxm1gh.js +8287 -0
- package/dist/gallery/frame/chunks/swimlanesDiagram-ULZ7WXOC-0h93e2qq.js +42 -0
- package/dist/gallery/frame/chunks/timeline-definition-Z64GVDOM-wp0bhbzg.js +1549 -0
- package/dist/gallery/frame/chunks/treeView-QDETBFTQ-hcx7n0vt.js +10 -0
- package/dist/gallery/frame/chunks/treemap-6X3UGDF4-av02skdw.js +10 -0
- package/dist/gallery/frame/chunks/vennDiagram-T6HMQDX7-pmvk1p7n.js +2566 -0
- package/dist/gallery/frame/chunks/wardley-OPB4EBWU-njsp6e40.js +10 -0
- package/dist/gallery/frame/chunks/wardleyDiagram-T6FBY63Y-eggmnv4z.js +979 -0
- package/dist/gallery/frame/chunks/xychartDiagram-ELKLHX3M-q40tnpe5.js +1954 -0
- package/dist/gallery/frame/frame.css +183 -0
- package/dist/gallery/frame/runtime/chart.js +42744 -0
- package/dist/gallery/frame/runtime/html.js +14 -0
- package/dist/gallery/frame/runtime/markdown.js +1403 -0
- package/dist/gallery/frame/runtime/mermaid.js +32 -0
- package/dist/gallery/frame/runtime/svg.js +14 -0
- package/dist/gallery/frame/runtime/tsx.js +52 -0
- package/dist/gallery/index.html +104 -0
- package/docs/reference/cli.md +163 -0
- package/docs/reference/export.md +106 -0
- package/docs/reference/html.md +215 -0
- package/docs/reference/http.md +85 -0
- package/docs/reference/mcp.md +78 -0
- package/docs/reference/security.md +119 -0
- package/docs/reference/storage.md +93 -0
- package/docs/reference/tsx.md +101 -0
- package/docs/reference/validation.md +212 -0
- package/package.json +89 -0
- package/scripts/build-gallery.ts +127 -0
- package/scripts/launch-netns.sh +67 -0
- package/skills/facet/SKILL.md +100 -0
- package/src/cli/client.ts +311 -0
- package/src/cli/commands/create.ts +49 -0
- package/src/cli/commands/doctor.ts +249 -0
- package/src/cli/commands/export.ts +179 -0
- package/src/cli/commands/instantiate.ts +35 -0
- package/src/cli/commands/list.ts +49 -0
- package/src/cli/commands/open.ts +75 -0
- package/src/cli/commands/pin.ts +39 -0
- package/src/cli/commands/promote.ts +49 -0
- package/src/cli/commands/publish.ts +122 -0
- package/src/cli/commands/read-back.ts +56 -0
- package/src/cli/commands/status.ts +114 -0
- package/src/cli/commands/watch.ts +146 -0
- package/src/cli/main.ts +522 -0
- package/src/cli/output.ts +106 -0
- package/src/cli/parser.ts +376 -0
- package/src/cli/presenter.ts +212 -0
- package/src/cli/service-metadata.ts +134 -0
- package/src/cli/spawn-service.ts +326 -0
- package/src/css.d.ts +4 -0
- package/src/gallery-web/app.ts +1581 -0
- package/src/gallery-web/export-menu.ts +121 -0
- package/src/gallery-web/export.ts +112 -0
- package/src/gallery-web/favicon.ts +54 -0
- package/src/gallery-web/frame/entries/chart.ts +6 -0
- package/src/gallery-web/frame/entries/html.ts +6 -0
- package/src/gallery-web/frame/entries/markdown.ts +6 -0
- package/src/gallery-web/frame/entries/mermaid.ts +6 -0
- package/src/gallery-web/frame/entries/svg.ts +6 -0
- package/src/gallery-web/frame/entries/tsx.ts +6 -0
- package/src/gallery-web/frame/frame-payload.ts +91 -0
- package/src/gallery-web/frame/renderer-validation.ts +13 -0
- package/src/gallery-web/frame/renderers/chart.ts +182 -0
- package/src/gallery-web/frame/renderers/dompurify-shim.ts +112 -0
- package/src/gallery-web/frame/renderers/html.ts +48 -0
- package/src/gallery-web/frame/renderers/markdown.ts +81 -0
- package/src/gallery-web/frame/renderers/mermaid.ts +120 -0
- package/src/gallery-web/frame/renderers/registry.ts +260 -0
- package/src/gallery-web/frame/renderers/svg.ts +393 -0
- package/src/gallery-web/frame/renderers/tsx.ts +61 -0
- package/src/gallery-web/frame/runtime.ts +501 -0
- package/src/gallery-web/frame/styles/artifact.css +8424 -0
- package/src/gallery-web/frame/styles/frame.css +183 -0
- package/src/gallery-web/frame/styles/html-source.css +23 -0
- package/src/gallery-web/frame/view-box.ts +33 -0
- package/src/gallery-web/frame-html.ts +72 -0
- package/src/gallery-web/index.html +104 -0
- package/src/gallery-web/session.ts +107 -0
- package/src/gallery-web/sse-client.ts +212 -0
- package/src/gallery-web/styles/app.css +420 -0
- package/src/gallery-web/styles/tokens.css +112 -0
- package/src/gallery-web/styles/verdict.css +186 -0
- package/src/gallery-web/swap.ts +64 -0
- package/src/gallery-web/theme.ts +40 -0
- package/src/gallery-web/view-state.ts +93 -0
- package/src/harness-adapters/claude-code/README.md +10 -0
- package/src/harness-adapters/claude-code/facet.sh +4 -0
- package/src/harness-adapters/codex/README.md +10 -0
- package/src/harness-adapters/codex/facet.sh +4 -0
- package/src/harness-adapters/mcp/README.md +13 -0
- package/src/harness-adapters/mcp/cli-bridge.ts +216 -0
- package/src/harness-adapters/mcp/main.ts +15 -0
- package/src/harness-adapters/mcp/server.ts +164 -0
- package/src/harness-adapters/mcp/tool-schemas.ts +53 -0
- package/src/harness-adapters/opencode/README.md +10 -0
- package/src/harness-adapters/opencode/facet.sh +4 -0
- package/src/runtime/compiled-entrypoints.ts +69 -0
- package/src/service/dispatcher.ts +717 -0
- package/src/service/export.ts +218 -0
- package/src/service/http-utils.ts +55 -0
- package/src/service/lexical/expectations.ts +210 -0
- package/src/service/lifecycle/idle-controller.ts +125 -0
- package/src/service/lifecycle/orphan-cleanup.ts +128 -0
- package/src/service/lifecycle/process-lock.ts +235 -0
- package/src/service/main.ts +136 -0
- package/src/service/process.ts +240 -0
- package/src/service/router-guards.ts +239 -0
- package/src/service/router.ts +627 -0
- package/src/service/security/auth.ts +168 -0
- package/src/service/security/host-origin.ts +138 -0
- package/src/service/security/http-guards.ts +13 -0
- package/src/service/security/leases.ts +182 -0
- package/src/service/security/token-store.ts +138 -0
- package/src/service/server.ts +356 -0
- package/src/service/store/database.ts +61 -0
- package/src/service/store/evidence-retention.ts +234 -0
- package/src/service/store/migrations.ts +167 -0
- package/src/service/store/repository-lifecycle.ts +121 -0
- package/src/service/store/repository.ts +732 -0
- package/src/service/store/schema.ts +218 -0
- package/src/service/stored-verdict.ts +97 -0
- package/src/service/stream.ts +301 -0
- package/src/service/verdict-enrichment.ts +45 -0
- package/src/shared/build/frame-bundle-plugins.ts +61 -0
- package/src/shared/build-mode.ts +28 -0
- package/src/shared/config/evidence-read.ts +98 -0
- package/src/shared/config/limits.ts +56 -0
- package/src/shared/config/paths.ts +96 -0
- package/src/shared/contracts/artifact-types.ts +2 -0
- package/src/shared/contracts/artifact.ts +98 -0
- package/src/shared/contracts/commands/_shared.ts +59 -0
- package/src/shared/contracts/commands/guards.ts +86 -0
- package/src/shared/contracts/commands/index.ts +204 -0
- package/src/shared/contracts/commands/names.ts +35 -0
- package/src/shared/contracts/commands/requests.ts +149 -0
- package/src/shared/contracts/commands/results.ts +200 -0
- package/src/shared/contracts/envelope.ts +140 -0
- package/src/shared/contracts/events.ts +41 -0
- package/src/shared/contracts/observed-counts.ts +19 -0
- package/src/shared/contracts/renderers.ts +6 -0
- package/src/shared/contracts/validation.ts +296 -0
- package/src/shared/errors/facet-error.ts +127 -0
- package/src/shared/errors/store-error.ts +68 -0
- package/src/shared/evidence-image.ts +37 -0
- package/src/shared/export.ts +31 -0
- package/src/shared/html/policy.ts +108 -0
- package/src/shared/html/style-vocabulary.ts +352 -0
- package/src/shared/logging/logger.ts +108 -0
- package/src/shared/logging/redact.ts +49 -0
- package/src/shared/security/frozen-csp.ts +13 -0
- package/src/shared/storage-version.ts +2 -0
- package/src/shared/tsx/execution.ts +29 -0
- package/src/shared/tsx/import-policy.ts +94 -0
- package/src/shared/util/dir-permissions.ts +94 -0
- package/src/shared/util/mermaid-nodes.ts +230 -0
- package/src/shared/util/process.ts +67 -0
- package/src/shared/util/time.ts +21 -0
- package/src/shared/version.ts +3 -0
- package/src/validation/sandbox/limits.ts +41 -0
- package/src/validation/sandbox/netns.ts +177 -0
- package/src/validation/tier0/chart.ts +177 -0
- package/src/validation/tier0/dom-shim.ts +54 -0
- package/src/validation/tier0/html.ts +408 -0
- package/src/validation/tier0/markdown.ts +285 -0
- package/src/validation/tier0/mermaid.ts +98 -0
- package/src/validation/tier0/runner.ts +504 -0
- package/src/validation/tier0/svg.ts +321 -0
- package/src/validation/tier0/tsx/allowlist-resolver.ts +77 -0
- package/src/validation/tier0/tsx/ast-policy-capabilities.ts +238 -0
- package/src/validation/tier0/tsx/ast-policy-shared.ts +71 -0
- package/src/validation/tier0/tsx/ast-policy.ts +181 -0
- package/src/validation/tier0/tsx/compiler.ts +156 -0
- package/src/validation/tier0/worker-dispatch.ts +211 -0
- package/src/validation/tier0/worker-entry.ts +96 -0
- package/src/validation/tier0/worker-input.ts +75 -0
- package/src/validation/tier1/browser-process.ts +147 -0
- package/src/validation/tier1/cdp-pipe.ts +281 -0
- package/src/validation/tier1/entries/chart.ts +5 -0
- package/src/validation/tier1/entries/html.ts +6 -0
- package/src/validation/tier1/entries/markdown.ts +5 -0
- package/src/validation/tier1/entries/mermaid.ts +5 -0
- package/src/validation/tier1/entries/svg.ts +5 -0
- package/src/validation/tier1/entries/tsx.ts +5 -0
- package/src/validation/tier1/frame-target.ts +162 -0
- package/src/validation/tier1/harness-entry.ts +261 -0
- package/src/validation/tier1/harness.ts +215 -0
- package/src/validation/tier1/isolated-probe.ts +94 -0
- package/src/validation/tier1/launcher.ts +177 -0
- package/src/validation/tier1/limits.ts +80 -0
- package/src/validation/tier1/nonce.ts +17 -0
- package/src/validation/tier1/protocol-probe.ts +519 -0
- package/src/validation/tier1/runner.ts +1122 -0
- package/src/validation/tier1/verdict.ts +246 -0
- package/src/validation/tier1/webp.ts +69 -0
- package/templates/README.md +88 -0
- package/templates/bar-compare.vl.json +63 -0
- package/templates/capacity-report.tsx +102 -0
- package/templates/decision-record.md +24 -0
- package/templates/deployment-state.mmd +40 -0
- package/templates/exemplar.md +91 -0
- package/templates/facet-story.tsx +799 -0
- package/templates/fleet-dashboard.html +141 -0
- package/templates/html-release-ledger.html +91 -0
- package/templates/html-status-report.html +114 -0
- package/templates/incident-console.tsx +147 -0
- package/templates/legion-boundaries.mmd +23 -0
- package/templates/legion-flow.mmd +15 -0
- package/templates/legion-sequence.mmd +14 -0
- package/templates/legion-state.mmd +7 -0
- package/templates/metric-card.svg +29 -0
- package/templates/observability-map.svg +101 -0
- package/templates/pipeline-audit.md +108 -0
- package/templates/release-metrics.vl.json +98 -0
- package/templates/service-topology.mmd +47 -0
- package/templates/status-report.md +32 -0
- package/templates/system-map.svg +53 -0
- package/templates/timeseries.vl.json +71 -0
- package/templates/tsx-interactive-counter.tsx +19 -0
- 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
|
+
}
|