studiograph 1.3.48-next.262 → 1.3.48-next.263

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. package/dist/agent/skills/paged-documents/skill.md +22 -2
  2. package/dist/core/artifact-bundle.d.ts +69 -0
  3. package/dist/core/artifact-bundle.js +305 -0
  4. package/dist/core/artifact-bundle.js.map +1 -0
  5. package/dist/core/artifact-export.d.ts +83 -0
  6. package/dist/core/artifact-export.js +1567 -0
  7. package/dist/core/artifact-export.js.map +1 -0
  8. package/dist/core/artifact-frame-document.js +54 -19
  9. package/dist/core/artifact-frame-document.js.map +1 -1
  10. package/dist/core/artifact-frame-model.d.ts +42 -1
  11. package/dist/core/artifact-frame-model.js +102 -1
  12. package/dist/core/artifact-frame-model.js.map +1 -1
  13. package/dist/core/artifact-frame-selectors.d.ts +42 -0
  14. package/dist/core/artifact-frame-selectors.js +65 -0
  15. package/dist/core/artifact-frame-selectors.js.map +1 -0
  16. package/dist/core/artifact-paged-css.d.ts +64 -0
  17. package/dist/core/artifact-paged-css.js +253 -0
  18. package/dist/core/artifact-paged-css.js.map +1 -0
  19. package/dist/core/field-library.d.ts +62 -0
  20. package/dist/core/field-library.js +129 -0
  21. package/dist/core/field-library.js.map +1 -0
  22. package/dist/mcp/comment-virtual-entity.d.ts +36 -0
  23. package/dist/mcp/comment-virtual-entity.js +71 -0
  24. package/dist/mcp/comment-virtual-entity.js.map +1 -0
  25. package/dist/server/routes/chat-turn-outcome.d.ts +36 -0
  26. package/dist/server/routes/chat-turn-outcome.js +17 -0
  27. package/dist/server/routes/chat-turn-outcome.js.map +1 -0
  28. package/dist/server/routes/field-library-api.d.ts +28 -0
  29. package/dist/server/routes/field-library-api.js +126 -0
  30. package/dist/server/routes/field-library-api.js.map +1 -0
  31. package/dist/server/routes/import-batch.d.ts +24 -0
  32. package/dist/server/routes/import-batch.js +33 -0
  33. package/dist/server/routes/import-batch.js.map +1 -0
  34. package/dist/services/artifact-inspect-metadata.d.ts +21 -0
  35. package/dist/services/artifact-inspect-metadata.js +66 -0
  36. package/dist/services/artifact-inspect-metadata.js.map +1 -0
  37. package/dist/services/asset-upload-service.d.ts +56 -0
  38. package/dist/services/asset-upload-service.js +200 -0
  39. package/dist/services/asset-upload-service.js.map +1 -0
  40. package/dist/services/bookmark-import-service.d.ts +49 -0
  41. package/dist/services/bookmark-import-service.js +250 -0
  42. package/dist/services/bookmark-import-service.js.map +1 -0
  43. package/dist/web/_app/immutable/chunks/{DLFxzom2.js → -MYqXlc3.js} +1 -1
  44. package/dist/web/_app/immutable/chunks/{BBiZOxjN.js → 1Av0cjjM.js} +1 -1
  45. package/dist/web/_app/immutable/chunks/1_BWKAKA2.js +23 -0
  46. package/dist/web/_app/immutable/chunks/{CbnTIbQI2.js → 7C_XQ_V22.js} +56 -22
  47. package/dist/web/_app/immutable/chunks/{DE5MFphK.js → 7dxFJmtT.js} +1 -1
  48. package/dist/web/_app/immutable/chunks/B2cjR0rG.js +83 -0
  49. package/dist/web/_app/immutable/chunks/{CdvkVymz2.js → B3TQ2v3O.js} +1 -1
  50. package/dist/web/_app/immutable/chunks/{DxPoYrW02.js → BKJo_vcH2.js} +1 -1
  51. package/dist/web/_app/immutable/chunks/{gwOFYscJ2.js → BLjaRsry2.js} +1 -1
  52. package/dist/web/_app/immutable/chunks/{D74E7DtA.js → BWM7Fyds.js} +1 -1
  53. package/dist/web/_app/immutable/chunks/{HMuf07932.js → BZrDj1Yc2.js} +1 -1
  54. package/dist/web/_app/immutable/chunks/{BwZmyfEX.js → B_EIjYsb.js} +1 -1
  55. package/dist/web/_app/immutable/chunks/{DqGo4lyB2.js → BaVciYXp.js} +1 -1
  56. package/dist/web/_app/immutable/chunks/{Ca57r8F-.js → BkWTUZUm.js} +1 -1
  57. package/dist/web/_app/immutable/chunks/BnYohOts2.js +1 -0
  58. package/dist/web/_app/immutable/chunks/{CmPtL-Rn.js → BvyVm7-j.js} +1 -1
  59. package/dist/web/_app/immutable/chunks/BxeOdaUQ.js +1 -0
  60. package/dist/web/_app/immutable/chunks/{DHtwYMGu2.js → BzEpkiVx.js} +1 -1
  61. package/dist/web/_app/immutable/chunks/C6d9nn3k.js +1 -0
  62. package/dist/web/_app/immutable/chunks/CB-t1Bv-.js +1 -0
  63. package/dist/web/_app/immutable/chunks/{tvHn3XYh.js → CFQ_EH_P.js} +1 -1
  64. package/dist/web/_app/immutable/chunks/CKGHeJzq.js +1 -0
  65. package/dist/web/_app/immutable/chunks/CL_APTyv.js +1 -0
  66. package/dist/web/_app/immutable/chunks/{DzBvLWrB2.js → COK5qG3L2.js} +2 -2
  67. package/dist/web/_app/immutable/chunks/{DOTPES0g.js → CRwWneZ-.js} +1 -1
  68. package/dist/web/_app/immutable/chunks/{gIVpw0C1.js → CW2H4JII.js} +1 -1
  69. package/dist/web/_app/immutable/chunks/{w53PifJt.js → CWeviCpk.js} +1 -1
  70. package/dist/web/_app/immutable/chunks/{B4K-yj0D2.js → CXOqnNsj2.js} +1 -1
  71. package/dist/web/_app/immutable/chunks/CXPMb3zd.js +1 -0
  72. package/dist/web/_app/immutable/chunks/CX_WuP2f.js +1 -0
  73. package/dist/web/_app/immutable/chunks/CY5xLE5F.js +1 -0
  74. package/dist/web/_app/immutable/chunks/{Ryiz4ZhP.js → CeUT6lMf.js} +2 -2
  75. package/dist/web/_app/immutable/chunks/CgCz4Fj6.js +1 -0
  76. package/dist/web/_app/immutable/chunks/{DqGcR20a.js → CgmH6rPv.js} +3 -3
  77. package/dist/web/_app/immutable/chunks/{DtsNBsm82.js → CkOWeGhc2.js} +1 -1
  78. package/dist/web/_app/immutable/chunks/{CRANgTjw2.js → CkPu5VTd2.js} +1 -1
  79. package/dist/web/_app/immutable/chunks/ClSRVrsW.js +1 -0
  80. package/dist/web/_app/immutable/chunks/{G9YEWi42.js → Cmq7bKUh.js} +1 -1
  81. package/dist/web/_app/immutable/chunks/{B7WN6QF2.js → CnR_G-ce.js} +1 -1
  82. package/dist/web/_app/immutable/chunks/CzSx91mJ.js +2 -0
  83. package/dist/web/_app/immutable/chunks/D-a94Hdg.js +1 -0
  84. package/dist/web/_app/immutable/chunks/DAcMjPms.js +2 -0
  85. package/dist/web/_app/immutable/chunks/DBGaXAP8.js +1 -0
  86. package/dist/web/_app/immutable/chunks/{DwvPlTQh.js → DC2fTKy0.js} +1 -1
  87. package/dist/web/_app/immutable/chunks/{Cf7pPlrJ2.js → DDfqLzbz2.js} +2 -2
  88. package/dist/web/_app/immutable/chunks/{DnUROuxo.js → DFLuHkej.js} +1 -1
  89. package/dist/web/_app/immutable/chunks/{rSrKfaH92.js → DOZR4-gy.js} +1 -1
  90. package/dist/web/_app/immutable/chunks/DO_V9Cgc.js +1 -0
  91. package/dist/web/_app/immutable/chunks/{Mozc9LAu.js → DPI7dnke.js} +3 -3
  92. package/dist/web/_app/immutable/chunks/{HdbzGSgU.js → DVcmIzo2.js} +1 -1
  93. package/dist/web/_app/immutable/chunks/DZ2Zr5zb.js +1 -0
  94. package/dist/web/_app/immutable/chunks/{DJ61sY6B.js → DgP0Pxes.js} +1 -1
  95. package/dist/web/_app/immutable/chunks/{DrD4p8T22.js → DiGsqyRs.js} +1 -1
  96. package/dist/web/_app/immutable/chunks/DiiXREv7.js +1 -0
  97. package/dist/web/_app/immutable/chunks/{Cg5kjIaW.js → DmzyKw-k.js} +1 -1
  98. package/dist/web/_app/immutable/chunks/Dvj33Rn2.js +1 -0
  99. package/dist/web/_app/immutable/chunks/{B1pVemww.js → MDb5bFUr.js} +1 -1
  100. package/dist/web/_app/immutable/chunks/{BNq2--8a.js → Oglnp7sm2.js} +1 -1
  101. package/dist/web/_app/immutable/chunks/{D3ZdQV1G2.js → mYfi0ijf2.js} +1 -1
  102. package/dist/web/_app/immutable/chunks/{CSkL5wO6.js → nkOOikT2.js} +1 -1
  103. package/dist/web/_app/immutable/chunks/{Bncd_2iV2.js → qU6V8dVT2.js} +2 -2
  104. package/dist/web/_app/immutable/chunks/{3Jf1z4Hy2.js → qtwjrlYo2.js} +1 -1
  105. package/dist/web/_app/immutable/chunks/{CnpFxw1N2.js → vR5HiNz9.js} +1 -1
  106. package/dist/web/_app/immutable/chunks/{CB2VdVc2.js → xhwwouQZ.js} +1 -1
  107. package/dist/web/_app/immutable/chunks/{D_iZ9buk.js → yKBXs2Vl.js} +1 -1
  108. package/dist/web/_app/immutable/chunks/{BeMEksa_2.js → zMLQbXeR2.js} +1 -1
  109. package/dist/web/_app/immutable/entry/app.CRvuxoVJ.js +2 -0
  110. package/dist/web/_app/immutable/entry/start.CRSlQSAo.js +1 -0
  111. package/dist/web/_app/immutable/nodes/{0.GCsQhZXx.js → 0.DCtO9hJz.js} +2 -2
  112. package/dist/web/_app/immutable/nodes/{1.Bai1Q_j6.js → 1.CU0Xk0hX.js} +1 -1
  113. package/dist/web/_app/immutable/nodes/{10.Y-zXN2Lv.js → 10.BQ_I0U3h.js} +1 -1
  114. package/dist/web/_app/immutable/nodes/{11.BGLbrt6p.js → 11.D8W7J9oj.js} +1 -1
  115. package/dist/web/_app/immutable/nodes/{12.kkg6EDtl.js → 12.shu8hSiJ.js} +1 -1
  116. package/dist/web/_app/immutable/nodes/{13.DVLmp3Ll.js → 13.CqSie9pj.js} +1 -1
  117. package/dist/web/_app/immutable/nodes/{15.B6Aw4lhr.js → 15.CRTNtmaK.js} +1 -1
  118. package/dist/web/_app/immutable/nodes/{16.BvduImqP.js → 16.CLnfWtVJ.js} +1 -1
  119. package/dist/web/_app/immutable/nodes/{18.pwlJhpUE.js → 18.L6u3xca6.js} +1 -1
  120. package/dist/web/_app/immutable/nodes/{19.CC1kL3Df.js → 19.BfT4fpP2.js} +1 -1
  121. package/dist/web/_app/immutable/nodes/2.Cywkr3cW.js +124 -0
  122. package/dist/web/_app/immutable/nodes/{3.DBPrinvQ.js → 3.C5-yIMEM.js} +1 -1
  123. package/dist/web/_app/immutable/nodes/{4.A-1yULWB.js → 4.LWwxRkzM.js} +3 -3
  124. package/dist/web/_app/immutable/nodes/{5.CN9jkFSb.js → 5.CwRllvWS.js} +1 -1
  125. package/dist/web/_app/immutable/nodes/{6.B2TGmdUT.js → 6.BcR9vQIF.js} +1 -1
  126. package/dist/web/_app/immutable/nodes/{7.DQFIEPX4.js → 7.Bm0Ie3c3.js} +1 -1
  127. package/dist/web/_app/immutable/nodes/{8.CsTDHJ_M.js → 8.Dj0JJcTw.js} +1 -1
  128. package/dist/web/_app/immutable/nodes/{9.B9zlBMg4.js → 9.9MiRRKIu.js} +1 -1
  129. package/dist/web/_app/version.json +1 -1
  130. package/dist/web/index.html +18 -18
  131. package/package.json +1 -1
  132. package/dist/web/_app/immutable/chunks/2jM_FrdF.js +0 -1
  133. package/dist/web/_app/immutable/chunks/3e54b3S0.js +0 -1
  134. package/dist/web/_app/immutable/chunks/7pkBLE0o.js +0 -23
  135. package/dist/web/_app/immutable/chunks/AFZSy-Vk.js +0 -2
  136. package/dist/web/_app/immutable/chunks/B7P31uII.js +0 -1
  137. package/dist/web/_app/immutable/chunks/B7QhSEou.js +0 -1
  138. package/dist/web/_app/immutable/chunks/BH8Lm2ff.js +0 -1
  139. package/dist/web/_app/immutable/chunks/BLw5bIbe.js +0 -1
  140. package/dist/web/_app/immutable/chunks/BaEbafZv.js +0 -1
  141. package/dist/web/_app/immutable/chunks/BksI9pEP.js +0 -2
  142. package/dist/web/_app/immutable/chunks/Bo6vsMxZ.js +0 -1
  143. package/dist/web/_app/immutable/chunks/CGG26Xmx.js +0 -1
  144. package/dist/web/_app/immutable/chunks/C_vyTCDY.js +0 -1
  145. package/dist/web/_app/immutable/chunks/D2G8GgRX.js +0 -1
  146. package/dist/web/_app/immutable/chunks/D8nBN3-t.js +0 -1
  147. package/dist/web/_app/immutable/chunks/DGayKSDC.js +0 -1
  148. package/dist/web/_app/immutable/chunks/DIro3eQz.js +0 -1
  149. package/dist/web/_app/immutable/chunks/DWbRq2tL.js +0 -1
  150. package/dist/web/_app/immutable/chunks/Dm-W1U3E.js +0 -1
  151. package/dist/web/_app/immutable/chunks/DxQ5eC8d.js +0 -1
  152. package/dist/web/_app/immutable/chunks/nacCpTNj.js +0 -83
  153. package/dist/web/_app/immutable/entry/app.D9mnvkg5.js +0 -2
  154. package/dist/web/_app/immutable/entry/start.B1L019Jv.js +0 -1
  155. package/dist/web/_app/immutable/nodes/2.CvV_5gfw.js +0 -124
  156. /package/dist/web/_app/immutable/chunks/{B9KfEsQw.js → B9KfEsQw2.js} +0 -0
  157. /package/dist/web/_app/immutable/chunks/{BO6-nRV92.js → BO6-nRV9.js} +0 -0
  158. /package/dist/web/_app/immutable/chunks/{CcWaWbrI.js → CcWaWbrI2.js} +0 -0
  159. /package/dist/web/_app/immutable/chunks/{CsIWxmio.js → CsIWxmio2.js} +0 -0
  160. /package/dist/web/_app/immutable/chunks/{DQMWa2t72.js → DQMWa2t7.js} +0 -0
  161. /package/dist/web/_app/immutable/chunks/{MAYbL-xn.js → MAYbL-xn2.js} +0 -0
  162. /package/dist/web/_app/immutable/chunks/{MtN4vJa0.js → MtN4vJa02.js} +0 -0
@@ -0,0 +1,1567 @@
1
+ import { encodeBase64, escapeAttribute, escapeHtml, escapeScript, escapeStyle, } from './artifact-escape.js';
2
+ import { applyAssetResolution, artifactCsp } from './artifact-asset-resolution.js';
3
+ import { encodeAssetFilename, findArtifactAssetRefs } from './artifact-asset-urls.js';
4
+ import { FRAME_SELECTION_SELECTOR, FRAME_STEPPER_FALLBACK_SELECTOR, frameStepperSelector, frameStepperVisibilityCss, } from './artifact-frame-selectors.js';
5
+ /** The neutral gray the hosted app presents artifacts on. The web stage rule
6
+ * (.artifact-stage[data-view-mode='fullscreen'] in ArtifactEntityView.svelte)
7
+ * must use the same value — a source-sync test pins it. */
8
+ export const ARTIFACT_PRESENT_BACKDROP = '#4a4a4a';
9
+ /** Shared dead-band for content-size churn, in px. Single-sourced HERE (core)
10
+ * so the iframe runtime — which interpolates it into its injected reporter
11
+ * script below — and the web host's per-slot recorder (which re-exports this
12
+ * through artifact-view-state.ts) can never drift apart: a tuning change one
13
+ * side silently filtered out would ship as a no-op. */
14
+ export const CONTENT_SIZE_DEAD_BAND_PX = 8;
15
+ function stripNetworkAssetTags(html) {
16
+ return html
17
+ .replace(/<link\b(?=[^>]*\brel\s*=\s*(?:"[^"]*\bstylesheet\b[^"]*"|'[^']*\bstylesheet\b[^']*'|[^\s>]*stylesheet[^\s>]*))[^>]*>/gi, '')
18
+ .replace(/<script\b(?=[^>]*\bsrc\s*=)[^>]*>[\s\S]*?<\/script\s*>/gi, '');
19
+ }
20
+ function scriptTypeFromAttributes(attrs) {
21
+ const match = attrs.match(/\btype\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i);
22
+ return (match?.[1] ?? match?.[2] ?? match?.[3] ?? null)?.trim().toLowerCase() ?? null;
23
+ }
24
+ function executableScriptKind(attrs) {
25
+ const type = scriptTypeFromAttributes(attrs);
26
+ if (!type || type === 'text/javascript' || type === 'application/javascript' || type === 'text/ecmascript' || type === 'application/ecmascript') {
27
+ return 'classic';
28
+ }
29
+ return type === 'module' ? 'module' : null;
30
+ }
31
+ function scriptKindForPath(path) {
32
+ return path.toLowerCase().endsWith('.mjs') ? 'module' : 'classic';
33
+ }
34
+ function deferInlineScripts(html) {
35
+ const scripts = [];
36
+ const nextHtml = html.replace(/<script\b((?:(?!\bsrc\s*=)[^>])*)>([\s\S]*?)<\/script\s*>/gi, (match, attrs, js) => {
37
+ const kind = executableScriptKind(attrs);
38
+ if (!kind)
39
+ return match;
40
+ if (js.trim())
41
+ scripts.push({ path: `inline-${scripts.length + 1}.${kind === 'module' ? 'mjs' : 'js'}`, js, kind });
42
+ return '';
43
+ });
44
+ return { html: nextHtml, scripts };
45
+ }
46
+ function styleTags(bundle) {
47
+ return Object.entries(bundle.files)
48
+ .filter(([path]) => path.toLowerCase().endsWith('.css'))
49
+ .map(([path, css]) => `<style data-artifact-file="${escapeAttribute(path)}">\n${escapeStyle(css)}\n</style>`)
50
+ .join('\n');
51
+ }
52
+ function requestedLibraries(bundle) {
53
+ return bundle.libraries ?? [];
54
+ }
55
+ function libraryTags(bundle, sources = {}) {
56
+ const libraries = requestedLibraries(bundle);
57
+ if (libraries.length === 0)
58
+ return '';
59
+ const tags = [];
60
+ for (const library of libraries) {
61
+ if (library === 'd3' || library === 'motion') {
62
+ const source = sources[library];
63
+ if (!source)
64
+ continue;
65
+ tags.push(`<script data-artifact-library="${library}">\n${escapeScript(source)}\n</script>`);
66
+ }
67
+ }
68
+ const asyncInitializers = [];
69
+ if (libraries.includes('three') && sources.three) {
70
+ asyncInitializers.push(`
71
+ const decodeLibrarySource = (base64) => {
72
+ const binary = atob(base64);
73
+ const bytes = new Uint8Array(binary.length);
74
+ for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
75
+ return new TextDecoder().decode(bytes);
76
+ };
77
+ const threeSource = decodeLibrarySource(${JSON.stringify(encodeBase64(sources.three))});
78
+ const threeCoreSource = ${sources.threeCore ? `decodeLibrarySource(${JSON.stringify(encodeBase64(sources.threeCore))})` : "''"};
79
+ const threeCoreUrl = threeCoreSource
80
+ ? URL.createObjectURL(new Blob([threeCoreSource], { type: 'text/javascript' }))
81
+ : null;
82
+ const threeModuleSource = threeCoreUrl
83
+ ? threeSource.replace(/from\\s*(['"])\\.\\/three\\.core\\.min\\.js\\1/g, 'from ' + JSON.stringify(threeCoreUrl))
84
+ : threeSource;
85
+ const threeUrl = URL.createObjectURL(new Blob([threeModuleSource], { type: 'text/javascript' }));
86
+ try {
87
+ window.THREE = await import(threeUrl);
88
+ } finally {
89
+ URL.revokeObjectURL(threeUrl);
90
+ if (threeCoreUrl) URL.revokeObjectURL(threeCoreUrl);
91
+ }
92
+ `);
93
+ }
94
+ tags.push(`<script data-artifact-libraries-ready>
95
+ window.__STUDIOGRAPH_ARTIFACT_LIBRARIES__ = ${JSON.stringify(libraries)};
96
+ window.__STUDIOGRAPH_ARTIFACT_LIBRARIES_READY__ = (async () => {
97
+ ${asyncInitializers.join('\n')}
98
+ return true;
99
+ })().catch((error) => {
100
+ console.error('Artifact library load failed', error);
101
+ throw error;
102
+ });
103
+ </script>`);
104
+ return tags.join('\n');
105
+ }
106
+ function scriptTags(bundle, inlineScripts = []) {
107
+ const jsEntries = Object.entries(bundle.files)
108
+ .filter(([path]) => /\.(m?js)$/i.test(path))
109
+ .map(([path, js]) => ({ path, js, kind: scriptKindForPath(path) }));
110
+ if (requestedLibraries(bundle).length > 0) {
111
+ const payload = JSON.stringify([...inlineScripts, ...jsEntries]);
112
+ return `<script data-artifact-user-scripts>
113
+ (async () => {
114
+ await (window.__STUDIOGRAPH_ARTIFACT_LIBRARIES_READY__ || Promise.resolve());
115
+ const scripts = ${escapeScript(payload)};
116
+ const scriptUrls = [];
117
+ for (const script of scripts) {
118
+ await new Promise((resolve, reject) => {
119
+ const url = URL.createObjectURL(new Blob([
120
+ String(script.js) + '\\n//# sourceURL=studiograph-artifact://' + String(script.path)
121
+ ], { type: 'text/javascript' }));
122
+ scriptUrls.push(url);
123
+ if (script.kind === 'module') {
124
+ import(url).then(resolve, reject);
125
+ return;
126
+ }
127
+ const element = document.createElement('script');
128
+ element.src = url;
129
+ element.async = false;
130
+ element.dataset.artifactFile = String(script.path);
131
+ element.onload = () => resolve();
132
+ element.onerror = () => reject(new Error('Could not load artifact script: ' + String(script.path)));
133
+ document.head.appendChild(element);
134
+ });
135
+ }
136
+ if (document.readyState !== 'loading') {
137
+ try { document.dispatchEvent(new Event('DOMContentLoaded', { bubbles: true, cancelable: true })); } catch {}
138
+ }
139
+ if (document.readyState === 'complete') {
140
+ try { window.dispatchEvent(new Event('load')); } catch {}
141
+ }
142
+ setTimeout(() => {
143
+ for (const url of scriptUrls) URL.revokeObjectURL(url);
144
+ }, 0);
145
+ })().catch((error) => {
146
+ console.error('Artifact script error', error);
147
+ throw error;
148
+ });
149
+ </script>`;
150
+ }
151
+ return jsEntries
152
+ .map(({ path, js, kind }) => `<script${kind === 'module' ? ' type="module"' : ''} data-artifact-file="${escapeAttribute(path)}">\n${escapeScript(js)}\n</script>`)
153
+ .join('\n');
154
+ }
155
+ function getStepperControl(bundle) {
156
+ return bundle.display?.controls?.stepper ?? null;
157
+ }
158
+ const FONT_REF_RE = /\.(?:woff2?|ttf|otf)$/i;
159
+ const FONT_PRELOAD_MAX = 12;
160
+ /** `<link rel="preload" as="font">` tags for every workspace font the bundle
161
+ * references (iframe host only — the in-app/share document). Without these the
162
+ * browser only starts fetching a font after stylesheets apply and text lays
163
+ * out — on a cold load that fetch loses the race against the FOUT guard's
164
+ * settle ceiling and the swap still flashes. Emitted with the canonical
165
+ * `/api/assets/...` form; the finalize pass rewrites the hrefs through the
166
+ * SAME AssetResolution as every other asset URL (token `?at=` / share path),
167
+ * so preloads can never disagree with the CSP. `crossorigin` is required for
168
+ * font preloads to match the CSS font fetch mode (the sandbox's opaque origin
169
+ * makes all loads CORS). */
170
+ function fontPreloadTags(bundle, resolution) {
171
+ if (!resolution || resolution.mode === 'none')
172
+ return '';
173
+ // Token mode with the mint still in flight: every preload would go out
174
+ // un-tokenized and 401 (surfacing as opaque-origin CORS noise in the
175
+ // console). The host defers building until the mint settles, but if a
176
+ // tokenless build happens anyway (mint failed → proceed tokenless), the
177
+ // CSS @font-face fetches degrade gracefully — guaranteed-dead preloads
178
+ // must simply never be emitted.
179
+ if (resolution.mode === 'token' && !resolution.token)
180
+ return '';
181
+ // Relative (export) mode resolves fonts to bundled ./assets paths — no
182
+ // preload tag needed (and a preload of an unbundled placeholder would 404).
183
+ if (resolution.mode === 'relative')
184
+ return '';
185
+ const text = Object.values(bundle.files).filter((v) => typeof v === 'string').join('\n');
186
+ return findArtifactAssetRefs(text)
187
+ .filter((ref) => FONT_REF_RE.test(ref.filename))
188
+ .slice(0, FONT_PRELOAD_MAX)
189
+ .map((ref) => `<link rel="preload" as="font" crossorigin href="/api/assets/${ref.assetId}/${encodeAssetFilename(ref.filename)}">`)
190
+ .join('');
191
+ }
192
+ // ---------------------------------------------------------------------------
193
+ // Unified artifact runtime (eng review D2). ONE script, both host modes.
194
+ //
195
+ // The frame stepper — frame detection, active-frame attribution, keyboard nav,
196
+ // click-to-step — is the part that historically drifted between the in-app and
197
+ // the standalone-export runtimes (the `.slide` selector bug). It now lives in
198
+ // these three SHARED fragments, emitted verbatim into BOTH host bodies, so the
199
+ // two can never disagree about which elements are frames. Each host body
200
+ // supplies the small hooks the shared fragments call:
201
+ // - `send(payload)` postMessage to the host (iframe) / no-op (none)
202
+ // - `sendFrameState()` report the active frame to the host / no-op
203
+ // - `sendActivity()` report user activity to the host / no-op
204
+ // - `applyFrameFit()` self-fit the active frame to the viewport (none) /
205
+ // no-op (iframe — the host owns scaling)
206
+ // - `stepperEnabled` gate (runtime expr in iframe, literal true in none)
207
+ // - `interactiveSelector` elements a click must NOT hijack for stepping
208
+ // ---------------------------------------------------------------------------
209
+ /** Stepper detection + active-frame state. Depends on the host-supplied
210
+ * `stepperEnabled`, `send`, `sendFrameState`, `applyFrameFit`. */
211
+ const STEPPER_SETUP_JS = `
212
+ const stepperSelector = typeof stepperControl?.selector === 'string' && stepperControl.selector.trim() ? stepperControl.selector.trim() : ${JSON.stringify(FRAME_STEPPER_FALLBACK_SELECTOR)};
213
+ const stepperLabel = typeof stepperControl?.label === 'string' && stepperControl.label.trim() ? stepperControl.label.trim().slice(0, 40) : 'Frame';
214
+ let activeFrameIndex = 0;
215
+ let lastActivityAt = 0;
216
+ const frames = () => {
217
+ if (!stepperEnabled) return [];
218
+ try { return Array.from(document.querySelectorAll(stepperSelector)).filter((node) => node instanceof HTMLElement); }
219
+ catch { return []; }
220
+ };
221
+ const sendFrameState = () => {
222
+ const allFrames = frames();
223
+ if (!stepperEnabled || allFrames.length === 0) return;
224
+ send({ type: 'frame-state', index: activeFrameIndex, count: allFrames.length, label: stepperLabel });
225
+ };
226
+ const setActiveFrame = (index) => {
227
+ const allFrames = frames();
228
+ if (!stepperEnabled || allFrames.length === 0) return;
229
+ activeFrameIndex = Math.max(0, Math.min(allFrames.length - 1, Number.isFinite(index) ? Math.round(index) : 0));
230
+ allFrames.forEach((frame, frameIndex) => {
231
+ const active = frameIndex === activeFrameIndex;
232
+ frame.classList.toggle('active', active);
233
+ frame.toggleAttribute('data-studiograph-active-frame', active);
234
+ frame.setAttribute('aria-hidden', active ? 'false' : 'true');
235
+ });
236
+ try { history.replaceState(null, '', '#' + String(activeFrameIndex + 1)); } catch {}
237
+ applyFrameFit();
238
+ sendFrameState();
239
+ };
240
+ const initializeStepper = () => {
241
+ if (!stepperEnabled) return;
242
+ const allFrames = frames();
243
+ if (allFrames.length === 0) return;
244
+ const activeIndex = allFrames.findIndex((frame) => frame.classList.contains('active') || frame.hasAttribute('data-studiograph-active-frame'));
245
+ const hashIndex = /^#\\d+$/.test(location.hash) ? Number(location.hash.slice(1)) - 1 : -1;
246
+ setActiveFrame(hashIndex >= 0 ? hashIndex : activeIndex >= 0 ? activeIndex : 0);
247
+ };
248
+ const stepFrame = (direction) => {
249
+ const allFrames = frames();
250
+ if (allFrames.length === 0) return;
251
+ setActiveFrame((activeFrameIndex + direction + allFrames.length) % allFrames.length);
252
+ };
253
+ const sendActivity = () => {
254
+ if (!stepperEnabled) return;
255
+ const now = Date.now();
256
+ if (now - lastActivityAt < 150) return;
257
+ lastActivityAt = now;
258
+ send({ type: 'activity' });
259
+ };`;
260
+ /** Keyboard frame navigation (arrows / page / space / home / end / 1–9). */
261
+ const STEPPER_KEYBOARD_JS = `
262
+ window.addEventListener('keydown', (event) => {
263
+ if (!stepperEnabled) return;
264
+ sendActivity();
265
+ if (event.altKey || event.ctrlKey || event.metaKey) return;
266
+ if (event.key === 'ArrowRight' || event.key === 'PageDown' || event.key === ' ') { stepFrame(1); event.preventDefault(); return; }
267
+ if (event.key === 'ArrowLeft' || event.key === 'PageUp' || event.key === 'Backspace') { stepFrame(-1); event.preventDefault(); return; }
268
+ if (event.key === 'Home') { setActiveFrame(0); event.preventDefault(); return; }
269
+ if (event.key === 'End') { setActiveFrame(frames().length - 1); event.preventDefault(); return; }
270
+ if (/^[1-9]$/.test(event.key)) { setActiveFrame(Number(event.key) - 1); event.preventDefault(); }
271
+ });`;
272
+ /** Click-to-step: left fifth steps back, the rest forward — unless the click
273
+ * landed on something interactive. Returns true when it consumed the click. */
274
+ const STEPPER_CLICK_JS = `
275
+ const handleStepperClick = (event) => {
276
+ if (!stepperEnabled) return false;
277
+ if (event.target instanceof Element && event.target.closest(interactiveSelector + ',[data-artifact-no-stepper]')) return false;
278
+ const allFrames = frames();
279
+ if (allFrames.length <= 1) return false;
280
+ stepFrame(event.clientX < window.innerWidth * 0.2 ? -1 : 1);
281
+ event.preventDefault();
282
+ return true;
283
+ };`;
284
+ const INTERACTIVE_SELECTOR_JS = `const interactiveSelector = 'a,button,input,textarea,select,summary,[contenteditable="true"],[role="button"],[data-artifact-no-pan]';`;
285
+ /** The full interactive runtime for a hosted iframe render: postMessage state,
286
+ * pan/zoom, element selection, content-size reporting, the FOUT guard, plus
287
+ * the shared stepper. Behaviour is gated at runtime on the baked
288
+ * `artifactContext` so a single emitted script serves editor + fullscreen. */
289
+ function iframeRuntimeScript(artifactId, context, tool, stepperControl, viewportMode) {
290
+ return `
291
+ (() => {
292
+ const artifactId = ${JSON.stringify(artifactId)};
293
+ const artifactContext = ${JSON.stringify(context)};
294
+ let artifactTool = ${JSON.stringify(tool)};
295
+ const stepperControl = ${JSON.stringify(stepperControl)};
296
+ const artifactViewportMode = ${JSON.stringify(viewportMode)};
297
+ window.__STUDIOGRAPH_ARTIFACT_CONTEXT__ = artifactContext;
298
+ window.__STUDIOGRAPH_ARTIFACT_TOOL__ = artifactTool;
299
+ document.documentElement.dataset.artifactContext = artifactContext;
300
+ document.documentElement.dataset.artifactTool = artifactTool;
301
+ if (stepperControl) document.documentElement.dataset.artifactStepper = 'true';
302
+ // FOUT guard (fullscreen/present only): @font-face with font-display:swap
303
+ // paints fallback text first, then swaps — a visible flash on the share
304
+ // page. The EDITOR canvas never shows it because the host holds the reveal
305
+ // for the settled report (which waits for document.fonts.ready); fullscreen
306
+ // has no host hold, so hold INSIDE the sandbox: keep the document invisible
307
+ // until fonts settle after load, with hard ceilings so a slow or broken
308
+ // font can only ever delay the reveal briefly (blank-on-backdrop beats
309
+ // wrong-typeface-then-jump). visibility (not display) keeps layout running
310
+ // so font loads actually start while hidden.
311
+ if (artifactContext === 'fullscreen' && document.fonts && typeof document.fonts.ready?.then === 'function') {
312
+ const holdStyle = document.createElement('style');
313
+ holdStyle.textContent = 'html { visibility: hidden !important; }';
314
+ document.head.appendChild(holdStyle);
315
+ let revealed = false;
316
+ const reveal = () => {
317
+ if (revealed) return;
318
+ revealed = true;
319
+ holdStyle.remove();
320
+ };
321
+ // Absolute ceiling — reveal no matter what (load never firing, a hung
322
+ // font request, an exotic fonts.ready rejection).
323
+ setTimeout(reveal, 4000);
324
+ const settle = () => {
325
+ // After load the stylesheets are applied and text is laid out, so the
326
+ // @font-face loads are in flight and fonts.ready is meaningful (called
327
+ // earlier it can resolve before any font load has started). The head
328
+ // carries rel=preload for every referenced font, so cold-load fetches
329
+ // started at parse time — this race usually resolves via fonts.ready.
330
+ Promise.race([
331
+ document.fonts.ready,
332
+ new Promise((resolve) => setTimeout(resolve, 2500)),
333
+ ]).then(reveal, reveal);
334
+ };
335
+ if (document.readyState === 'complete') settle();
336
+ else window.addEventListener('load', settle, { once: true });
337
+ }
338
+ let panPointerId = null;
339
+ const selectedElements = new Map();
340
+ const send = (payload) => {
341
+ try { parent.postMessage({ channel: 'studiograph-artifact', artifactId, ...payload }, '*'); } catch {}
342
+ };
343
+ let pendingPanMove = null;
344
+ let pendingPanMoveFrame = null;
345
+ let pendingPanDelta = null;
346
+ let pendingPanDeltaFrame = null;
347
+ let pendingZoomDelta = null;
348
+ let pendingZoomDeltaFrame = null;
349
+ const flushPanMove = () => {
350
+ if (pendingPanMoveFrame !== null) {
351
+ cancelAnimationFrame(pendingPanMoveFrame);
352
+ pendingPanMoveFrame = null;
353
+ }
354
+ if (!pendingPanMove) return;
355
+ send(pendingPanMove);
356
+ pendingPanMove = null;
357
+ };
358
+ const queuePanMove = (payload) => {
359
+ pendingPanMove = payload;
360
+ if (pendingPanMoveFrame !== null) return;
361
+ pendingPanMoveFrame = requestAnimationFrame(() => {
362
+ pendingPanMoveFrame = null;
363
+ if (!pendingPanMove) return;
364
+ send(pendingPanMove);
365
+ pendingPanMove = null;
366
+ });
367
+ };
368
+ const queuePanDelta = (dx, dy) => {
369
+ pendingPanDelta = pendingPanDelta
370
+ ? { dx: pendingPanDelta.dx + dx, dy: pendingPanDelta.dy + dy }
371
+ : { dx, dy };
372
+ if (pendingPanDeltaFrame !== null) return;
373
+ pendingPanDeltaFrame = requestAnimationFrame(() => {
374
+ pendingPanDeltaFrame = null;
375
+ if (!pendingPanDelta) return;
376
+ const delta = pendingPanDelta;
377
+ pendingPanDelta = null;
378
+ send({ type: 'pan-delta', dx: delta.dx, dy: delta.dy });
379
+ });
380
+ };
381
+ const queueZoomDelta = (delta, x, y) => {
382
+ pendingZoomDelta = pendingZoomDelta
383
+ ? { delta: pendingZoomDelta.delta + delta, x, y }
384
+ : { delta, x, y };
385
+ if (pendingZoomDeltaFrame !== null) return;
386
+ pendingZoomDeltaFrame = requestAnimationFrame(() => {
387
+ pendingZoomDeltaFrame = null;
388
+ if (!pendingZoomDelta) return;
389
+ const zoomDelta = pendingZoomDelta;
390
+ pendingZoomDelta = null;
391
+ send({ type: 'zoom-delta', delta: zoomDelta.delta, x: zoomDelta.x, y: zoomDelta.y });
392
+ });
393
+ };
394
+ const setArtifactTool = (nextTool) => {
395
+ if (nextTool !== 'pointer' && nextTool !== 'hand') return;
396
+ artifactTool = nextTool;
397
+ window.__STUDIOGRAPH_ARTIFACT_TOOL__ = artifactTool;
398
+ document.documentElement.dataset.artifactTool = artifactTool;
399
+ if (artifactTool !== 'hand') {
400
+ panPointerId = null;
401
+ flushPanMove();
402
+ document.documentElement.classList.remove('studiograph-artifact-panning');
403
+ }
404
+ };
405
+ window.addEventListener('error', (event) => {
406
+ send({ status: 'error', message: String(event.message || 'Artifact error') });
407
+ });
408
+ window.addEventListener('unhandledrejection', (event) => {
409
+ send({ status: 'error', message: String(event.reason?.message || event.reason || 'Artifact promise rejection') });
410
+ });
411
+ window.addEventListener('load', () => {
412
+ send({ status: 'ready' });
413
+ initializeStepper();
414
+ startContentSizeReporting();
415
+ scheduleFinalContentSizeReport();
416
+ });
417
+ // Content-size reporting — editor context, fixed-viewport bundles only (the
418
+ // host's measured-geometry path is scoped the same way; responsive bundles
419
+ // just echo whatever size they're given, so reports would be noise). The
420
+ // host sizes the editor iframe and overview zoom from what actually
421
+ // rendered, not the declared viewport (a deck's editor contact sheet
422
+ // renders far larger than it declares). The FIRST report goes out at
423
+ // DOMContentLoaded — the host holds the initial reveal for it, and waiting
424
+ // for window 'load' would hold a blank canvas behind slow images whose
425
+ // layout extent is already known. Layout that settles later (fonts, images
426
+ // without declared dimensions, script-built content) follows through the
427
+ // debounced ResizeObserver. Sub-dead-band wiggle is dropped here (the host
428
+ // applies the same shared dead-band).
429
+ let lastContentSize = null;
430
+ let contentSizeTimer = null;
431
+ let contentSizeReportingStarted = false;
432
+ // Single gate for BOTH the streaming reporter and the settled (final)
433
+ // report — widening one without the other would arm the ResizeObserver
434
+ // loop while never sending the final marker the host's reveal hold waits
435
+ // for (every open would ride the full ceiling).
436
+ const contentSizeReportingEnabled = artifactContext === 'editor' && ${JSON.stringify('fixed')} === artifactViewportMode;
437
+ const measureContentSize = () => ({
438
+ width: Math.max(document.documentElement.scrollWidth, document.body?.scrollWidth || 0),
439
+ height: Math.max(document.documentElement.scrollHeight, document.body?.scrollHeight || 0),
440
+ });
441
+ const reportContentSize = () => {
442
+ const size = measureContentSize();
443
+ if (!(size.width > 0) || !(size.height > 0)) return;
444
+ if (lastContentSize && Math.abs(size.width - lastContentSize.width) < ${CONTENT_SIZE_DEAD_BAND_PX} && Math.abs(size.height - lastContentSize.height) < ${CONTENT_SIZE_DEAD_BAND_PX}) return;
445
+ lastContentSize = size;
446
+ send({ type: 'content-size', width: size.width, height: size.height });
447
+ };
448
+ const queueContentSizeReport = () => {
449
+ if (contentSizeTimer) clearTimeout(contentSizeTimer);
450
+ contentSizeTimer = setTimeout(reportContentSize, 150);
451
+ };
452
+ // The once-per-render SETTLED report. Early reports (DOMContentLoaded, RO)
453
+ // can undershoot — images finish and web fonts swap AFTER them, growing the
454
+ // layout. The host holds the initial reveal for this marker so the first
455
+ // visible paint is already at the final size; it goes out after window
456
+ // 'load' + fonts.ready + a quiet beat, unconditionally (the dead-band must
457
+ // not swallow the marker even when the size already matches).
458
+ let finalContentSizeReported = false;
459
+ const reportFinalContentSize = () => {
460
+ if (finalContentSizeReported) return;
461
+ const size = measureContentSize();
462
+ if (!(size.width > 0) || !(size.height > 0)) return;
463
+ finalContentSizeReported = true;
464
+ lastContentSize = size;
465
+ send({ type: 'content-size', width: size.width, height: size.height, final: true });
466
+ };
467
+ const scheduleFinalContentSizeReport = () => {
468
+ if (!contentSizeReportingEnabled) return;
469
+ const settle = () => setTimeout(reportFinalContentSize, 180);
470
+ try {
471
+ const fontsReady = document.fonts?.ready;
472
+ if (fontsReady && typeof fontsReady.then === 'function') fontsReady.then(settle, settle);
473
+ else settle();
474
+ } catch { settle(); }
475
+ };
476
+ const startContentSizeReporting = () => {
477
+ if (contentSizeReportingStarted) return;
478
+ if (!contentSizeReportingEnabled) return;
479
+ contentSizeReportingStarted = true;
480
+ reportContentSize();
481
+ try {
482
+ const observer = new ResizeObserver(queueContentSizeReport);
483
+ observer.observe(document.documentElement);
484
+ if (document.body) observer.observe(document.body);
485
+ } catch {}
486
+ try { document.fonts?.ready?.then(queueContentSizeReport); } catch {}
487
+ };
488
+ if (document.readyState === 'loading') {
489
+ document.addEventListener('DOMContentLoaded', () => startContentSizeReporting());
490
+ } else {
491
+ startContentSizeReporting();
492
+ }
493
+ const stepperEnabled = artifactContext === 'fullscreen' && !!stepperControl;
494
+ // The host owns scaling in an iframe render — the runtime never self-fits.
495
+ const applyFrameFit = () => {};
496
+ ${STEPPER_SETUP_JS}
497
+ ${INTERACTIVE_SELECTOR_JS}
498
+ ${STEPPER_CLICK_JS}
499
+ ${STEPPER_KEYBOARD_JS}
500
+ window.addEventListener('message', (event) => {
501
+ const data = event.data;
502
+ if (!data || typeof data !== 'object') return;
503
+ if (data.channel !== 'studiograph-artifact-host' || data.artifactId !== artifactId) return;
504
+ if (data.type === 'set-tool') setArtifactTool(data.tool);
505
+ if (data.type === 'clear-selection') clearSelection();
506
+ if (data.type === 'set-frame') setActiveFrame(Number(data.index));
507
+ if (data.type === 'reset-frames') setActiveFrame(0);
508
+ if (data.type === 'request-frame-state') sendFrameState();
509
+ });
510
+ window.addEventListener('pointermove', sendActivity, { passive: true });
511
+ const canPanFrom = (target) => {
512
+ if (artifactContext !== 'editor' || artifactTool !== 'hand') return false;
513
+ if (!(target instanceof Element)) return false;
514
+ if (target.closest('[data-artifact-no-pan-zone]')) return false;
515
+ return true;
516
+ };
517
+ const canWheelPanFrom = (target) => {
518
+ if (artifactContext !== 'editor') return false;
519
+ if (!(target instanceof Element)) return false;
520
+ if (target.closest('[data-artifact-no-pan-zone]')) return false;
521
+ return true;
522
+ };
523
+ const sendPan = (phase, event) => {
524
+ if (phase === 'start') document.documentElement.classList.add('studiograph-artifact-panning');
525
+ if (phase === 'end' || phase === 'cancel') document.documentElement.classList.remove('studiograph-artifact-panning');
526
+ const payload = {
527
+ type: 'pan',
528
+ phase,
529
+ pointerId: String(event.pointerId),
530
+ x: event.screenX,
531
+ y: event.screenY
532
+ };
533
+ if (phase === 'move') {
534
+ queuePanMove(payload);
535
+ return;
536
+ }
537
+ if (phase === 'end' || phase === 'cancel') flushPanMove();
538
+ send(payload);
539
+ };
540
+ const normalizeWheelDelta = (event) => {
541
+ const unit = event.deltaMode === WheelEvent.DOM_DELTA_LINE ? 16 : event.deltaMode === WheelEvent.DOM_DELTA_PAGE ? window.innerHeight : 1;
542
+ return { dx: event.deltaX * unit, dy: event.deltaY * unit };
543
+ };
544
+ const safeText = (value, limit = 160) => String(value || '').replace(/\\s+/g, ' ').trim().slice(0, limit);
545
+ const artifactFrameSelector = ${JSON.stringify(FRAME_SELECTION_SELECTOR)};
546
+ const artifactScaffoldSelector = '[data-artifact-layout],[data-artifact-pan-surface],[data-artifact-scaffold],[data-artifact-container]';
547
+ const artifactNoSelectSelector = '[data-artifact-no-select]';
548
+ const rootRenderableSelector = artifactFrameSelector + ',svg,canvas,img,video,[data-artifact-selectable]';
549
+ const selectableCandidateSelector = artifactFrameSelector + ',h1,h2,h3,h4,h5,h6,p,li,td,th,blockquote,figure,figcaption,label,section,article,main,svg,path,g,rect,circle,text,a,button,[role="button"],div,span';
550
+ const cssEscape = (value) => {
551
+ if (window.CSS?.escape) return window.CSS.escape(value);
552
+ return String(value).replace(/[^a-zA-Z0-9_-]/g, '\\\\$&');
553
+ };
554
+ const selectorFor = (element) => {
555
+ if (!(element instanceof Element)) return '';
556
+ if (element.id) return '#' + cssEscape(element.id);
557
+ const parts = [];
558
+ let node = element;
559
+ while (node && node.nodeType === 1 && parts.length < 5) {
560
+ let part = node.tagName.toLowerCase();
561
+ const artifactIdAttr = node.getAttribute('data-artifact-id') || node.getAttribute('data-id');
562
+ if (artifactIdAttr) {
563
+ part += '[data-artifact-id="' + artifactIdAttr.replace(/"/g, '\\\\"') + '"]';
564
+ parts.unshift(part);
565
+ break;
566
+ }
567
+ const classes = Array.from(node.classList || []).slice(0, 2);
568
+ if (classes.length) part += '.' + classes.map(cssEscape).join('.');
569
+ const parent = node.parentElement;
570
+ if (parent) {
571
+ const siblings = Array.from(parent.children).filter((child) => child.tagName === node.tagName);
572
+ if (siblings.length > 1) part += ':nth-of-type(' + (siblings.indexOf(node) + 1) + ')';
573
+ }
574
+ parts.unshift(part);
575
+ node = parent;
576
+ if (node === document.body || node === document.documentElement) break;
577
+ }
578
+ return parts.join(' > ');
579
+ };
580
+ const selectionKeyFor = (target) => [target.frameId || '', target.selector || '', target.text || ''].join('::');
581
+ const frameFor = (element) => element.closest?.(artifactFrameSelector) || null;
582
+ const frameDescriptorFor = (element) => {
583
+ const frame = frameFor(element);
584
+ if (!(frame instanceof Element)) return {};
585
+ let frameIndex = -1;
586
+ try { frameIndex = Array.from(document.querySelectorAll(artifactFrameSelector)).indexOf(frame); } catch {}
587
+ const frameName = safeText(
588
+ frame.getAttribute('data-sg-name')
589
+ || frame.getAttribute('data-title')
590
+ || frame.getAttribute('aria-label')
591
+ || frame.getAttribute('title')
592
+ || ''
593
+ , 80);
594
+ const frameStableId = safeText(
595
+ frame.getAttribute('data-sg-id')
596
+ || frame.getAttribute('data-artifact-id')
597
+ || frame.id
598
+ || frame.getAttribute('data-index')
599
+ || ''
600
+ , 80);
601
+ return {
602
+ frameId: frameStableId || (frameIndex >= 0 ? 'frame-' + String(frameIndex + 1) : undefined),
603
+ frameIndex: frameIndex >= 0 ? frameIndex : undefined,
604
+ frameName: frameName || undefined
605
+ };
606
+ };
607
+ const targetDescriptorFor = (element, event) => {
608
+ const rect = element.getBoundingClientRect();
609
+ const selector = selectorFor(element);
610
+ const name = safeText(
611
+ element.getAttribute('data-sg-name')
612
+ || element.getAttribute('aria-label')
613
+ || element.getAttribute('title')
614
+ || ''
615
+ , 80);
616
+ const role = safeText(
617
+ element.getAttribute('data-sg-role')
618
+ || element.getAttribute('role')
619
+ || ''
620
+ , 80);
621
+ const stableId = safeText(
622
+ element.getAttribute('data-sg-id')
623
+ || element.getAttribute('data-artifact-id')
624
+ || element.getAttribute('data-id')
625
+ || element.id
626
+ || ''
627
+ , 80);
628
+ const target = {
629
+ ...frameDescriptorFor(element),
630
+ targetId: '',
631
+ selector,
632
+ tagName: element.tagName.toLowerCase(),
633
+ id: element.id || '',
634
+ className: typeof element.className === 'string' ? element.className : '',
635
+ name: name || undefined,
636
+ role: role || undefined,
637
+ text: safeText(element.textContent),
638
+ x: event.clientX,
639
+ y: event.clientY,
640
+ rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height }
641
+ };
642
+ target.targetId = stableId || selectionKeyFor(target);
643
+ return target;
644
+ };
645
+ const publishSelection = () => {
646
+ const targets = Array.from(selectedElements.values()).map((entry) => entry.target);
647
+ send({
648
+ type: 'selection-change',
649
+ primaryTargetId: targets[targets.length - 1]?.targetId || '',
650
+ targets
651
+ });
652
+ if (targets.length === 0) send({ type: 'clear-selection' });
653
+ };
654
+ const clearSelection = (publish = true) => {
655
+ for (const entry of selectedElements.values()) {
656
+ entry.element?.removeAttribute?.('data-studiograph-selected-target');
657
+ }
658
+ selectedElements.clear();
659
+ if (publish) publishSelection();
660
+ };
661
+ const isArtifactFrameElement = (element) => element.matches(artifactFrameSelector);
662
+ const isAdditiveSelectionEvent = (event) => !!(event.shiftKey || event.metaKey || event.ctrlKey);
663
+ const isArtifactScaffold = (element) => {
664
+ if (!(element instanceof Element)) return false;
665
+ if (element.matches('[data-artifact-selectable]')) return false;
666
+ if (element === document.body || element === document.documentElement) return true;
667
+ if (isArtifactFrameElement(element)) return false;
668
+ if (element.matches(artifactScaffoldSelector)) return true;
669
+ if (element.querySelector(artifactFrameSelector)) return true;
670
+ if (element.parentElement === document.body && element.children.length > 0 && !element.matches(rootRenderableSelector)) return true;
671
+ const rect = element.getBoundingClientRect();
672
+ const bodyRect = document.body?.getBoundingClientRect?.();
673
+ const bodyWidth = Math.max(document.documentElement.clientWidth || 0, document.body?.scrollWidth || 0, bodyRect?.width || 0);
674
+ const bodyHeight = Math.max(document.documentElement.clientHeight || 0, document.body?.scrollHeight || 0, bodyRect?.height || 0);
675
+ return element.children.length > 0
676
+ && !element.matches(rootRenderableSelector)
677
+ && rect.width >= bodyWidth - 2
678
+ && rect.height >= bodyHeight - 2;
679
+ };
680
+ const selectionElementFor = (target, event) => {
681
+ if (!(target instanceof Element)) return null;
682
+ const explicitSelectable = target.closest('[data-artifact-selectable]');
683
+ const explicitNoSelect = target.closest(artifactNoSelectSelector);
684
+ if (explicitNoSelect && !explicitSelectable) return null;
685
+ const element = explicitSelectable || target.closest(selectableCandidateSelector) || target;
686
+ if (!(element instanceof Element)) return null;
687
+ if (!explicitSelectable && isArtifactScaffold(element)) return null;
688
+ return element;
689
+ };
690
+ const shouldSuppressNativeModifierSelection = (event) => {
691
+ if (artifactContext !== 'editor' || artifactTool !== 'pointer') return false;
692
+ if (!isAdditiveSelectionEvent(event)) return false;
693
+ if (event.button !== undefined && event.button !== 0) return false;
694
+ return selectionElementFor(event.target, event) instanceof Element;
695
+ };
696
+ const selectElement = (target, event) => {
697
+ if (artifactContext !== 'editor' || artifactTool !== 'pointer') return;
698
+ if (!(target instanceof Element)) return;
699
+ const element = selectionElementFor(target, event);
700
+ if (!(element instanceof Element)) {
701
+ clearSelection();
702
+ return;
703
+ }
704
+ const targetDescriptor = targetDescriptorFor(element, event);
705
+ const key = targetDescriptor.targetId;
706
+ const additive = isAdditiveSelectionEvent(event);
707
+ if (!additive) clearSelection(false);
708
+ if (additive && selectedElements.has(key)) {
709
+ selectedElements.get(key)?.element?.removeAttribute?.('data-studiograph-selected-target');
710
+ selectedElements.delete(key);
711
+ } else {
712
+ element.setAttribute('data-studiograph-selected-target', 'true');
713
+ selectedElements.set(key, { element, target: targetDescriptor });
714
+ }
715
+ publishSelection();
716
+ if (additive) event.preventDefault();
717
+ };
718
+ window.addEventListener('keydown', (event) => {
719
+ if (artifactContext !== 'editor') return;
720
+ if (event.key !== 'Escape' || selectedElements.size === 0) return;
721
+ clearSelection();
722
+ event.preventDefault();
723
+ }, { capture: true });
724
+ // Prototypes frequently contain links (href="#cart", "checkout.html", external URLs)
725
+ // that, inside this sandboxed srcdoc frame, navigate the frame to a nonexistent target
726
+ // and leave a blank canvas. Handle them gracefully: scroll to in-page anchors when they
727
+ // exist, and otherwise swallow the navigation so the prototype stays put. JS-driven
728
+ // links (javascript: / onclick handlers) still run — we only cancel the default nav.
729
+ const handleArtifactLinkClick = (event) => {
730
+ if (event.defaultPrevented) return false;
731
+ if (event.button !== undefined && event.button !== 0) return false;
732
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return false;
733
+ const anchor = event.target instanceof Element ? event.target.closest('a[href]') : null;
734
+ if (!anchor) return false;
735
+ const href = anchor.getAttribute('href') || '';
736
+ if (!href || /^javascript:/i.test(href)) return false;
737
+ if (href.startsWith('#')) {
738
+ // In-page anchor: scroll to it when present, no-op when it dangles. Never navigate.
739
+ event.preventDefault();
740
+ const id = href.slice(1);
741
+ if (id) {
742
+ let dest = null;
743
+ try { dest = document.getElementById(id) || document.querySelector('a[name="' + cssEscape(id) + '"]'); } catch {}
744
+ dest?.scrollIntoView?.({ block: 'start', behavior: 'smooth' });
745
+ }
746
+ return true;
747
+ }
748
+ // Any other navigation (relative path, external URL, mailto, etc.) would blank the
749
+ // sandboxed frame — swallow it rather than leave the user on an empty canvas.
750
+ event.preventDefault();
751
+ return true;
752
+ };
753
+ window.addEventListener('click', (event) => {
754
+ // In the editor, still allow the click to select the link element; just don't navigate.
755
+ if (handleArtifactLinkClick(event) && artifactContext !== 'editor') return;
756
+ if (stepperEnabled) {
757
+ if (handleStepperClick(event)) return;
758
+ if (event.target instanceof Element && event.target.closest(interactiveSelector + ',[data-artifact-no-stepper]')) return;
759
+ }
760
+ selectElement(event.target, event);
761
+ }, { capture: true });
762
+ window.addEventListener('mousedown', (event) => {
763
+ if (!shouldSuppressNativeModifierSelection(event)) return;
764
+ event.preventDefault();
765
+ }, { capture: true });
766
+ window.addEventListener('wheel', (event) => {
767
+ if (!canWheelPanFrom(event.target)) return;
768
+ const delta = normalizeWheelDelta(event);
769
+ if (Math.abs(delta.dx) < 0.01 && Math.abs(delta.dy) < 0.01) return;
770
+ if (event.ctrlKey || event.metaKey) {
771
+ queueZoomDelta(delta.dy, event.clientX, event.clientY);
772
+ event.preventDefault();
773
+ return;
774
+ }
775
+ // In the editor the canvas is a pan/zoom surface (this handler only runs in
776
+ // editor context — see canWheelPanFrom): a trackpad/wheel gesture pans the
777
+ // whole canvas, it never scrolls a container the artifact happens to sit in.
778
+ // An artifact that genuinely needs an internal scroll region marks it with
779
+ // [data-artifact-no-pan-zone] (canWheelPanFrom returns false there, so the
780
+ // wheel falls through to native scrolling).
781
+ queuePanDelta(delta.dx, delta.dy);
782
+ event.preventDefault();
783
+ }, { capture: true, passive: false });
784
+ window.addEventListener('pointerdown', (event) => {
785
+ if (event.button !== 0 || !canPanFrom(event.target)) return;
786
+ panPointerId = event.pointerId;
787
+ try { event.target.setPointerCapture?.(event.pointerId); } catch {}
788
+ sendPan('start', event);
789
+ event.preventDefault();
790
+ }, { capture: true });
791
+ window.addEventListener('pointermove', (event) => {
792
+ if (panPointerId !== event.pointerId) return;
793
+ sendPan('move', event);
794
+ event.preventDefault();
795
+ }, { capture: true });
796
+ const endPan = (event) => {
797
+ if (panPointerId !== event.pointerId) return;
798
+ sendPan(event.type === 'pointercancel' ? 'cancel' : 'end', event);
799
+ panPointerId = null;
800
+ };
801
+ window.addEventListener('pointerup', endPan, { capture: true });
802
+ window.addEventListener('pointercancel', endPan, { capture: true });
803
+ })();`;
804
+ }
805
+ /** The minimal self-contained runtime for a hostless render (downloaded HTML).
806
+ * No host to scale or chrome the frame, so it owns step-frames + self-fit and
807
+ * nothing else — capability-frozen at today's export parity. Only emitted for
808
+ * a stepped fullscreen export; a paged reader / plain document needs no JS. */
809
+ function standaloneRuntimeScript(stepperControl) {
810
+ return `
811
+ (() => {
812
+ const stepperControl = ${JSON.stringify(stepperControl)};
813
+ const stepperEnabled = true;
814
+ // No host: the runtime scales the active frame to fit the viewport (contain,
815
+ // centered) so a fixed-size slide (e.g. 1920x1080) isn't clipped by the
816
+ // overflow:hidden container on a smaller window. Gated to the fullscreen
817
+ // context so the export/PDF render keeps frames native-size.
818
+ function fitActiveFrame() {
819
+ if (document.documentElement.dataset.artifactContext !== 'fullscreen') return;
820
+ const active = frames()[activeFrameIndex];
821
+ if (!active) return;
822
+ active.style.setProperty('transform', 'none', 'important');
823
+ const fw = active.offsetWidth || 1;
824
+ const fh = active.offsetHeight || 1;
825
+ const ww = window.innerWidth || fw;
826
+ const wh = window.innerHeight || fh;
827
+ const scale = Math.min(ww / fw, wh / fh);
828
+ const left = Math.max(0, (ww - fw * scale) / 2);
829
+ const top = Math.max(0, (wh - fh * scale) / 2);
830
+ active.style.setProperty('transform-origin', 'top left', 'important');
831
+ active.style.setProperty('transform', 'translate(' + left + 'px, ' + top + 'px) scale(' + scale + ')', 'important');
832
+ }
833
+ // send (and the shared fragment's sendFrameState/sendActivity that call it)
834
+ // post to a host; there is none here, so a no-op send makes them inert.
835
+ const send = () => {};
836
+ const applyFrameFit = fitActiveFrame;
837
+ ${STEPPER_SETUP_JS}
838
+ ${INTERACTIVE_SELECTOR_JS}
839
+ ${STEPPER_CLICK_JS}
840
+ ${STEPPER_KEYBOARD_JS}
841
+ window.addEventListener('click', (event) => { handleStepperClick(event); }, { capture: true });
842
+ window.addEventListener('resize', fitActiveFrame);
843
+ // Print-to-PDF (Cmd+P / Save as PDF on the downloaded file): the on-screen
844
+ // self-fit writes an inline transform: translate(...) scale(...) !important
845
+ // on the active frame, sized to the WINDOW. Inline !important beats any print
846
+ // stylesheet, so that screen transform would carry into print and push the
847
+ // frame off the page (a blank PDF). Clear every frame's inline transform
848
+ // before printing — the @media print rules then lay each frame out one per
849
+ // page at native size — and restore the screen fit afterward.
850
+ const clearFitTransforms = () => { for (const frame of frames()) frame.style.removeProperty('transform'); };
851
+ window.addEventListener('beforeprint', clearFitTransforms);
852
+ window.addEventListener('afterprint', fitActiveFrame);
853
+ try {
854
+ const printMedia = window.matchMedia('print');
855
+ const onPrintChange = (event) => { if (event.matches) clearFitTransforms(); else fitActiveFrame(); };
856
+ if (printMedia.addEventListener) printMedia.addEventListener('change', onPrintChange);
857
+ else if (printMedia.addListener) printMedia.addListener(onPrintChange);
858
+ } catch {}
859
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', initializeStepper, { once: true });
860
+ else initializeStepper();
861
+ })();`;
862
+ }
863
+ function addArtifactContextToHtmlTag(html, context, tool, hasStepper) {
864
+ if (!/<html[\s>]/i.test(html))
865
+ return html;
866
+ return html.replace(/<html\b([^>]*)>/i, (match, attrs) => {
867
+ let nextAttrs = attrs;
868
+ if (!/\bdata-artifact-context\s*=/i.test(nextAttrs))
869
+ nextAttrs = ` data-artifact-context="${context}"${nextAttrs}`;
870
+ if (!/\bdata-artifact-tool\s*=/i.test(nextAttrs))
871
+ nextAttrs = ` data-artifact-tool="${tool}"${nextAttrs}`;
872
+ if (hasStepper && !/\bdata-artifact-stepper\s*=/i.test(nextAttrs))
873
+ nextAttrs = ` data-artifact-stepper="true"${nextAttrs}`;
874
+ return `<html${nextAttrs}>`;
875
+ });
876
+ }
877
+ function addAttributesToHtmlTag(html, attrs) {
878
+ if (!/<html[\s>]/i.test(html))
879
+ return html;
880
+ return html.replace(/<html\b([^>]*)>/i, (match, existingAttrs) => {
881
+ let nextAttrs = existingAttrs;
882
+ for (const [name, value] of Object.entries(attrs)) {
883
+ if (value === undefined || value === false)
884
+ continue;
885
+ if (new RegExp(`\\b${name}\\s*=`, 'i').test(nextAttrs))
886
+ continue;
887
+ nextAttrs = ` ${name}${value === true ? '' : `="${escapeAttribute(value)}"`}${nextAttrs}`;
888
+ }
889
+ return `<html${nextAttrs}>`;
890
+ });
891
+ }
892
+ // ---------------------------------------------------------------------------
893
+ // Host styles. The fullscreen/present + paged blocks are shared between the
894
+ // in-app (iframe) and the export (none) host-style packages so the two can't
895
+ // drift on which elements letterbox / step. The surface-specific pieces
896
+ // (editor cursor for iframe; export-frame flattening + print pagination +
897
+ // in-document present backdrop for none) stay where they belong.
898
+ // ---------------------------------------------------------------------------
899
+ function stepperFullscreenCss(bundle) {
900
+ if (!getStepperControl(bundle))
901
+ return '';
902
+ return `
903
+ html[data-artifact-context="fullscreen"][data-artifact-stepper="true"],
904
+ html[data-artifact-context="fullscreen"][data-artifact-stepper="true"] body {
905
+ width: 100% !important;
906
+ height: 100% !important;
907
+ min-height: 100% !important;
908
+ overflow: hidden !important;
909
+ }
910
+ html[data-artifact-context="fullscreen"][data-artifact-stepper="true"] .artifact-sequence,
911
+ html[data-artifact-context="fullscreen"][data-artifact-stepper="true"] .deck,
912
+ html[data-artifact-context="fullscreen"][data-artifact-stepper="true"] [data-artifact-layout="sequence"] {
913
+ width: 100% !important;
914
+ height: 100% !important;
915
+ min-height: 100% !important;
916
+ padding: 0 !important;
917
+ margin: 0 !important;
918
+ display: block !important;
919
+ position: relative !important;
920
+ overflow: hidden !important;
921
+ background: transparent !important;
922
+ box-shadow: none !important;
923
+ }
924
+ ${frameStepperVisibilityCss(frameStepperSelector(getStepperControl(bundle)?.selector), 'html[data-artifact-context="fullscreen"][data-artifact-stepper="true"]')}
925
+ `;
926
+ }
927
+ // `pinHtmlHeight`: the in-app (iframe) present pins html/body to the host stage
928
+ // height (the stage sizes the iframe, so 100% fills it). The hostless export
929
+ // must NOT — a downloaded paged document has no host stage, and a fixed
930
+ // `height: 100%` body turns the page column into an internal scroll container
931
+ // (and would print only the first viewport when the file is printed to PDF).
932
+ // It keeps `min-height: 100%` so the document grows to natural content height
933
+ // and the WINDOW scrolls — exactly the pre-unification per-surface behavior.
934
+ function pagedFullscreenCss(bundle, pinHtmlHeight) {
935
+ if (bundle.viewport.mode !== 'paged')
936
+ return '';
937
+ return `
938
+ html[data-artifact-context="fullscreen"],
939
+ html[data-artifact-context="fullscreen"] body {
940
+ width: 100% !important;
941
+ ${pinHtmlHeight ? ' height: 100% !important;\n' : ''} min-height: 100% !important;
942
+ overflow: auto !important;
943
+ }
944
+ html[data-artifact-context="fullscreen"] .pages,
945
+ html[data-artifact-context="fullscreen"] .document-board,
946
+ html[data-artifact-context="fullscreen"] .artifact-board[data-artifact-layout="paged"],
947
+ html[data-artifact-context="fullscreen"] [data-artifact-layout="paged"] {
948
+ width: 100% !important;
949
+ height: auto !important;
950
+ min-height: 100% !important;
951
+ padding: 48px 0 !important;
952
+ display: flex !important;
953
+ flex-direction: column !important;
954
+ align-items: center !important;
955
+ justify-content: flex-start !important;
956
+ align-content: center !important;
957
+ gap: 32px !important;
958
+ background: transparent !important;
959
+ box-shadow: none !important;
960
+ overflow: visible !important;
961
+ }
962
+ html[data-artifact-context="fullscreen"] .page,
963
+ html[data-artifact-context="fullscreen"] [data-artifact-page] {
964
+ position: relative !important;
965
+ left: auto !important;
966
+ right: auto !important;
967
+ top: auto !important;
968
+ bottom: auto !important;
969
+ transform: none !important;
970
+ }
971
+ `;
972
+ }
973
+ /** Host styles for the in-app (iframe) render: transparent background so the
974
+ * host stage shows through, the editor hand/pointer cursors, plus the shared
975
+ * fullscreen stepper / paged reader CSS. */
976
+ // No `tool` param: the hand/grab cursor CSS keys entirely off the runtime-set
977
+ // `[data-artifact-tool="hand"]` attribute (the runtime flips it live on tool
978
+ // change), never off a build-time tool value — so build-time tool can't affect
979
+ // these styles.
980
+ function inAppHostStyleTags(bundle, context) {
981
+ const stepperFullscreen = context === 'fullscreen' ? stepperFullscreenCss(bundle) : '';
982
+ const editorCursor = context === 'editor'
983
+ ? `
984
+ html[data-artifact-context="editor"][data-artifact-tool="hand"],
985
+ html[data-artifact-context="editor"][data-artifact-tool="hand"] body,
986
+ html[data-artifact-context="editor"][data-artifact-tool="hand"] body * {
987
+ cursor: grab !important;
988
+ }
989
+ html[data-artifact-context="editor"][data-artifact-tool="hand"].studiograph-artifact-panning,
990
+ html[data-artifact-context="editor"][data-artifact-tool="hand"].studiograph-artifact-panning body,
991
+ html[data-artifact-context="editor"][data-artifact-tool="hand"].studiograph-artifact-panning body * {
992
+ cursor: grabbing !important;
993
+ }
994
+ html[data-artifact-context="editor"][data-artifact-tool="hand"] a,
995
+ html[data-artifact-context="editor"][data-artifact-tool="hand"] button,
996
+ html[data-artifact-context="editor"][data-artifact-tool="hand"] summary,
997
+ html[data-artifact-context="editor"][data-artifact-tool="hand"] [role="button"] {
998
+ cursor: pointer !important;
999
+ }
1000
+ html[data-artifact-context="editor"][data-artifact-tool="hand"] input,
1001
+ html[data-artifact-context="editor"][data-artifact-tool="hand"] textarea,
1002
+ html[data-artifact-context="editor"][data-artifact-tool="hand"] select,
1003
+ html[data-artifact-context="editor"][data-artifact-tool="hand"] [contenteditable="true"],
1004
+ html[data-artifact-context="editor"][data-artifact-tool="hand"] [data-artifact-no-pan] {
1005
+ cursor: auto !important;
1006
+ }
1007
+ `
1008
+ : '';
1009
+ const pagedFullscreen = context === 'fullscreen' ? pagedFullscreenCss(bundle, true) : '';
1010
+ return `<style data-artifact-host>
1011
+ html, body {
1012
+ background: transparent !important;
1013
+ }
1014
+ .document-board,
1015
+ .artifact-board[data-artifact-layout="paged"] {
1016
+ background: transparent !important;
1017
+ }
1018
+ ${editorCursor}
1019
+ ${pagedFullscreen}
1020
+ ${stepperFullscreen}
1021
+ </style>`;
1022
+ }
1023
+ function artifactRenderHostStyleTags(bundle) {
1024
+ const viewport = bundle.display?.fullscreenViewport ?? bundle.viewport;
1025
+ const frameWidth = `${Math.max(1, Math.round(viewport.width))}px`;
1026
+ const frameHeight = `${Math.max(1, Math.round(viewport.height))}px`;
1027
+ const authoredPageSizing = bundle.viewport.mode === 'paged';
1028
+ const exportFrameSizing = authoredPageSizing
1029
+ ? ' contain: layout paint !important;'
1030
+ : ` width: var(--studiograph-export-frame-width) !important;
1031
+ height: var(--studiograph-export-frame-height) !important;
1032
+ min-width: var(--studiograph-export-frame-width) !important;
1033
+ min-height: var(--studiograph-export-frame-height) !important;
1034
+ max-width: var(--studiograph-export-frame-width) !important;
1035
+ max-height: var(--studiograph-export-frame-height) !important;
1036
+ contain: layout paint size !important;`;
1037
+ const exportFlattening = `
1038
+ html[data-artifact-context="export"] {
1039
+ --studiograph-export-frame-width: ${frameWidth};
1040
+ --studiograph-export-frame-height: ${frameHeight};
1041
+ }
1042
+ html[data-artifact-context="export"],
1043
+ html[data-artifact-context="export"] body {
1044
+ width: 100% !important;
1045
+ min-height: 100% !important;
1046
+ height: auto !important;
1047
+ overflow: visible !important;
1048
+ }
1049
+ html[data-artifact-context="export"] .artifact-sequence,
1050
+ html[data-artifact-context="export"] .deck,
1051
+ html[data-artifact-context="export"] .pages,
1052
+ html[data-artifact-context="export"] .document-board,
1053
+ html[data-artifact-context="export"] .artifact-board,
1054
+ html[data-artifact-context="export"] [data-artifact-layout="sequence"],
1055
+ html[data-artifact-context="export"] [data-artifact-layout="paged"] {
1056
+ width: 100% !important;
1057
+ height: auto !important;
1058
+ min-height: 0 !important;
1059
+ padding: 0 !important;
1060
+ margin: 0 !important;
1061
+ display: flex !important;
1062
+ flex-direction: column !important;
1063
+ align-items: center !important;
1064
+ justify-content: flex-start !important;
1065
+ gap: 0 !important;
1066
+ overflow: visible !important;
1067
+ background: transparent !important;
1068
+ box-shadow: none !important;
1069
+ }
1070
+ html[data-artifact-context="export"] [data-artifact-frame],
1071
+ html[data-artifact-context="export"] [data-artifact-page],
1072
+ html[data-artifact-context="export"] .page {
1073
+ display: block !important;
1074
+ position: relative !important;
1075
+ box-sizing: border-box !important;
1076
+ ${exportFrameSizing}
1077
+ isolation: isolate !important;
1078
+ clip-path: inset(0) !important;
1079
+ inset: auto !important;
1080
+ left: auto !important;
1081
+ top: auto !important;
1082
+ right: auto !important;
1083
+ bottom: auto !important;
1084
+ transform: none !important;
1085
+ margin: 0 !important;
1086
+ flex: none !important;
1087
+ overflow: hidden !important;
1088
+ }
1089
+ `;
1090
+ // The hosted app presents artifacts on a neutral gray stage
1091
+ // (.artifact-stage[data-view-mode='fullscreen'] in ArtifactEntityView.svelte
1092
+ // — ARTIFACT_PRESENT_BACKDROP, keep in sync). The exported standalone file
1093
+ // has no host stage, so the document itself carries the backdrop — but ONLY
1094
+ // where the standalone runtime letterboxes content around a stage (stepper
1095
+ // present mode, paged reader). A plain fixed/responsive document fills the
1096
+ // window with its own page, and forcing its body transparent would strip an
1097
+ // AUTHORED background the in-app render keeps. `display.background:
1098
+ // 'transparent'` opts out, matching the in-app rule.
1099
+ const backdropScope = bundle.display?.controls?.stepper
1100
+ ? 'html[data-artifact-context="fullscreen"][data-artifact-stepper="true"]'
1101
+ : bundle.viewport.mode === 'paged'
1102
+ ? 'html[data-artifact-context="fullscreen"]'
1103
+ : null;
1104
+ const presentBackdrop = backdropScope && bundle.display?.background !== 'transparent'
1105
+ ? `
1106
+ ${backdropScope} {
1107
+ background: ${ARTIFACT_PRESENT_BACKDROP} !important;
1108
+ }
1109
+ ${backdropScope} body {
1110
+ background: transparent !important;
1111
+ }
1112
+ `
1113
+ : '';
1114
+ const stepperFullscreen = stepperFullscreenCss(bundle);
1115
+ const pagedFullscreen = pagedFullscreenCss(bundle, false);
1116
+ const printPagination = `
1117
+ @media print {
1118
+ html[data-artifact-context="fullscreen"],
1119
+ html[data-artifact-context="fullscreen"] body,
1120
+ html[data-artifact-context="export"],
1121
+ html[data-artifact-context="export"] body {
1122
+ height: auto !important;
1123
+ min-height: 0 !important;
1124
+ overflow: visible !important;
1125
+ /* The on-screen present backdrop must never print as gray pages. */
1126
+ background: transparent !important;
1127
+ }
1128
+ html[data-artifact-context="fullscreen"] .artifact-sequence,
1129
+ html[data-artifact-context="fullscreen"] .deck,
1130
+ html[data-artifact-context="fullscreen"] .pages,
1131
+ html[data-artifact-context="fullscreen"] .document-board,
1132
+ html[data-artifact-context="fullscreen"] [data-artifact-layout="sequence"],
1133
+ html[data-artifact-context="fullscreen"] [data-artifact-layout="paged"],
1134
+ html[data-artifact-context="export"] .artifact-sequence,
1135
+ html[data-artifact-context="export"] .deck,
1136
+ html[data-artifact-context="export"] .pages,
1137
+ html[data-artifact-context="export"] .document-board,
1138
+ html[data-artifact-context="export"] .artifact-board,
1139
+ html[data-artifact-context="export"] [data-artifact-layout="sequence"],
1140
+ html[data-artifact-context="export"] [data-artifact-layout="paged"] {
1141
+ display: block !important;
1142
+ width: 100% !important;
1143
+ height: auto !important;
1144
+ min-height: 0 !important;
1145
+ padding: 0 !important;
1146
+ margin: 0 !important;
1147
+ overflow: visible !important;
1148
+ background: transparent !important;
1149
+ box-shadow: none !important;
1150
+ }
1151
+ html[data-artifact-context="fullscreen"] [data-artifact-frame],
1152
+ html[data-artifact-context="fullscreen"] [data-artifact-page],
1153
+ html[data-artifact-context="fullscreen"] .page,
1154
+ html[data-artifact-context="export"] [data-artifact-frame],
1155
+ html[data-artifact-context="export"] [data-artifact-page],
1156
+ html[data-artifact-context="export"] .page {
1157
+ display: block !important;
1158
+ position: relative !important;
1159
+ box-sizing: border-box !important;
1160
+ ${exportFrameSizing}
1161
+ isolation: isolate !important;
1162
+ clip-path: inset(0) !important;
1163
+ inset: auto !important;
1164
+ left: auto !important;
1165
+ top: auto !important;
1166
+ right: auto !important;
1167
+ bottom: auto !important;
1168
+ transform: none !important;
1169
+ margin: 0 !important;
1170
+ overflow: hidden !important;
1171
+ break-after: page;
1172
+ page-break-after: always;
1173
+ }
1174
+ html[data-artifact-context="fullscreen"] [data-artifact-frame]:not(:first-child),
1175
+ html[data-artifact-context="fullscreen"] [data-artifact-page]:not(:first-child),
1176
+ html[data-artifact-context="fullscreen"] .page:not(:first-child),
1177
+ html[data-artifact-context="export"] [data-artifact-frame]:not(:first-child),
1178
+ html[data-artifact-context="export"] [data-artifact-page]:not(:first-child),
1179
+ html[data-artifact-context="export"] .page:not(:first-child) {
1180
+ break-before: page;
1181
+ page-break-before: always;
1182
+ }
1183
+ html[data-artifact-context="fullscreen"] [data-artifact-frame]:last-child,
1184
+ html[data-artifact-context="fullscreen"] [data-artifact-page]:last-child,
1185
+ html[data-artifact-context="fullscreen"] .page:last-child,
1186
+ html[data-artifact-context="export"] [data-artifact-frame]:last-child,
1187
+ html[data-artifact-context="export"] [data-artifact-page]:last-child,
1188
+ html[data-artifact-context="export"] .page:last-child {
1189
+ break-after: auto;
1190
+ page-break-after: auto;
1191
+ }
1192
+ }
1193
+ `;
1194
+ // Letterboxed exports (a stepped deck/prototype, a paged document) print each
1195
+ // frame as its own page at native size. `preferCSSPageSize:false` in the
1196
+ // headless Playwright PDF path ignores this, so it only governs a browser
1197
+ // Cmd+P / Save-as-PDF of the downloaded file — exactly the path it fixes.
1198
+ const printPageSize = (getStepperControl(bundle) || bundle.viewport.mode === 'paged')
1199
+ ? `\n@media print {\n @page { size: ${frameWidth} ${frameHeight}; margin: 0; }\n html[data-artifact-context="fullscreen"] {\n --studiograph-export-frame-width: ${frameWidth};\n --studiograph-export-frame-height: ${frameHeight};\n }\n}\n`
1200
+ : '';
1201
+ // The on-screen present chrome (hide-inactive-frames, letterbox/self-fit
1202
+ // scaffolding, gray backdrop) is `@media screen` ONLY. Without this the
1203
+ // stepper hide rule out-specifies the print "show every frame" rule and only
1204
+ // the active frame would survive a print — and even it lands off-page from
1205
+ // the runtime's inline self-fit transform. Confining the present chrome to
1206
+ // screen lets the print rules below lay out all frames cleanly, one per page.
1207
+ return `<style data-artifact-export-host>
1208
+ html, body {
1209
+ margin: 0;
1210
+ padding: 0;
1211
+ }
1212
+ @media screen {
1213
+ ${presentBackdrop}
1214
+ ${pagedFullscreen}
1215
+ ${stepperFullscreen}
1216
+ }
1217
+ ${exportFlattening}
1218
+ ${printPagination}
1219
+ ${printPageSize}
1220
+ </style>`;
1221
+ }
1222
+ function headAdditions(bundle, entry, options) {
1223
+ // The CSP is derived from the SAME AssetResolution that rewrites the asset
1224
+ // URLs (finalize step), so this document can't carry URLs from one asset
1225
+ // model and a policy from another. `base-uri` opens to exactly the
1226
+ // baseHref's origin when the headless render injects a <base> tag.
1227
+ // Safe unescaped: every dynamic CSP part is validated upstream (origins are
1228
+ // URL-parsed, share tokens charset-checked) — no quote can reach here.
1229
+ const csp = artifactCsp(options.assets, { allowBaseOrigin: options.baseHref });
1230
+ const additions = [
1231
+ '<meta charset="utf-8">',
1232
+ `<meta http-equiv="Content-Security-Policy" content="${csp}">`,
1233
+ options.baseHref ? `<base href="${escapeAttribute(options.baseHref)}">` : '',
1234
+ '<meta name="generator" content="Studiograph Artifact Canvas">',
1235
+ /<title[\s>]/i.test(entry) ? '' : `<title>${escapeHtml(bundle.title)}</title>`,
1236
+ styleTags(bundle),
1237
+ options.includeRenderHostStyles ? artifactRenderHostStyleTags(bundle) : '',
1238
+ ];
1239
+ return additions.filter(Boolean).join('\n');
1240
+ }
1241
+ function withDoctype(html) {
1242
+ return /^\s*<!doctype\b/i.test(html) ? html : `<!doctype html>\n${html}`;
1243
+ }
1244
+ export function artifactExportFilename(bundle, extension = 'html') {
1245
+ const source = bundle.title || bundle.artifactId || 'artifact';
1246
+ const slug = source
1247
+ .normalize('NFKD')
1248
+ .replace(/[\u0300-\u036f]/g, '')
1249
+ .toLowerCase()
1250
+ .replace(/[^a-z0-9]+/g, '-')
1251
+ .replace(/^-+|-+$/g, '')
1252
+ .slice(0, 80);
1253
+ return `${slug || 'artifact'}.${extension}`;
1254
+ }
1255
+ /**
1256
+ * THE single artifact document builder (eng review D2). Every render context —
1257
+ * in-app canvas, public share/present page, downloaded standalone HTML, and the
1258
+ * headless Playwright render — produces its HTML here, from one set of
1259
+ * content-processing helpers and one runtime. `host` selects the document SHAPE
1260
+ * (a live-hosted iframe srcdoc vs a self-contained file) and the runtime's
1261
+ * capabilities; the asset CSP + URL rewrite come from the one `assets` value.
1262
+ */
1263
+ export function buildArtifactDocument(bundle, ctx) {
1264
+ return ctx.host === 'iframe' ? buildIframeDocument(bundle, ctx) : buildStandaloneDocument(bundle, ctx);
1265
+ }
1266
+ /** A self-contained runtime that replaces the browser's default broken-image
1267
+ * glyph with a footprint-preserving placeholder, mirroring the reading
1268
+ * surfaces' missing-asset chip (Carbon DocumentUnknown glyph + the same
1269
+ * "Original asset is no longer available" copy; createUnavailableImageNote in
1270
+ * web/src/lib/utils/unavailable-image.ts). Injected into EVERY render (canvas,
1271
+ * fullscreen, standalone download, headless PNG/PDF) so a deleted or
1272
+ * unreachable embed degrades identically everywhere — and, unlike the inline
1273
+ * reading chip, holds the image's box so a fixed artifact layout (e.g. a slide)
1274
+ * doesn't collapse. Vanilla (the sandboxed iframe can't import the Svelte
1275
+ * builder), theme-agnostic (an artifact background is light OR dark, and the
1276
+ * app's `--color-text-*` tokens aren't defined inside the sandbox), capture
1277
+ * phase (an `<img>` `error` event doesn't bubble), idempotent, never throws. */
1278
+ function brokenImageScriptTag() {
1279
+ const js = `
1280
+ (() => {
1281
+ var MESSAGE = 'Original asset is no longer available';
1282
+ var GLYPH = [
1283
+ 'M16,30H8a2,2,0,0,1-2-2V4A2,2,0,0,1,8,2h10a1,1,0,0,1,.71.29l5,5A1,1,0,0,1,24,8v6H22V9H17a1,1,0,0,1-1-1V4H8V28h8Z',
1284
+ 'M24.5,29A2.5,2.5,0,1,1,27,26.5,2.5,2.5,0,0,1,24.5,29Zm0-3.5a1,1,0,1,0,1,1A1,1,0,0,0,24.5,25.5Z',
1285
+ 'M24.5,23a1,1,0,0,1-1-1V21a2.5,2.5,0,1,0-2.5-2.5,1,1,0,0,1-2,0A4.5,4.5,0,1,1,25,22.79V22A1,1,0,0,1,24.5,23Z'
1286
+ ];
1287
+ var NS = 'http://www.w3.org/2000/svg';
1288
+ function replace(img) {
1289
+ try {
1290
+ if (!img || img.tagName !== 'IMG' || img.hasAttribute('data-sg-broken')) return;
1291
+ if (img.complete && img.naturalWidth > 0) return; // recovered (e.g. a retry swapped src)
1292
+ img.setAttribute('data-sg-broken', '');
1293
+ var cs = window.getComputedStyle ? getComputedStyle(img) : null;
1294
+ var rect = img.getBoundingClientRect();
1295
+ // getComputedStyle returns a USED px length ("695px") only for a laid-out
1296
+ // element. Inside a display:none subtree — an INACTIVE FRAME in present /
1297
+ // fullscreen mode — it returns the COMPUTED value verbatim ("100%"),
1298
+ // which is not a pixel box; parseFloat("100%") === 100 would bake a bogus
1299
+ // 100x100 footprint (the small glyph-only box seen in present mode). So
1300
+ // accept a computed length ONLY when it is in px; it still dodges the
1301
+ // collapsed broken-img rendered box and the "numeric width attr that CSS
1302
+ // overrides" trap. Then a unitless pixel attribute, the rendered box, a
1303
+ // default. A percentage means the image fills its slot — mirrored below.
1304
+ var pxLen = function (cssVal) {
1305
+ if (typeof cssVal === 'string' && /px$/.test(cssVal)) {
1306
+ var c = parseFloat(cssVal);
1307
+ if (isFinite(c) && c > 1) return Math.round(c);
1308
+ }
1309
+ return null;
1310
+ };
1311
+ var dim = function (cssVal, attr, rectVal, dflt) {
1312
+ var c = pxLen(cssVal);
1313
+ if (c != null) return c;
1314
+ if (attr && /^\\s*\\d+(?:\\.\\d+)?\\s*$/.test(attr)) return parseFloat(attr);
1315
+ if (rectVal > 1) return Math.round(rectVal);
1316
+ return dflt;
1317
+ };
1318
+ var w = dim(cs && cs.width, img.getAttribute('width'), rect.width, 240);
1319
+ var h = dim(cs && cs.height, img.getAttribute('height'), rect.height, 160);
1320
+ // Fill case: the computed size is a percentage we could NOT resolve to px
1321
+ // (the img lives in a display:none present-mode frame). Mirror the
1322
+ // percentage onto the placeholder so it fills the same slot once the frame
1323
+ // is revealed + self-fit-scaled, instead of the default box. w/h keep
1324
+ // their defaults purely to select the large (glyph+label) tier, since a
1325
+ // fill image is content-sized.
1326
+ var fillW = (pxLen(cs && cs.width) == null && cs && /%$/.test(cs.width)) ? cs.width : null;
1327
+ var fillH = (pxLen(cs && cs.height) == null && cs && /%$/.test(cs.height)) ? cs.height : null;
1328
+ var radius = (cs && cs.borderRadius && cs.borderRadius !== '0px') ? cs.borderRadius : '4px';
1329
+ // Degrade by available footprint so a tiny broken embed stays tiny instead
1330
+ // of blowing out the layout with text it can't hold: large -> glyph +
1331
+ // label; small -> glyph only; tiny -> just the box. The aria-label always
1332
+ // carries the full message regardless of what is drawn.
1333
+ var minDim = w < h ? w : h;
1334
+ var showGlyph = minDim >= 40;
1335
+ var showText = w >= 130 && h >= 70;
1336
+ var pad = showText ? 12 : (showGlyph ? 6 : 0);
1337
+ // Block-level images (incl. flex/grid items) get a block-level flex box so
1338
+ // full-width layout and margin:auto centering survive; inline images stay
1339
+ // inline-flex.
1340
+ var disp = (cs && /^inline/.test(cs.display)) ? 'inline-flex' : 'flex';
1341
+ var note = document.createElement('div');
1342
+ note.setAttribute('data-sg-broken-image', '');
1343
+ note.setAttribute('role', 'img');
1344
+ var alt = (img.getAttribute('alt') || '').trim();
1345
+ note.setAttribute('aria-label', alt ? alt + ' \\u2014 ' + MESSAGE : MESSAGE);
1346
+ // width holds the footprint; the text tier uses min-height (not a fixed
1347
+ // height) so the label can grow rather than clip, while the glyph/box
1348
+ // tiers keep the exact footprint. overflow:hidden only rounds the corners
1349
+ // (the grown/exact height always fits whatever is drawn).
1350
+ var widthCss = 'width:' + (fillW || (w + 'px')) + ';';
1351
+ var heightCss = fillH ? 'height:' + fillH + ';' : (showText ? 'min-height:' + h + 'px;' : 'height:' + h + 'px;');
1352
+ note.style.cssText = 'box-sizing:border-box;display:' + disp + ';flex-direction:column;align-items:center;justify-content:center;gap:8px;' + widthCss + heightCss + 'padding:' + pad + 'px;background:rgba(128,128,128,0.12);border:1px solid rgba(128,128,128,0.25);border-radius:' + radius + ';color:rgba(120,120,120,0.95);font:500 13px/1.35 system-ui,-apple-system,sans-serif;text-align:center;overflow:hidden';
1353
+ // Preserve the image's placement so a broken absolutely-positioned,
1354
+ // floated, centered, or grid/flex-placed image keeps its slot instead of
1355
+ // dropping into normal flow and shifting the whole artifact.
1356
+ if (cs) {
1357
+ var keep = ['position','top','right','bottom','left','transform','transformOrigin','float','marginTop','marginRight','marginBottom','marginLeft','verticalAlign','alignSelf','justifySelf','gridColumn','gridRow'];
1358
+ for (var k = 0; k < keep.length; k++) {
1359
+ var val = cs[keep[k]];
1360
+ if (val && val !== 'auto' && val !== 'normal' && val !== 'static' && val !== 'none' && val !== '0px') {
1361
+ try { note.style[keep[k]] = val; } catch (e) {}
1362
+ }
1363
+ }
1364
+ }
1365
+ if (showGlyph) {
1366
+ var svg = document.createElementNS(NS, 'svg');
1367
+ svg.setAttribute('viewBox', '0 0 32 32');
1368
+ svg.setAttribute('width', '20');
1369
+ svg.setAttribute('height', '20');
1370
+ svg.setAttribute('fill', 'currentColor');
1371
+ svg.setAttribute('aria-hidden', 'true');
1372
+ svg.style.cssText = 'flex:none;opacity:0.85';
1373
+ for (var i = 0; i < GLYPH.length; i++) {
1374
+ var p = document.createElementNS(NS, 'path');
1375
+ p.setAttribute('d', GLYPH[i]);
1376
+ svg.appendChild(p);
1377
+ }
1378
+ note.appendChild(svg);
1379
+ }
1380
+ if (showText) {
1381
+ var label = document.createElement('span');
1382
+ label.textContent = MESSAGE;
1383
+ label.style.maxWidth = '100%';
1384
+ note.appendChild(label);
1385
+ }
1386
+ var prevDisplay = img.style.display;
1387
+ img.style.display = 'none';
1388
+ if (img.parentNode) img.parentNode.insertBefore(note, img.nextSibling);
1389
+ // Self-heal: if the same <img> later loads (an artifact's own onerror
1390
+ // retry swapped src to a working URL), drop the placeholder and restore
1391
+ // the image to its PRIOR inline display rather than leave a valid image
1392
+ // hidden behind the note.
1393
+ img.addEventListener('load', function onload() {
1394
+ if (img.naturalWidth > 0) {
1395
+ img.removeEventListener('load', onload);
1396
+ if (note.parentNode) note.parentNode.removeChild(note);
1397
+ img.style.display = prevDisplay;
1398
+ img.removeAttribute('data-sg-broken');
1399
+ }
1400
+ });
1401
+ } catch (e) {}
1402
+ }
1403
+ // Schedule on the window receiver (a detached rAF call can throw in strict
1404
+ // contexts) and never let scheduling throw out of the capture listener.
1405
+ function defer(fn) {
1406
+ try { if (window.requestAnimationFrame) { window.requestAnimationFrame(fn); return; } } catch (e) {}
1407
+ try { window.setTimeout(fn, 0); } catch (e) {}
1408
+ }
1409
+ document.addEventListener('error', function (e) {
1410
+ var t = e && e.target;
1411
+ // Defer a frame so an artifact's own onerror retry can swap src first; the
1412
+ // load-time sweep below still inserts the note synchronously before a
1413
+ // headless capture (which waits for load + rAF), so the export never races.
1414
+ if (t && t.tagName === 'IMG') defer(function () { replace(t); });
1415
+ }, true);
1416
+ // Sweep images already broken before the listener registered (cached 404s, or
1417
+ // the standalone tag running at body end after parse).
1418
+ function sweep() {
1419
+ try {
1420
+ var imgs = document.querySelectorAll('img');
1421
+ for (var i = 0; i < imgs.length; i++) {
1422
+ var im = imgs[i];
1423
+ if (im.complete && im.naturalWidth === 0 && im.getAttribute('src')) replace(im);
1424
+ }
1425
+ } catch (e) {}
1426
+ }
1427
+ // Run once now — catches images already parsed + broken when this tag
1428
+ // executes (the standalone tag sits at body end, after the markup), so a
1429
+ // user/library script running next sees the placeholder, not the raw broken
1430
+ // img. Run again on load for cached 404s that completed before the capture
1431
+ // listener registered.
1432
+ sweep();
1433
+ window.addEventListener('load', sweep);
1434
+ })();`;
1435
+ return `<script data-artifact-broken-image>${escapeScript(js)}</script>`;
1436
+ }
1437
+ /** Live-hosted iframe srcdoc (in-app canvas + public share/present page). */
1438
+ function buildIframeDocument(bundle, ctx) {
1439
+ const context = ctx.context ?? 'editor';
1440
+ const tool = ctx.tool ?? 'pointer';
1441
+ // Resolve workspace-asset URLs per the render's AssetResolution at the
1442
+ // single exit below (token: append ?at= so the cookieless sandbox can load
1443
+ // workspace images; share: the token-scoped public path). The CSP in `meta`
1444
+ // derives from the SAME value, so URLs and policy can never disagree.
1445
+ const finalize = (html) => applyAssetResolution(html, ctx.assets);
1446
+ const stepperControl = getStepperControl(bundle);
1447
+ const rawEntry = stripNetworkAssetTags(bundle.files[bundle.entrypoint] ?? '');
1448
+ const deferred = requestedLibraries(bundle).length > 0 ? deferInlineScripts(rawEntry) : { html: rawEntry, scripts: [] };
1449
+ const entry = deferred.html;
1450
+ // Safe unescaped: every dynamic CSP part is validated upstream (origins are
1451
+ // URL-parsed, share tokens charset-checked) — no quote can reach here.
1452
+ const meta = `<meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="${artifactCsp(ctx.assets)}">${fontPreloadTags(bundle, ctx.assets)}`;
1453
+ const libraries = libraryTags(bundle, ctx.librarySources);
1454
+ const runtime = `<script>${escapeScript(iframeRuntimeScript(bundle.artifactId, context, tool, stepperControl, bundle.viewport.mode))}</script>\n${brokenImageScriptTag()}`;
1455
+ const styles = [styleTags(bundle), inAppHostStyleTags(bundle, context)].filter(Boolean).join('\n');
1456
+ const scripts = scriptTags(bundle, deferred.scripts);
1457
+ if (/<html[\s>]/i.test(entry)) {
1458
+ let html = addArtifactContextToHtmlTag(entry, context, tool, !!stepperControl);
1459
+ html = html.replace(/<head([^>]*)>/i, `<head$1>${meta}\n${libraries}\n${runtime}\n${styles}`);
1460
+ if (!/<head[\s>]/i.test(html))
1461
+ html = html.replace(/<html([^>]*)>/i, `<html$1><head>${meta}\n${libraries}\n${runtime}\n${styles}</head>`);
1462
+ if (/<body[\s>]/i.test(html)) {
1463
+ html = /<\/body>/i.test(html) ? html.replace(/<\/body>/i, `${scripts}\n</body>`) : `${html}\n${scripts}`;
1464
+ }
1465
+ else {
1466
+ html += `<body>${scripts}</body>`;
1467
+ }
1468
+ return finalize(html);
1469
+ }
1470
+ return finalize(`<!doctype html>
1471
+ <html data-artifact-context="${escapeAttribute(context)}" data-artifact-tool="${escapeAttribute(tool)}"${stepperControl ? ' data-artifact-stepper="true"' : ''}>
1472
+ <head>
1473
+ ${meta}
1474
+ ${libraries}
1475
+ ${runtime}
1476
+ ${styles}
1477
+ </head>
1478
+ <body>
1479
+ ${entry}
1480
+ ${scripts}
1481
+ </body>
1482
+ </html>`);
1483
+ }
1484
+ /** Self-contained file (downloaded standalone HTML + headless Playwright). */
1485
+ function buildStandaloneDocument(bundle, ctx) {
1486
+ const options = {
1487
+ context: ctx.context,
1488
+ baseHref: ctx.baseHref,
1489
+ includeRenderHostStyles: ctx.includeRenderHostStyles,
1490
+ librarySources: ctx.librarySources,
1491
+ assets: ctx.assets,
1492
+ };
1493
+ // Resolve workspace-asset URLs per the render's AssetResolution at the
1494
+ // single exit (token: append ?at= for the cookieless headless render;
1495
+ // relative: ./assets paths + placeholders for the standalone export).
1496
+ // The CSP in headAdditions derives from the same value.
1497
+ const finalize = (html) => applyAssetResolution(html, ctx.assets);
1498
+ const stepperControl = getStepperControl(bundle);
1499
+ const rawEntry = stripNetworkAssetTags(bundle.files[bundle.entrypoint] ?? '');
1500
+ const deferred = requestedLibraries(bundle).length > 0 ? deferInlineScripts(rawEntry) : { html: rawEntry, scripts: [] };
1501
+ const entry = deferred.html;
1502
+ const styles = headAdditions(bundle, entry, options);
1503
+ const libraries = libraryTags(bundle, ctx.librarySources);
1504
+ const scripts = scriptTags(bundle, deferred.scripts);
1505
+ // Self-fit stepper runtime: ONLY a stepped fullscreen export needs it (a
1506
+ // paged reader / plain document letterboxes via CSS alone, and the headless
1507
+ // export-context render drives frames itself). Same unified runtime, hostless
1508
+ // capability set — the duplicate standalone stepper is gone.
1509
+ const runtime = (ctx.context === 'fullscreen' && stepperControl)
1510
+ ? `<script data-artifact-standalone-runtime>${escapeScript(standaloneRuntimeScript(stepperControl))}</script>`
1511
+ : '';
1512
+ // Broken-image runtime first so its capture listener is live before any
1513
+ // user/library script runs; it also sweeps already-broken images on load.
1514
+ const bodyScripts = [brokenImageScriptTag(), runtime, scripts].filter(Boolean).join('\n');
1515
+ const htmlAttrs = ctx.context
1516
+ ? {
1517
+ 'data-artifact-context': ctx.context,
1518
+ 'data-artifact-stepper': stepperControl ? 'true' : undefined,
1519
+ }
1520
+ : {};
1521
+ if (/<html[\s>]/i.test(entry)) {
1522
+ let html = addAttributesToHtmlTag(entry, htmlAttrs);
1523
+ if (/<head[\s>]/i.test(html)) {
1524
+ html = html.replace(/<head([^>]*)>/i, `<head$1>\n${styles}\n${libraries}\n`);
1525
+ }
1526
+ else {
1527
+ html = html.replace(/<html([^>]*)>/i, `<html$1>\n<head>\n${styles}\n${libraries}\n</head>`);
1528
+ }
1529
+ if (bodyScripts) {
1530
+ if (/<\/body>/i.test(html)) {
1531
+ html = html.replace(/<\/body>/i, `${bodyScripts}\n</body>`);
1532
+ }
1533
+ else {
1534
+ html = `${html}\n${bodyScripts}`;
1535
+ }
1536
+ }
1537
+ return finalize(withDoctype(html));
1538
+ }
1539
+ return finalize(`<!doctype html>
1540
+ <html${ctx.context ? ` data-artifact-context="${escapeAttribute(ctx.context)}"${stepperControl ? ' data-artifact-stepper="true"' : ''}` : ''}>
1541
+ <head>
1542
+ ${styles}
1543
+ ${libraries}
1544
+ </head>
1545
+ <body>
1546
+ ${entry}
1547
+ ${bodyScripts}
1548
+ </body>
1549
+ </html>`);
1550
+ }
1551
+ /**
1552
+ * Self-contained HTML export + headless Playwright render. A thin adapter over
1553
+ * {@link buildArtifactDocument} with `host: 'none'` — kept for its existing
1554
+ * callers (the export route, MCP `export_artifact`, the render service). A
1555
+ * bare call (no `context`) yields a plain self-contained file, unchanged.
1556
+ */
1557
+ export function buildArtifactHtmlExport(bundle, options = {}) {
1558
+ return buildArtifactDocument(bundle, {
1559
+ host: 'none',
1560
+ context: options.context,
1561
+ baseHref: options.baseHref,
1562
+ includeRenderHostStyles: options.includeRenderHostStyles,
1563
+ librarySources: options.librarySources,
1564
+ assets: options.assets,
1565
+ });
1566
+ }
1567
+ //# sourceMappingURL=artifact-export.js.map