studiograph 1.3.48-next.261 → 1.3.48-next.262

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 (175) hide show
  1. package/dist/agent/prompts/system.md +2 -2
  2. package/dist/agent/skills/artifact-canvas/skill.md +25 -37
  3. package/dist/agent/skills/paged-documents/skill.md +53 -103
  4. package/dist/agent/tools/artifact-tools.js +1 -1
  5. package/dist/agent/tools/artifact-tools.js.map +1 -1
  6. package/dist/core/artifact-columns-css.d.ts +38 -0
  7. package/dist/core/artifact-columns-css.js +157 -0
  8. package/dist/core/artifact-columns-css.js.map +1 -0
  9. package/dist/core/artifact-frame-document.d.ts +57 -15
  10. package/dist/core/artifact-frame-document.js +351 -346
  11. package/dist/core/artifact-frame-document.js.map +1 -1
  12. package/dist/mcp/tools.js +8 -8
  13. package/dist/mcp/tools.js.map +1 -1
  14. package/dist/server/routes/artifact-export-api.js +2 -3
  15. package/dist/server/routes/artifact-export-api.js.map +1 -1
  16. package/dist/services/artifact-library-sources.d.ts +1 -6
  17. package/dist/services/artifact-library-sources.js +1 -9
  18. package/dist/services/artifact-library-sources.js.map +1 -1
  19. package/dist/services/artifact-render-export.d.ts +3 -3
  20. package/dist/services/artifact-render-export.js +57 -50
  21. package/dist/services/artifact-render-export.js.map +1 -1
  22. package/dist/web/_app/immutable/chunks/2jM_FrdF.js +1 -0
  23. package/dist/web/_app/immutable/chunks/{DtXJCaQc2.js → 3Jf1z4Hy2.js} +1 -1
  24. package/dist/web/_app/immutable/chunks/3e54b3S0.js +1 -0
  25. package/dist/web/_app/immutable/chunks/7pkBLE0o.js +23 -0
  26. package/dist/web/_app/immutable/chunks/{B9VQbssY.js → B1pVemww.js} +1 -1
  27. package/dist/web/_app/immutable/chunks/{DMFH9Cnt2.js → B4K-yj0D2.js} +1 -1
  28. package/dist/web/_app/immutable/chunks/B7P31uII.js +1 -0
  29. package/dist/web/_app/immutable/chunks/B7QhSEou.js +1 -0
  30. package/dist/web/_app/immutable/chunks/{F5xZdLlB.js → B7WN6QF2.js} +1 -1
  31. package/dist/web/_app/immutable/chunks/{Cg7m1gqN.js → BBiZOxjN.js} +1 -1
  32. package/dist/web/_app/immutable/chunks/BH8Lm2ff.js +1 -0
  33. package/dist/web/_app/immutable/chunks/BLw5bIbe.js +1 -0
  34. package/dist/web/_app/immutable/chunks/{Oglnp7sm.js → BNq2--8a.js} +1 -1
  35. package/dist/web/_app/immutable/chunks/BaEbafZv.js +1 -0
  36. package/dist/web/_app/immutable/chunks/{sUAtda6y2.js → BeMEksa_2.js} +1 -1
  37. package/dist/web/_app/immutable/chunks/BksI9pEP.js +2 -0
  38. package/dist/web/_app/immutable/chunks/{CLninaDG2.js → Bncd_2iV2.js} +2 -2
  39. package/dist/web/_app/immutable/chunks/Bo6vsMxZ.js +1 -0
  40. package/dist/web/_app/immutable/chunks/{D0_Iu3Uj.js → BwZmyfEX.js} +1 -1
  41. package/dist/web/_app/immutable/chunks/{7l950wkn.js → CB2VdVc2.js} +1 -1
  42. package/dist/web/_app/immutable/chunks/CGG26Xmx.js +1 -0
  43. package/dist/web/_app/immutable/chunks/{K5YHt1WL2.js → CRANgTjw2.js} +1 -1
  44. package/dist/web/_app/immutable/chunks/{CuatNdzp.js → CSkL5wO6.js} +1 -1
  45. package/dist/web/_app/immutable/chunks/C_vyTCDY.js +1 -0
  46. package/dist/web/_app/immutable/chunks/{CQW66fYJ.js → Ca57r8F-.js} +1 -1
  47. package/dist/web/_app/immutable/chunks/CbnTIbQI2.js +915 -0
  48. package/dist/web/_app/immutable/chunks/{CR1JY_cb.js → CdvkVymz2.js} +1 -1
  49. package/dist/web/_app/immutable/chunks/{atFSAfcd2.js → Cf7pPlrJ2.js} +2 -2
  50. package/dist/web/_app/immutable/chunks/{CKwV6HKc.js → Cg5kjIaW.js} +1 -1
  51. package/dist/web/_app/immutable/chunks/{BvyVm7-j.js → CmPtL-Rn.js} +1 -1
  52. package/dist/web/_app/immutable/chunks/{B3u4QMoU.js → CnpFxw1N2.js} +1 -1
  53. package/dist/web/_app/immutable/chunks/D2G8GgRX.js +1 -0
  54. package/dist/web/_app/immutable/chunks/D3ZdQV1G2.js +66 -0
  55. package/dist/web/_app/immutable/chunks/{snCOoD78.js → D74E7DtA.js} +1 -1
  56. package/dist/web/_app/immutable/chunks/{B4PrlkmE.js → D8nBN3-t.js} +1 -1
  57. package/dist/web/_app/immutable/chunks/{Bpmx7Umc.js → DE5MFphK.js} +1 -1
  58. package/dist/web/_app/immutable/chunks/DGayKSDC.js +1 -0
  59. package/dist/web/_app/immutable/chunks/{BU5TCj7m.js → DHtwYMGu2.js} +1 -1
  60. package/dist/web/_app/immutable/chunks/DIro3eQz.js +1 -0
  61. package/dist/web/_app/immutable/chunks/{DgP0Pxes.js → DJ61sY6B.js} +1 -1
  62. package/dist/web/_app/immutable/chunks/{-MYqXlc3.js → DLFxzom2.js} +1 -1
  63. package/dist/web/_app/immutable/chunks/{BIeOkmiV.js → DOTPES0g.js} +1 -1
  64. package/dist/web/_app/immutable/chunks/DWbRq2tL.js +1 -0
  65. package/dist/web/_app/immutable/chunks/{BBrP9GiZ.js → D_iZ9buk.js} +1 -1
  66. package/dist/web/_app/immutable/chunks/Dm-W1U3E.js +1 -0
  67. package/dist/web/_app/immutable/chunks/{DhD1HF7R.js → DnUROuxo.js} +1 -1
  68. package/dist/web/_app/immutable/chunks/{B5UH1FNe.js → DqGcR20a.js} +3 -3
  69. package/dist/web/_app/immutable/chunks/{DaB0hU8_.js → DqGo4lyB2.js} +1 -1
  70. package/dist/web/_app/immutable/chunks/{ChS3kWO-.js → DrD4p8T22.js} +1 -1
  71. package/dist/web/_app/immutable/chunks/{-Tbr-96c2.js → DtsNBsm82.js} +1 -1
  72. package/dist/web/_app/immutable/chunks/{B06uE1qJ.js → DwvPlTQh.js} +1 -1
  73. package/dist/web/_app/immutable/chunks/{CZBW5wi82.js → DxPoYrW02.js} +1 -1
  74. package/dist/web/_app/immutable/chunks/DxQ5eC8d.js +1 -0
  75. package/dist/web/_app/immutable/chunks/{2z2ekE6D2.js → DzBvLWrB2.js} +2 -2
  76. package/dist/web/_app/immutable/chunks/{CcgYvk_3.js → G9YEWi42.js} +1 -1
  77. package/dist/web/_app/immutable/chunks/{CQl_RUVN2.js → HMuf07932.js} +1 -1
  78. package/dist/web/_app/immutable/chunks/{CG8YKaqc.js → HdbzGSgU.js} +1 -1
  79. package/dist/web/_app/immutable/chunks/{Cxdio_Al.js → Mozc9LAu.js} +3 -3
  80. package/dist/web/_app/immutable/chunks/{D-vvXTU3.js → Ryiz4ZhP.js} +2 -2
  81. package/dist/web/_app/immutable/chunks/{1QRy7-wo.js → gIVpw0C1.js} +1 -1
  82. package/dist/web/_app/immutable/chunks/{Cx5IiiDO2.js → gwOFYscJ2.js} +1 -1
  83. package/dist/web/_app/immutable/chunks/nacCpTNj.js +83 -0
  84. package/dist/web/_app/immutable/chunks/{PwUOTfSv.js → rSrKfaH92.js} +1 -1
  85. package/dist/web/_app/immutable/chunks/{CkivrILi.js → tvHn3XYh.js} +1 -1
  86. package/dist/web/_app/immutable/chunks/{pPsy4008.js → w53PifJt.js} +1 -1
  87. package/dist/web/_app/immutable/entry/app.D9mnvkg5.js +2 -0
  88. package/dist/web/_app/immutable/entry/start.B1L019Jv.js +1 -0
  89. package/dist/web/_app/immutable/nodes/{0.v0W5DL7W.js → 0.GCsQhZXx.js} +2 -2
  90. package/dist/web/_app/immutable/nodes/{1.81Rb3Ith.js → 1.Bai1Q_j6.js} +1 -1
  91. package/dist/web/_app/immutable/nodes/{10.CAlZNFOq.js → 10.Y-zXN2Lv.js} +1 -1
  92. package/dist/web/_app/immutable/nodes/{11.CCfUginI.js → 11.BGLbrt6p.js} +1 -1
  93. package/dist/web/_app/immutable/nodes/{12.BJLboAyk.js → 12.kkg6EDtl.js} +1 -1
  94. package/dist/web/_app/immutable/nodes/{13.Cz4UTl2G.js → 13.DVLmp3Ll.js} +1 -1
  95. package/dist/web/_app/immutable/nodes/15.B6Aw4lhr.js +1 -0
  96. package/dist/web/_app/immutable/nodes/{16.BjohAUt9.js → 16.BvduImqP.js} +1 -1
  97. package/dist/web/_app/immutable/nodes/{18.DsOT0tDo.js → 18.pwlJhpUE.js} +1 -1
  98. package/dist/web/_app/immutable/nodes/{19.BiJWLOUT.js → 19.CC1kL3Df.js} +1 -1
  99. package/dist/web/_app/immutable/nodes/2.CvV_5gfw.js +124 -0
  100. package/dist/web/_app/immutable/nodes/{3.BIT9mm9r.js → 3.DBPrinvQ.js} +1 -1
  101. package/dist/web/_app/immutable/nodes/{4.Cn7r0Poe.js → 4.A-1yULWB.js} +3 -3
  102. package/dist/web/_app/immutable/nodes/{5.CkJNdCZG.js → 5.CN9jkFSb.js} +1 -1
  103. package/dist/web/_app/immutable/nodes/{6.1rbIRQgQ.js → 6.B2TGmdUT.js} +1 -1
  104. package/dist/web/_app/immutable/nodes/{7.w17Zl36i.js → 7.DQFIEPX4.js} +1 -1
  105. package/dist/web/_app/immutable/nodes/{8.DqTsYuU-.js → 8.CsTDHJ_M.js} +1 -1
  106. package/dist/web/_app/immutable/nodes/{9.BJhYZPfc.js → 9.B9zlBMg4.js} +1 -1
  107. package/dist/web/_app/version.json +1 -1
  108. package/dist/web/index.html +17 -17
  109. package/package.json +1 -2
  110. package/dist/core/artifact-bundle.d.ts +0 -69
  111. package/dist/core/artifact-bundle.js +0 -305
  112. package/dist/core/artifact-bundle.js.map +0 -1
  113. package/dist/core/artifact-export.d.ts +0 -83
  114. package/dist/core/artifact-export.js +0 -1567
  115. package/dist/core/artifact-export.js.map +0 -1
  116. package/dist/core/artifact-frame-selectors.d.ts +0 -42
  117. package/dist/core/artifact-frame-selectors.js +0 -65
  118. package/dist/core/artifact-frame-selectors.js.map +0 -1
  119. package/dist/core/artifact-paged-css.d.ts +0 -64
  120. package/dist/core/artifact-paged-css.js +0 -253
  121. package/dist/core/artifact-paged-css.js.map +0 -1
  122. package/dist/core/field-library.d.ts +0 -62
  123. package/dist/core/field-library.js +0 -129
  124. package/dist/core/field-library.js.map +0 -1
  125. package/dist/mcp/comment-virtual-entity.d.ts +0 -36
  126. package/dist/mcp/comment-virtual-entity.js +0 -71
  127. package/dist/mcp/comment-virtual-entity.js.map +0 -1
  128. package/dist/server/routes/field-library-api.d.ts +0 -28
  129. package/dist/server/routes/field-library-api.js +0 -126
  130. package/dist/server/routes/field-library-api.js.map +0 -1
  131. package/dist/server/routes/import-batch.d.ts +0 -24
  132. package/dist/server/routes/import-batch.js +0 -33
  133. package/dist/server/routes/import-batch.js.map +0 -1
  134. package/dist/services/artifact-inspect-metadata.d.ts +0 -21
  135. package/dist/services/artifact-inspect-metadata.js +0 -66
  136. package/dist/services/artifact-inspect-metadata.js.map +0 -1
  137. package/dist/services/asset-upload-service.d.ts +0 -56
  138. package/dist/services/asset-upload-service.js +0 -200
  139. package/dist/services/asset-upload-service.js.map +0 -1
  140. package/dist/services/bookmark-import-service.d.ts +0 -49
  141. package/dist/services/bookmark-import-service.js +0 -250
  142. package/dist/services/bookmark-import-service.js.map +0 -1
  143. package/dist/web/_app/immutable/chunks/1_BWKAKA2.js +0 -23
  144. package/dist/web/_app/immutable/chunks/9Sn2PwSn2.js +0 -1025
  145. package/dist/web/_app/immutable/chunks/B2cjR0rG.js +0 -83
  146. package/dist/web/_app/immutable/chunks/BIwe25Lk.js +0 -1
  147. package/dist/web/_app/immutable/chunks/BnYohOts2.js +0 -1
  148. package/dist/web/_app/immutable/chunks/BxeOdaUQ.js +0 -1
  149. package/dist/web/_app/immutable/chunks/C6d9nn3k.js +0 -1
  150. package/dist/web/_app/immutable/chunks/C85NG5lS.js +0 -1
  151. package/dist/web/_app/immutable/chunks/CB-t1Bv-.js +0 -1
  152. package/dist/web/_app/immutable/chunks/CJbr-v9X.js +0 -1
  153. package/dist/web/_app/immutable/chunks/CKGHeJzq.js +0 -1
  154. package/dist/web/_app/immutable/chunks/CXPMb3zd.js +0 -1
  155. package/dist/web/_app/immutable/chunks/CgCz4Fj6.js +0 -1
  156. package/dist/web/_app/immutable/chunks/CuYL65Bn.js +0 -2
  157. package/dist/web/_app/immutable/chunks/D4vfNy_f.js +0 -1
  158. package/dist/web/_app/immutable/chunks/DBGaXAP8.js +0 -1
  159. package/dist/web/_app/immutable/chunks/DG7uGoZb2.js +0 -58
  160. package/dist/web/_app/immutable/chunks/DTOO9nUj.js +0 -1
  161. package/dist/web/_app/immutable/chunks/Dav8Lgyy2.js +0 -5
  162. package/dist/web/_app/immutable/chunks/DiiXREv7.js +0 -1
  163. package/dist/web/_app/immutable/chunks/DjXtgWTP.js +0 -1
  164. package/dist/web/_app/immutable/chunks/oIZupUnz.js +0 -1
  165. package/dist/web/_app/immutable/entry/app.1tcNTKI-.js +0 -2
  166. package/dist/web/_app/immutable/entry/start.BJMsySI1.js +0 -1
  167. package/dist/web/_app/immutable/nodes/15.D2mNE6C-.js +0 -1
  168. package/dist/web/_app/immutable/nodes/2.Cd72nG2V.js +0 -124
  169. /package/dist/web/_app/immutable/chunks/{B9KfEsQw2.js → B9KfEsQw.js} +0 -0
  170. /package/dist/web/_app/immutable/chunks/{BO6-nRV9.js → BO6-nRV92.js} +0 -0
  171. /package/dist/web/_app/immutable/chunks/{CcWaWbrI2.js → CcWaWbrI.js} +0 -0
  172. /package/dist/web/_app/immutable/chunks/{CsIWxmio2.js → CsIWxmio.js} +0 -0
  173. /package/dist/web/_app/immutable/chunks/{DQMWa2t7.js → DQMWa2t72.js} +0 -0
  174. /package/dist/web/_app/immutable/chunks/{MAYbL-xn2.js → MAYbL-xn.js} +0 -0
  175. /package/dist/web/_app/immutable/chunks/{MtN4vJa02.js → MtN4vJa0.js} +0 -0
@@ -20,11 +20,11 @@ import { computeFrameLayout, FRAME_LAYOUT_DEFAULT_GAP } from './artifact-frame-l
20
20
  import { escapeAttribute, escapeHtml, escapeScript, escapeStyle } from './artifact-escape.js';
21
21
  import { applyAssetResolution, artifactCsp } from './artifact-asset-resolution.js';
22
22
  import { brokenImageScriptTag, libraryTags } from './artifact-libraries.js';
23
- import { SANITIZE_CSS_URLS_TWIN } from './artifact-paged-css.js';
24
- /** Cap (ms) on the in-sandbox `document.fonts.ready` wait before Paged.js
25
- * fragments. The host inlines workspace-asset fonts as data: URLs so this
26
- * normally resolves immediately; the cap only bites when a font falls back to a
27
- * slow network endpoint, trading a worst-case late-font reflow for not stalling
23
+ import { rewriteAuthorCssForColumns } from './artifact-columns-css.js';
24
+ /** Cap (ms) on the in-sandbox `document.fonts.ready` wait before the columns
25
+ * compositor measures. The host inlines workspace-asset fonts as data: URLs so
26
+ * this normally resolves immediately; the cap only bites when a font falls back
27
+ * to a slow network endpoint, trading a worst-case late-font reflow for not stalling
28
28
  * the canvas for many seconds. Interpolated into the pagination bootstrap. */
29
29
  const FONT_READY_TIMEOUT_MS = 2000;
30
30
  /** Artboard fill for a frame that opts into `neutral`. The DEFAULT (unset) is
@@ -138,21 +138,6 @@ function sharedStyleTags(bundle) {
138
138
  .map(([path, css]) => `<style data-artifact-file="${escapeAttribute(path)}">\n${escapeStyle(css)}\n</style>`)
139
139
  .join('\n');
140
140
  }
141
- /** Concatenated shared stylesheet TEXT (raw CSS). Paged.js only honors break
142
- * rules (`break-before/after/inside`, named pages) from stylesheets handed to
143
- * its polisher — CSS that merely sits in the document head styles the content
144
- * visually but is invisible to the fragmenter. So the authored shared CSS is
145
- * passed to `preview()` alongside the `@page` rule (it is ALSO in the head for
146
- * global styling; double-application is idempotent). */
147
- function sharedCssText(bundle) {
148
- const files = bundle.shared?.files;
149
- if (!files)
150
- return '';
151
- return Object.entries(files)
152
- .filter(([path]) => path.toLowerCase().endsWith('.css'))
153
- .map(([, css]) => css)
154
- .join('\n');
155
- }
156
141
  /** The platform layout CSS: a transparent canvas sized to the total extent, with
157
142
  * each frame container absolutely positioned at its computed rect. Fixed frames
158
143
  * get an exact height (content clips); auto-height frames grow to content
@@ -390,9 +375,27 @@ function frameRuntimeScript(artifactId, surface, fullBleed = false, whiteboard =
390
375
  selected.set(t.targetId, { el: target, target: t });
391
376
  publishSelection();
392
377
  };
378
+ // Clip an element rect to a box (both in iframe viewport coords), returning the
379
+ // visible intersection as a plain {x,y,width,height}. A paginated document renders
380
+ // each page as a [data-sg-page-window] (overflow:hidden) onto ONE column of a single
381
+ // native multi-column flow; a block that fragments across a column (= page) boundary
382
+ // has a getBoundingClientRect spanning BOTH pages, and getBoundingClientRect ignores
383
+ // the window's clip — so a selection of it would draw ACROSS pages. Clipping to the
384
+ // page window keeps the overlay on the current page (its only visible part there).
385
+ const clipRectToBox = (rect, box) => {
386
+ const left = Math.max(rect.left, box.left);
387
+ const top = Math.max(rect.top, box.top);
388
+ const right = Math.min(rect.right, box.right);
389
+ const bottom = Math.min(rect.bottom, box.bottom);
390
+ return { x: left, y: top, width: Math.max(0, right - left), height: Math.max(0, bottom - top) };
391
+ };
393
392
  const descriptorFor = (el) => {
394
393
  const frame = el.closest('[data-sg-frame]');
395
- const rect = el.getBoundingClientRect();
394
+ // Only content inside a paginated page window is clipped; furniture and a bare
395
+ // frame/tile selection (not inside a window) keep their full rect.
396
+ const pageWindow = el.closest('[data-sg-page-window]');
397
+ const elRect = el.getBoundingClientRect();
398
+ const rect = pageWindow ? clipRectToBox(elRect, pageWindow.getBoundingClientRect()) : elRect;
396
399
  const frameId = frame ? frame.getAttribute('data-sg-frame-id') : '';
397
400
  // Never emit an empty selector (the host drops empty-selector targets): the
398
401
  // frame element addresses by its frame id, anything else by its DOM path,
@@ -870,51 +873,45 @@ function deckStandaloneRuntime() {
870
873
  })();`;
871
874
  }
872
875
  /** Present a `document` artifact as a continuous vertical scroll through its
873
- * pages — read like a PDF, not stepped like a deck. The pages are Paged.js's
874
- * natural `.pagedjs_page` boxes (present-document does NOT promote them — see the
875
- * bootstrap's `promote` gate so they keep Paged.js's vertical flow); a
876
- * non-paginated (auto/fill-height) document frame flows as a single page. The
876
+ * pages — read like a PDF, not stepped like a deck. The columns compositor
877
+ * replaces the source frame with discrete `[data-sg-frame][data-sg-page]` page
878
+ * tiles (it runs in EVERY mode; `promote` only selects whether their layout is
879
+ * host-driven (editor grid) or CSS-driven (this scroll column)); a non-paginated
880
+ * (auto/fill-height) document frame flows as a single page tile-free frame. The
877
881
  * host sizes the iframe to the present stage; the page column scrolls internally.
878
- * Mirrors the `pagedFullscreenCss` reader model `!important` + the surface
879
- * scope win over Paged.js's runtime-injected `.pagedjs_*` styles (which reset
880
- * left/top/transform and would otherwise absolutely-position the pages). */
881
- function presentDocumentLayoutCss(bundle, surfaceAttr = 'present') {
882
- const surface = `html[data-artifact-surface="${surfaceAttr}"]`;
882
+ * Each tile carries its own inline PW×PH geometry + background from the
883
+ * compositor here we only center it (`margin:0 auto`) and gap it. */
884
+ function presentDocumentLayoutCss(bundle) {
883
885
  // A non-paginated (auto/fill-height) document frame IS the page — give the frame
884
- // itself page chrome. A paginated frame's wrapper stays transparent; its pages
885
- // get chrome via .pagedjs_page below.
886
+ // itself page chrome. A paginated frame is removed and replaced by page tiles,
887
+ // which get their chrome from the [data-sg-page] rule below.
886
888
  const rules = bundle.frames
887
889
  .map((frame) => {
888
890
  const width = typeof frame.geometry.width === 'number' ? `${frame.geometry.width}px` : '100%';
889
891
  const minHeight = typeof frame.geometry.height === 'number' ? `min-height:${frame.geometry.height}px;` : '';
890
892
  const background = frameBackground(frame.layout, frame.background, bundle.display?.background);
891
- return `[data-sg-frame-id="${frame.id}"]:not([data-sg-paginated]){width:${width};max-width:100%;margin:0 auto 32px;${minHeight}background:${background};box-shadow:0 6px 24px rgba(0,0,0,0.18);}`;
893
+ return `[data-sg-frame-id="${frame.id}"]:not([data-sg-page]){width:${width};max-width:100%;margin:0 auto 32px;${minHeight}background:${background};box-shadow:0 6px 24px rgba(0,0,0,0.18);}`;
892
894
  })
893
895
  .join('\n');
894
- // The page column MUST use block flow, not flex: Chromium does not accumulate
895
- // `counter-increment` across FLEX items, so a flex `.pagedjs_pages` renders the
896
- // "Page N of M" footer (which resolves `counter(page)`) as "Page 1" on every
897
- // page. Block boxes accumulate the page counter correctly. Pages center with
898
- // `margin:0 auto` (fixed-width box in a full-width column) and gap with
899
- // `margin-bottom`; the body block-stacks frames + scrolls.
896
+ // The page column uses block flow: each PW×PH tile centers with `margin:0 auto`
897
+ // (fixed-width box in a full-width column), gaps with `margin-bottom`, and the
898
+ // body block-stacks the tiles + scrolls. Page numbers are baked literal text in
899
+ // the per-tile furniture overlay, so there is no cross-tile `counter()` to keep.
900
900
  return `html,body{margin:0;padding:0;background:transparent;height:100%;}
901
901
  body{box-sizing:border-box;min-height:100%;overflow:auto;padding:48px 0;}
902
902
  [data-sg-frame]{position:relative;box-sizing:border-box;background:transparent;overflow:visible;}
903
- [data-sg-frame][data-sg-paginated]{display:block;width:100%;}
904
- ${surface} .pagedjs_pages{display:block !important;width:auto !important;margin:0 !important;padding:0 !important;background:transparent !important;}
905
- ${surface} .pagedjs_page{position:relative !important;left:auto !important;top:auto !important;margin:0 auto 32px !important;transform:none !important;background:#fff;box-shadow:0 6px 24px rgba(0,0,0,0.18);}
906
- ${surface} .pagedjs_sheet{box-shadow:none !important;}
903
+ [data-sg-frame][data-sg-page]{margin:0 auto 32px;overflow:hidden;box-shadow:0 6px 24px rgba(0,0,0,0.18);}
907
904
  ${furniturePositionCss()}
908
905
  ${rules}`;
909
906
  }
910
907
  // ---------------------------------------------------------------------------
911
- // Document pagination (P3.3). A `document` frame holds ONE flowing content
912
- // stream; when a Paged.js source is supplied the platform fragments it into page
913
- // boxes INSIDE the sandbox (CSS Paged Media @page geometry, running headers/
914
- // footers, page counters, break rules). The page count is a render OUTPUT, never
915
- // authored or persisted (the authored `document` frame is the single source of
916
- // truth — frame model `ResolvedFrame.origin: 'derived'`). Print shares the same
917
- // fragmentation, so PDF export gets faithful page breaks for free.
908
+ // Document pagination. A `document` frame holds ONE flowing content stream; the
909
+ // native-columns compositor (columnsCompositorBootstrap) lays it out as a multi-
910
+ // column flow INSIDE the sandbox (one column == one page) and materializes a
911
+ // discrete page TILE per page. The page count is a render OUTPUT, never authored
912
+ // or persisted (the authored `document` frame is the single source of truth —
913
+ // frame model `ResolvedFrame.origin: 'derived'`). PDF export prints the same
914
+ // tiles, so it matches the on-screen pagination exactly.
918
915
  //
919
916
  // A `document` frame paginates only when its height is a fixed page box (e.g.
920
917
  // US Letter 816×1056); a `document` with `height:'auto'` stays a single flowing
@@ -922,39 +919,85 @@ ${rules}`;
922
919
  // Defaults live in the model (DEFAULT_PAGE_MARGINS) so the validation cross-check
923
920
  // and this render agree on what an absent `pageMargins` side resolves to.
924
921
  // ---------------------------------------------------------------------------
925
- // The Paged.js source is a large (~500KB) static library; escapeScript scans it
926
- // for `</script` on every build. Memoize by identity so a session pays once.
927
- let escapedPagedCache = null;
928
- function escapedPagedSource(source) {
929
- if (escapedPagedCache && escapedPagedCache.source === source)
930
- return escapedPagedCache.escaped;
931
- const escaped = escapeScript(source);
932
- escapedPagedCache = { source, escaped };
933
- return escaped;
934
- }
935
- /** The frames the platform paginates: `pages` layout with a fixed page box
922
+ /** A frame the columns compositor paginates: `pages` layout with a fixed page box
936
923
  * (numeric width AND height). A `'fill'`/`'auto'` pages frame is a single flowing
937
924
  * page, not paginated. */
938
- function paginatedFrames(bundle) {
939
- return bundle.frames.filter((frame) => frame.layout === 'pages'
925
+ function isColumnPaginatedFrame(frame) {
926
+ return frame.layout === 'pages'
940
927
  && typeof frame.geometry.width === 'number'
941
- && typeof frame.geometry.height === 'number');
928
+ && typeof frame.geometry.height === 'number';
929
+ }
930
+ /** The frames the platform paginates (see {@link isColumnPaginatedFrame}). */
931
+ function paginatedFrames(bundle) {
932
+ return bundle.frames.filter(isColumnPaginatedFrame);
933
+ }
934
+ /** Rewrite a paginated frame's CSS for the native-columns compositor (forced page
935
+ * breaks → column breaks; `position:running()` removed), covering BOTH `<style>`
936
+ * blocks AND inline `style="…"` / `style='…'` attributes. An element with an inline
937
+ * `style="break-before:page"` (the offer-letter signature-block idiom) would
938
+ * otherwise survive un-mapped and only paginate by Chromium's lenient page→column
939
+ * coercion inside multicol — not guaranteed cross-browser. Each value runs through
940
+ * the same token-safe {@link rewriteAuthorCssForColumns}.
941
+ *
942
+ * ONE left-to-right scan over an alternation whose `<style>`-block branch is FIRST,
943
+ * so the scanner consumes a whole style element before it can reach a `style=`
944
+ * literal sitting in that element's CSS text (an `[style]` attribute selector, a
945
+ * `content:"…style=…"` string): only real markup attributes match the inline branch
946
+ * — no double-processing. A style element's text can't contain `</style>`, so the
947
+ * non-greedy block match is safe. (Limitation, mirroring the url() segmenter's: body
948
+ * TEXT shaped exactly like `style="break-before:page"` would also be rewritten; that
949
+ * pathological case is not worth a full HTML parser, and the value itself is still
950
+ * token-safe — strings/url()/comments inside it are preserved. Only QUOTED
951
+ * attribute values are matched; a rare unquoted single-declaration
952
+ * `style=break-before:page` is left to the browser's lenient page→column coercion,
953
+ * as it was before this mapping existed.) */
954
+ export function rewritePaginatedContentStyles(html) {
955
+ return html.replace(/(<style\b[^>]*>)([\s\S]*?)(<\/style>)|(\sstyle\s*=\s*)("([^"]*)"|'([^']*)')/gi, (_m, blockOpen, blockCss, blockClose, attrPrefix, attrQuoted, dq, sq) => {
956
+ if (blockOpen !== undefined) {
957
+ return blockOpen + rewriteAuthorCssForColumns(blockCss) + blockClose;
958
+ }
959
+ const quote = attrQuoted[0];
960
+ const value = dq !== undefined ? dq : sq;
961
+ return attrPrefix + quote + rewriteAuthorCssForColumns(value) + quote;
962
+ });
963
+ }
964
+ /** A bundle whose author CSS is mapped for the native-columns compositor: every
965
+ * shared `.css` file + each paginated frame's inline `<style>` blocks run through
966
+ * {@link rewriteAuthorCssForColumns}. Build-time (no sandbox runtime twin).
967
+ * Geometry, ids, furniture, and non-paginated frames are untouched, so the layout
968
+ * + predicates computed from it are identical to the source bundle. */
969
+ function rewriteBundleForColumns(bundle) {
970
+ const shared = bundle.shared;
971
+ const files = shared?.files;
972
+ const rewrittenFiles = files
973
+ ? Object.fromEntries(Object.entries(files).map(([path, content]) => [path, path.toLowerCase().endsWith('.css') ? rewriteAuthorCssForColumns(content) : content]))
974
+ : files;
975
+ return {
976
+ ...bundle,
977
+ // Rewrite shared `.css` files AND any `<style>` blocks in `shared.head` (an
978
+ // author can put CSS there too — its break/running rules must map for columns).
979
+ ...(shared
980
+ ? { shared: { ...shared, files: rewrittenFiles, ...(shared.head ? { head: rewritePaginatedContentStyles(shared.head) } : {}) } }
981
+ : {}),
982
+ frames: bundle.frames.map((frame) => isColumnPaginatedFrame(frame) ? { ...frame, content: rewritePaginatedContentStyles(frame.content) } : frame),
983
+ };
942
984
  }
943
- /** Whether a HOST-LESS render (PNG/PDF/inspect/standalone download) should carry
944
- * the Paged.js source to fragment this bundle into a page column. THE single
985
+ /** Whether a HOST-LESS render (PNG/PDF/inspect/standalone download) lays this
986
+ * bundle out as a scrolling page COLUMN of compositor-built page tiles. THE single
945
987
  * source of truth for that decision across every host-less caller (the headless
946
988
  * render, the export route, the MCP export tool), so none drifts from the
947
989
  * builder's actual column layout.
948
990
  *
949
991
  * Requires a FULLY-paginated document — every frame is a `pages` frame — with at
950
992
  * least one fixed page box. A MIXED artifact (pages + other layouts) is NOT
951
- * paginated host-less: the column layout ({@link isPagesArtifact}) wouldn't
952
- * apply, so it would fall to the clipped grid AND the render's page-box selector
953
- * would switch to `.pagedjs_page` and drop the non-page frames entirely. Instead
954
- * a mixed artifact renders as the grid with every frame present (the live editor,
955
- * which PROMOTES pages into the grid, is the surface that paginates mixed). This
956
- * also drops the `.some(layout === 'pages')` variants that shipped ~500KB of
957
- * Paged.js for an auto/fill `pages` frame the builder never paginates. */
993
+ * paginated host-less: the column layout ({@link isPagesArtifact}) wouldn't apply,
994
+ * so it would fall to the clipped grid AND the render's page-box selector would
995
+ * switch to the page TILES and drop the non-page frames entirely. Instead a mixed
996
+ * artifact renders as the grid with every frame present (the live editor, which
997
+ * composites pages into the grid, is the surface that paginates mixed). The
998
+ * compositor itself is always inlined (no library to load), so this no longer
999
+ * gates a multi-hundred-KB Paged.js fetch only the host-less COLUMN vs GRID
1000
+ * layout choice + the page-box selector. */
958
1001
  export function bundleNeedsPagination(bundle) {
959
1002
  return isPagesArtifact(bundle) && paginatedFrames(bundle).length > 0;
960
1003
  }
@@ -970,34 +1013,17 @@ function resolvePageMargins(frame) {
970
1013
  left: m?.left ?? DEFAULT_PAGE_MARGINS.left,
971
1014
  };
972
1015
  }
973
- /** The OVERRIDABLE `@page` defaults for one document frame: author margins and a
974
- * "Page N of M" counter in the bottom-RIGHT corner. Bottom-right (not -center) so
975
- * it never sits under a centered/left footer furniture band, which also lives in
976
- * the bottom margin. This sheet is applied BEFORE the author's shared CSS, so an
977
- * author can override the margin and the counter box with their own CSS Paged
978
- * Media. The page SIZE is NOT here — it is locked to the frame geometry by
979
- * pageSizeRuleCss (applied LAST) so author CSS can't desync the page box from the
980
- * canvas frame. Passed to Paged.js as stylesheet TEXT (never a URLno fetch
981
- * under `connect-src 'none'`). */
982
- function pageRuleCss(frame) {
983
- const m = resolvePageMargins(frame);
984
- return `@page{margin:${m.top}px ${m.right}px ${m.bottom}px ${m.left}px;`
985
- + `@bottom-right{content:"Page " counter(page) " of " counter(pages);`
986
- + `font:12px var(--sg-furniture-font, system-ui, -apple-system, sans-serif);color:#6b7280;}}`;
987
- }
988
- /** The AUTHORITATIVE `@page { size }` rule, locked to the frame geometry. Applied
989
- * as the LAST sheet (after author CSS) so the paginated page box always matches
990
- * the canvas frame's geometry — the page SIZE is the frame's `geometry`, not
991
- * something author CSS overrides (which would clip/gap the geometry-forced frame
992
- * box and desync the reported page count). Margins/margin-boxes stay overridable.
993
- * This locks the GENERIC `@page` size (the common case); a named/pseudo page with
994
- * its own size (e.g. `@page cover { size: A4 }`) is a deliberate advanced
995
- * divergence and is left to the author — it will simply not match the canvas
996
- * frame box, which is their choice. */
997
- function pageSizeRuleCss(frame) {
998
- const width = frame.geometry.width;
999
- const height = frame.geometry.height;
1000
- return `@page{size:${width}px ${height}px;}`;
1016
+ /** A content reset for the multi-column flow: cap replaced elements + tables to
1017
+ * the content-column width. Native multicol does NOT shrink an over-wide element
1018
+ * it overflows the column HORIZONTALLY, which inflates the two-pass page-count
1019
+ * measure (the Range right edge extends past the last column phantom pages).
1020
+ * Capping the common wide-content culprits (images, media, tables, code blocks) to
1021
+ * 100% keeps the measure honest AND matches the page-document contract (content
1022
+ * fits the page). Low specificity so an author can opt out. (An author who FORCES
1023
+ * a plain block wider than the column can still inflate the count documented.) */
1024
+ function columnFlowResetCss() {
1025
+ return `[data-sg-doc-flow] img{max-width:100%;height:auto;}
1026
+ [data-sg-doc-flow] svg,[data-sg-doc-flow] video,[data-sg-doc-flow] canvas,[data-sg-doc-flow] table,[data-sg-doc-flow] pre{max-width:100%;}`;
1001
1027
  }
1002
1028
  /** Base style for the injected per-page furniture (running header/footer). Only
1003
1029
  * font/color/positioning-context here — the actual top/bottom/left/right offset
@@ -1008,42 +1034,49 @@ function pageSizeRuleCss(frame) {
1008
1034
  function furniturePositionCss() {
1009
1035
  return `[data-sg-furniture]{position:absolute;font:12px var(--sg-furniture-font, system-ui, -apple-system, sans-serif);color:#374151;pointer-events:none;}`;
1010
1036
  }
1011
- /** Host CSS that neutralizes Paged.js's default preview chrome (gray backdrop,
1012
- * centering, shadows) so each promoted page sits flush as a white artboard, and
1013
- * carries the base style for the injected header/footer furniture (its offset is
1014
- * set inline per page from the frame's margins). */
1037
+ /** Host CSS for the editor/export GRID pagination path: just the base style for
1038
+ * the injected per-page furniture overlay (running header/footer + page number).
1039
+ * The page tiles themselves are positioned by frameLayoutCss / the host's
1040
+ * apply-layout, and carry their content window + multi-column flow + background
1041
+ * inline from the columns compositor. */
1015
1042
  function paginationHostCss() {
1016
- return `.pagedjs_pages{display:block;width:auto;padding:0;background:transparent;}
1017
- .pagedjs_page{margin:0;background:#fff;position:relative;box-shadow:none;}
1018
- .pagedjs_sheet{box-shadow:none;}
1019
- [data-sg-derived] .pagedjs_page{margin:0;}
1020
- ${furniturePositionCss()}`;
1043
+ return furniturePositionCss();
1021
1044
  }
1022
- /** The in-sandbox pagination bootstrap. After fonts settle it fragments each
1023
- * paginated `document` frame's flowing content via Paged.js and injects per-page
1024
- * running header/footer furniture (the first/last page variants override middle).
1045
+ export function columnPageGeometry(frame) {
1046
+ const margins = resolvePageMargins(frame);
1047
+ const width = frame.geometry.width;
1048
+ const height = frame.geometry.height;
1049
+ const contentWidth = width - margins.left - margins.right;
1050
+ const contentHeight = height - margins.top - margins.bottom;
1051
+ const gap = margins.right + margins.left + 40;
1052
+ return { width, height, contentWidth, contentHeight, gap, pitch: contentWidth + gap, margins };
1053
+ }
1054
+ /** The in-sandbox NATIVE-COLUMNS pagination compositor (replaces Paged.js). After
1055
+ * fonts settle it lays each paginated `document` frame's flowing content out as a
1056
+ * multi-column flow (one column == one page), then materializes discrete page
1057
+ * TILES — one `[data-sg-frame][data-sg-page]` per page, each a `width`×`height`
1058
+ * box with a margin-inset `overflow:hidden` window holding a clone of the flow
1059
+ * translated `−i*pitch` so only page `i` shows — plus the furniture overlay
1060
+ * (running header/footer + a default "Page N of M"). The break/running author CSS
1061
+ * was already mapped to columns at build time ({@link rewriteAuthorCssForColumns}).
1025
1062
  *
1026
- * `promote` selects the DOM outcome:
1027
- * - true (live host: editor/present): every page box is PROMOTED to a top-level
1028
- * `[data-sg-frame]` with a derived id (`<sourceId>__p<n>`) so derived pages
1029
- * reuse the same per-frame machinery as authored frames (host chrome,
1030
- * selection, present, inspect); a `frame-pages` message tells the host to lay
1031
- * them out in a column.
1032
- * - false (export/print, host:'none'): pages stay in Paged.js's native
1033
- * `.pagedjs_page` structure in place, so Chromium's print engine maps each
1034
- * page box to one PDF page (`preferCSSPageSize`) — no guillotine.
1063
+ * `promote` selects only the tile LAYOUT:
1064
+ * - true (live host: editor/present grid): tiles are absolutely placed (the host's
1065
+ * apply-layout refines them) and a `frame-pages` message reports the derived
1066
+ * `<sourceId>__p<n>` ids so the host lays them out + drives selection/zoom/pan.
1067
+ * - false (export / scroll present / standalone): tiles are laid out by CSS
1068
+ * (presentDocumentLayoutCss) as a centered scroll column; no host message.
1035
1069
  *
1036
- * Runs after the Paged.js source defines the UMD global. */
1037
- function paginationBootstrap(frames, artifactId, promote, sharedCss, renderId, hostedCanvas) {
1070
+ * Either way it sets `<html data-sg-paginated-done>` once finished the settle
1071
+ * signal a host-less render (PNG/PDF/inspect) waits on. NOTE: no backticks in this
1072
+ * string-built bootstrap — they would close the template literal. */
1073
+ function columnsCompositorBootstrap(frames, artifactId, promote, renderId, hostedCanvas, displayBackground) {
1038
1074
  const configById = {};
1039
1075
  for (const frame of frames) {
1040
1076
  configById[frame.id] = {
1041
- css: pageRuleCss(frame),
1042
- sizeCss: pageSizeRuleCss(frame),
1043
- width: frame.geometry.width,
1044
- height: frame.geometry.height,
1077
+ ...columnPageGeometry(frame),
1045
1078
  title: frame.title || 'Document',
1046
- margins: resolvePageMargins(frame),
1079
+ background: frameBackground(frame.layout, frame.background, displayBackground),
1047
1080
  ...(frame.furniture ? { furniture: frame.furniture } : {}),
1048
1081
  };
1049
1082
  }
@@ -1051,28 +1084,18 @@ function paginationBootstrap(frames, artifactId, promote, sharedCss, renderId, h
1051
1084
  (() => {
1052
1085
  const artifactId = ${JSON.stringify(artifactId)};
1053
1086
  const renderId = ${JSON.stringify(renderId)};
1054
- const GAP = ${FRAME_LAYOUT_DEFAULT_GAP};
1087
+ const FRAME_GAP = ${FRAME_LAYOUT_DEFAULT_GAP};
1055
1088
  const PROMOTE = ${JSON.stringify(promote)};
1056
1089
  const HOSTED_CANVAS = ${JSON.stringify(hostedCanvas)};
1057
- // Runtime twin of the core sanitizer (src/core/artifact-paged-css.ts), injected
1058
- // verbatim. Neutralizes every non-absolute url() to about:blank in the CSS handed
1059
- // to Paged.js, because Paged.js calls new URL(value) (no base) on each url() token
1060
- // and a scheme-less ref throws synchronously, aborting the whole pagination run.
1061
- // Only the Paged-fed copy is sanitized — the head <style> keeps real url()s and
1062
- // paints fonts/backgrounds. Runs AFTER asset resolution (the embedded SHARED_CSS
1063
- // is already token-rewritten), so it can't fight the ?at= rewrite.
1064
- ${SANITIZE_CSS_URLS_TWIN}
1065
- const SHARED_CSS = sanitizeCssUrlsForPagination(${JSON.stringify(sharedCss)});
1090
+ const DONE_ATTR = 'data-sg-paginated-done';
1066
1091
  const PAGINATE = ${JSON.stringify(configById)};
1067
1092
  const cssEscape = (value) => (window.CSS && CSS.escape) ? CSS.escape(value) : String(value);
1068
- // The UMD global is Paged (paged.js) or PagedModule (paged.min.js).
1069
- const Paged = window.Paged || window.PagedModule;
1070
- // Pick the page-position variant: page 1 -> first (or middle), last page -> last
1071
- // (or middle), every other page -> middle.
1093
+ // Pick the page-position furniture variant: page 1 -> first (or middle), last
1094
+ // page -> last (or middle), every other page -> middle.
1072
1095
  const pickSlot = (slots, index, count) => index === 0
1073
1096
  ? (slots.first || slots.middle)
1074
1097
  : (index === count - 1 ? (slots.last || slots.middle) : slots.middle);
1075
- const addRegion = (page, html, region, margins) => {
1098
+ const addRegion = (tile, html, region, margins) => {
1076
1099
  if (!html) return;
1077
1100
  const el = document.createElement('div');
1078
1101
  el.setAttribute('data-sg-furniture', '');
@@ -1081,11 +1104,11 @@ function paginationBootstrap(frames, artifactId, promote, sharedCss, renderId, h
1081
1104
  el.style.left = margins.left + 'px';
1082
1105
  el.style.right = margins.right + 'px';
1083
1106
  el.innerHTML = html;
1084
- page.appendChild(el);
1107
+ tile.appendChild(el);
1085
1108
  // Position from the MEASURED height so a tall letterhead never overlaps the
1086
1109
  // body: bottom-align the header to just above the content (within the top
1087
1110
  // margin), top-align the footer to just below the content (within the bottom
1088
- // margin). Clamp to a min inset from the page edge. The auto "Page N of M"
1111
+ // margin). Clamp to a min inset from the page edge. The default "Page N of M"
1089
1112
  // counter is bottom-RIGHT, so a centered/left footer band clears it. If a
1090
1113
  // margin is smaller than its furniture the author must grow pageMargins —
1091
1114
  // out-of-flow furniture can't reserve space.
@@ -1101,193 +1124,160 @@ function paginationBootstrap(frames, artifactId, promote, sharedCss, renderId, h
1101
1124
  // network-idle, so the reposition lands before capture).
1102
1125
  el.querySelectorAll('img').forEach((img) => { if (!img.complete) img.addEventListener('load', place); });
1103
1126
  };
1104
- const addFurniture = (page, furniture, index, count, margins) => {
1105
- if (furniture.header) addRegion(page, pickSlot(furniture.header, index, count), 'header', margins);
1106
- if (furniture.footer) addRegion(page, pickSlot(furniture.footer, index, count), 'footer', margins);
1127
+ const addFurniture = (tile, furniture, index, count, margins) => {
1128
+ if (furniture.header) addRegion(tile, pickSlot(furniture.header, index, count), 'header', margins);
1129
+ if (furniture.footer) addRegion(tile, pickSlot(furniture.footer, index, count), 'footer', margins);
1107
1130
  };
1108
- const paginate = async () => {
1109
- if (!Paged || !Paged.Previewer) return;
1110
- // Paged.js fragments after fonts settle so line breaks match the final type.
1111
- // The host inlines workspace-asset fonts as data: URLs, so this normally
1112
- // resolves immediately; but TIME-BOX it so a font that falls back to a slow
1113
- // network endpoint can't stall pagination for many seconds — past the cap we
1114
- // paginate with whatever loaded (worst case: a fallback-metric reflow when the
1115
- // late font swaps in, far better than a multi-second blank canvas).
1116
- try {
1117
- if (document.fonts && document.fonts.ready) {
1118
- await Promise.race([document.fonts.ready, new Promise(function (r) { setTimeout(r, ${FONT_READY_TIMEOUT_MS}); })]);
1119
- }
1120
- } catch {}
1131
+ // The DEFAULT page number, bottom-right within the bottom margin (replaces the
1132
+ // removed Paged.js @page{@bottom-right{counter(page)}} default). Baked as literal
1133
+ // text per tile no cross-tile counter() to keep so every document keeps page
1134
+ // numbers even when it declares no furniture.
1135
+ const addPageNumber = (tile, index, count, margins) => {
1136
+ const el = document.createElement('div');
1137
+ el.setAttribute('data-sg-furniture', '');
1138
+ el.setAttribute('data-sg-furniture-region', 'pagenum');
1139
+ el.style.right = margins.right + 'px';
1140
+ el.style.bottom = Math.max(8, Math.round(margins.bottom / 2) - 8) + 'px';
1141
+ el.style.color = '#6b7280';
1142
+ el.textContent = 'Page ' + (index + 1) + ' of ' + count;
1143
+ tile.appendChild(el);
1144
+ };
1145
+ const compose = async () => {
1121
1146
  const expansions = [];
1122
- // Frames whose pagination THREW (e.g. a CSS pattern Paged.js still chokes on).
1123
- // Reported to the host so it can surface a non-blocking "couldn't paginate"
1124
- // hint instead of silently showing a clipped, margin-less single page.
1147
+ // Frames whose composition THREW. Reported to the host so it can surface a
1148
+ // non-blocking "couldn't paginate" hint instead of a clipped single page.
1125
1149
  const failed = [];
1126
- for (const id of Object.keys(PAGINATE)) {
1127
- const source = document.querySelector('[data-sg-frame-id="' + cssEscape(id) + '"]');
1128
- if (!source || !source.parentNode) continue;
1129
- const cfg = PAGINATE[id];
1130
- const content = document.createElement('div');
1131
- // Wrap the flowing stream in a single container element. Paged.js's
1132
- // createBreakToken walks up from the break node and dereferences a null
1133
- // parent when the paginated root's DIRECT children are the breakable blocks
1134
- // (a long paragraph split mid-element crashes); a wrapper gives every block
1135
- // a non-null parent chain within the root. (Verified: bare children throw
1136
- // "Cannot read properties of null (reading 'getAttribute')"; wrapped works.)
1137
- content.innerHTML = '<div data-sg-doc-flow>' + source.innerHTML + '</div>';
1138
- // Hoist any <style>/<link> out of the flowing stream into <head>. A style
1139
- // node sitting IN the paginated flow trips Paged.js's fragmenter (it counts
1140
- // as a child to walk but has no box) — in the editor surface it yields a
1141
- // blank first page + over-pagination (e.g. an inline @font-face block paged a
1142
- // 2-page letter to 4). Styles still apply globally from <head>, where they
1143
- // belong. Only document frames reach this bootstrap, so decks/apps are
1144
- // untouched. We ALSO collect inline <style> text into inlineCss and feed it
1145
- // to the paginator below: Paged.js only honors @page / break / running rules
1146
- // from the sheets passed to preview(), so an author who put those in an
1147
- // inline <style> (rather than shared.files) would otherwise see them painted
1148
- // but ignored for pagination.
1149
- let inlineCss = '';
1150
- content.querySelectorAll('style, link[rel="stylesheet"]').forEach((node) => {
1151
- if (node.tagName === 'STYLE') inlineCss += (node.textContent || '') + ' ';
1152
- document.head.appendChild(node);
1153
- });
1154
- // Render straight into the source frame in BOTH modes (in place). Promote
1155
- // then moves each page box out to a top-level frame (the canvas owns layout);
1156
- // export leaves them in place so Paged.js's print structure maps to PDF pages.
1157
- // Rendering promote into an OFFSCREEN position:absolute container made Paged.js
1158
- // spuriously split the flow wrapper and emit a blank leading page (plus a
1159
- // tightened first page) whenever an author used a position:running() letterhead
1160
- // band — an editor-canvas-only defect (export, which always rendered in place,
1161
- // was clean). Rendering in place gives promote the exact in-flow geometry
1162
- // export uses, so the running-element pagination matches. data-sg-derived
1163
- // still tags the source so paginationHostCss neutralizes Paged.js preview
1164
- // chrome while the pages live inside it (before promotion).
1165
- // Capture the authored markup before clearing so a pagination FAILURE restores
1166
- // the frame to its EXACT original DOM (an un-paginated single flow), not the
1167
- // doc-flow-wrapped, style-hoisted content copy — which would drop direct-child
1168
- // selectors like [data-sg-frame-id] > .cover. (source still holds its original
1169
- // inline styles here: the hoist above moved style/link out of the content clone.)
1170
- const originalHtml = source.innerHTML;
1171
- if (PROMOTE) source.setAttribute('data-sg-derived', '');
1172
- source.innerHTML = '';
1173
- let pageEls;
1150
+ // ALWAYS run on every exit path (success, per-frame throw, or top-level throw):
1151
+ // mark the settle signal + post frame-pages, so a host-less render's wait
1152
+ // resolves and the live host never holds the reveal forever.
1153
+ const finish = () => {
1154
+ try { document.documentElement.setAttribute(DONE_ATTR, ''); } catch {}
1155
+ if (PROMOTE) {
1156
+ try { parent.postMessage({ channel: 'studiograph-artifact', artifactId: artifactId, type: 'frame-pages', renderId: renderId, expansions: expansions, failed: failed }, '*'); } catch {}
1157
+ }
1158
+ };
1159
+ try {
1160
+ // Measure after fonts settle so column breaks match the final type. Fonts are
1161
+ // inlined as data: URLs by the host, so this normally resolves immediately;
1162
+ // TIME-BOX it so a slow font endpoint can't stall composition for seconds.
1174
1163
  try {
1175
- const previewer = new Paged.Previewer();
1176
- // Sheet ORDER is the cascade, in three layers:
1177
- // 1. sg-pagination.css — the OVERRIDABLE platform defaults (margins +
1178
- // bottom-right page counter), FIRST so author CSS can override them.
1179
- // 2. sg-shared.css the AUTHOR's CSS, so their @page margins, margin
1180
- // boxes (@top-center/@bottom-left/…), named pages, running elements
1181
- // (position:running()), and break rules win over our defaults. This is
1182
- // what keeps documents fully open to divergent CSS Paged Media layouts.
1183
- // 3. sg-page-size.css — the AUTHORITATIVE @page{size}, LAST, locked to the
1184
- // frame geometry so author CSS can't desync the page box from the
1185
- // canvas frame (which is sized + laid out from geometry).
1186
- // (1b) sg-frame-inline.css CSS lifted from the frame's own inline <style>
1187
- // blocks, in the author layer so its @page/break/running rules paginate
1188
- // too. Objects are CSS TEXT (no fetch under connect-src 'none').
1189
- const sheets = [{ 'sg-pagination.css': cfg.css }];
1190
- if (SHARED_CSS) sheets.push({ 'sg-shared.css': SHARED_CSS });
1191
- // Sanitize inline frame CSS too: a frame <style> with a relative url()
1192
- // would crash Paged.js exactly like the shared sheet. (SHARED_CSS is
1193
- // sanitized once up top.)
1194
- const safeInlineCss = sanitizeCssUrlsForPagination(inlineCss);
1195
- if (safeInlineCss) sheets.push({ 'sg-frame-inline.css': safeInlineCss });
1196
- sheets.push({ 'sg-page-size.css': cfg.sizeCss });
1197
- await previewer.preview(content, sheets, source);
1198
- pageEls = Array.prototype.slice.call(source.querySelectorAll('.pagedjs_page'));
1199
- } catch (error) {
1200
- console.error('Artifact pagination failed for frame ' + id, error);
1201
- failed.push({ id: id, reason: String((error && error.message) || error) });
1202
- // Restore the source to its exact authored DOM as an un-paginated single flow
1203
- // (both modes now render in place, so the source was cleared above).
1204
- source.removeAttribute('data-sg-derived');
1205
- source.innerHTML = originalHtml;
1206
- continue;
1164
+ if (document.fonts && document.fonts.ready) {
1165
+ await Promise.race([document.fonts.ready, new Promise(function (r) { setTimeout(r, ${FONT_READY_TIMEOUT_MS}); })]);
1166
+ }
1167
+ } catch {}
1168
+ for (const id of Object.keys(PAGINATE)) {
1169
+ const source = document.querySelector('[data-sg-frame-id="' + cssEscape(id) + '"]');
1170
+ if (!source || !source.parentNode) continue;
1171
+ const cfg = PAGINATE[id];
1172
+ try {
1173
+ const PW = cfg.width, PH = cfg.height, CW = cfg.contentWidth, CH = cfg.contentHeight;
1174
+ const GAP = cfg.gap, PITCH = cfg.pitch, m = cfg.margins;
1175
+ // Hoist inline <style>/<link> out of the flow into <head> so they apply
1176
+ // ONCE (not duplicated per page clone) and don't sit inside the multicol
1177
+ // flow. They were rewritten for columns at build time with the shared CSS.
1178
+ source.querySelectorAll('style, link[rel="stylesheet"]').forEach((node) => document.head.appendChild(node));
1179
+ const flowHtml = source.innerHTML;
1180
+ // Two-pass deterministic geometry: multicol STRETCHES columns to fill the
1181
+ // container, drifting positions, so pass 1 measures the page count at a
1182
+ // wide width and pass 2 pins the flow to exactly n*CW+(n-1)*GAP (+ an
1183
+ // explicit column-count) so columns are exactly CW. Measure the real
1184
+ // content extent via a Range (stretched columns inflate scrollWidth and
1185
+ // would invent phantom empty pages).
1186
+ const master = document.createElement('div');
1187
+ master.setAttribute('data-sg-measure', '');
1188
+ // data-sg-doc-flow so the columnFlowResetCss media caps apply during the
1189
+ // measure too (else a wide img/table overflows here and over-counts pages).
1190
+ master.setAttribute('data-sg-doc-flow', '');
1191
+ master.style.cssText = 'position:absolute;left:-99999px;top:0;visibility:hidden;height:' + CH + 'px;width:8000px;column-width:' + CW + 'px;column-gap:' + GAP + 'px;column-fill:auto;';
1192
+ master.innerHTML = flowHtml;
1193
+ document.body.appendChild(master);
1194
+ const measure = () => {
1195
+ const range = document.createRange();
1196
+ range.selectNodeContents(master);
1197
+ const right = range.getBoundingClientRect().right - master.getBoundingClientRect().left;
1198
+ return Math.max(1, Math.ceil((right - 1) / PITCH));
1199
+ };
1200
+ let n = measure();
1201
+ let exact = n * CW + (n - 1) * GAP;
1202
+ master.style.width = exact + 'px';
1203
+ master.style.columnCount = String(n);
1204
+ n = measure();
1205
+ exact = n * CW + (n - 1) * GAP;
1206
+ document.body.removeChild(master);
1207
+ const reported = [];
1208
+ const startLeft = source.offsetLeft;
1209
+ let cursorTop = source.offsetTop;
1210
+ for (let i = 0; i < n; i++) {
1211
+ const pid = id + '__p' + (i + 1);
1212
+ const tile = document.createElement('section');
1213
+ tile.setAttribute('data-sg-frame', '');
1214
+ tile.setAttribute('data-sg-frame-id', pid);
1215
+ tile.setAttribute('data-frame-layout', 'pages');
1216
+ tile.setAttribute('data-sg-derived-from', id);
1217
+ tile.setAttribute('data-sg-page', String(i + 1));
1218
+ tile.setAttribute('role', 'group');
1219
+ tile.setAttribute('aria-label', cfg.title + ' — page ' + (i + 1));
1220
+ tile.style.width = PW + 'px';
1221
+ tile.style.height = PH + 'px';
1222
+ tile.style.background = cfg.background;
1223
+ // PROMOTE (editor grid): place absolutely; the host's apply-layout
1224
+ // refines with the same column math. Scroll modes leave the position to
1225
+ // presentDocumentLayoutCss (margin:0 auto centering).
1226
+ if (PROMOTE) { tile.style.left = startLeft + 'px'; tile.style.top = cursorTop + 'px'; cursorTop += PH + FRAME_GAP; }
1227
+ const win = document.createElement('div');
1228
+ win.setAttribute('data-sg-page-window', '');
1229
+ win.style.cssText = 'position:absolute;overflow:hidden;top:' + m.top + 'px;left:' + m.left + 'px;width:' + CW + 'px;height:' + CH + 'px;';
1230
+ const flow = document.createElement('div');
1231
+ flow.setAttribute('data-sg-doc-flow', '');
1232
+ flow.style.cssText = 'height:' + CH + 'px;width:' + exact + 'px;column-width:' + CW + 'px;column-gap:' + GAP + 'px;column-fill:auto;column-count:' + n + ';transform:translateX(' + (-i * PITCH) + 'px);';
1233
+ flow.innerHTML = flowHtml;
1234
+ // Clone hygiene: each tile holds a full clone of the flow, so author ids
1235
+ // repeat across tiles. We do NOT strip them — the clones are IDENTICAL,
1236
+ // so an SVG url(#grad)/href="#x" resolves to the first matching id (same
1237
+ // gradient/anchor on every page); stripping would instead break a
1238
+ // gradient's own def→fill reference WITHIN the clone. The host draws a11y
1239
+ // from its own per-tile proxies, so duplicate ids don't reach the a11y tree.
1240
+ win.appendChild(flow);
1241
+ tile.appendChild(win);
1242
+ addFurniture(tile, cfg.furniture || {}, i, n, m);
1243
+ addPageNumber(tile, i, n, m);
1244
+ source.parentNode.insertBefore(tile, source);
1245
+ reported.push({ id: pid, width: PW, height: PH });
1246
+ }
1247
+ source.parentNode.removeChild(source);
1248
+ expansions.push({ sourceId: id, title: cfg.title, pages: reported });
1249
+ } catch (error) {
1250
+ console.error('Artifact column pagination failed for frame ' + id, error);
1251
+ failed.push({ id: id, reason: String((error && error.message) || error) });
1252
+ // Leave the source frame in place as an un-paginated single flow.
1253
+ continue;
1254
+ }
1207
1255
  }
1208
- const furniture = cfg.furniture || {};
1209
- const margins = cfg.margins;
1210
- if (!PROMOTE) {
1211
- pageEls.forEach((page, index) => addFurniture(page, furniture, index, pageEls.length, margins));
1212
- source.setAttribute('data-sg-paginated', '');
1213
- source.setAttribute('data-sg-pages', String(pageEls.length));
1214
- continue;
1256
+ // A document's author CSS often sets a body background (the page color). The
1257
+ // hoisted inline styles + the head shared CSS can paint the WHOLE hosted
1258
+ // surface behind the tiles (the editor grid reads as a solid slab, present
1259
+ // loses page separation). Re-assert the transparent canvas LAST so the tiles
1260
+ // keep their own background and the gaps show the canvas. HOSTED_CANVAS only
1261
+ // (host iframe: editor grid + live present); standalone export / PDF / PNG —
1262
+ // where body IS the document backdrop — are untouched.
1263
+ if (HOSTED_CANVAS) {
1264
+ const canvasReset = document.createElement('style');
1265
+ canvasReset.setAttribute('data-sg-canvas-reset', '');
1266
+ canvasReset.textContent = 'html,body{background:transparent !important;}';
1267
+ document.head.appendChild(canvasReset);
1215
1268
  }
1216
- const startLeft = source.offsetLeft;
1217
- let cursorTop = source.offsetTop;
1218
- const reported = [];
1219
- pageEls.forEach((page, index) => {
1220
- addFurniture(page, furniture, index, pageEls.length, margins);
1221
- // Force the page-box size on the moved element so it survives leaving the
1222
- // Paged.js .pagedjs_pages flow; margin boxes use custom props set on
1223
- // .pagedjs_page itself, which travel with it.
1224
- page.style.width = cfg.width + 'px';
1225
- page.style.height = cfg.height + 'px';
1226
- const pid = id + '__p' + (index + 1);
1227
- const frame = document.createElement('section');
1228
- frame.setAttribute('data-sg-frame', '');
1229
- frame.setAttribute('data-sg-frame-id', pid);
1230
- frame.setAttribute('data-frame-layout', 'pages');
1231
- frame.setAttribute('data-sg-derived-from', id);
1232
- frame.setAttribute('data-sg-page', String(index + 1));
1233
- frame.setAttribute('role', 'group');
1234
- frame.setAttribute('aria-label', cfg.title + ' — page ' + (index + 1));
1235
- frame.style.width = cfg.width + 'px';
1236
- frame.style.height = cfg.height + 'px';
1237
- // Re-establish BOTH page counters that Paged.js kept on the .pagedjs_pages
1238
- // container we removed by promoting each page to its own top-level frame:
1239
- // the pages-total on EVERY page so "of M" resolves M (not 0), and a
1240
- // page-zero reset on the FIRST page so the running number accumulates across
1241
- // the promoted sibling frames (1..M). Without an explicit reset of the page
1242
- // counter on a common scope, Chromium implicitly resets it per element and
1243
- // every footer reads "Page 1". counter-reset scopes to following siblings,
1244
- // so resetting on the first page also restarts numbering per document when an
1245
- // artifact has more than one paginated frame.
1246
- frame.style.counterReset = (index === 0 ? 'page 0 ' : '') + 'pages ' + pageEls.length;
1247
- // Initial column placement (host apply-layout refines with the same math).
1248
- frame.style.left = startLeft + 'px';
1249
- frame.style.top = cursorTop + 'px';
1250
- cursorTop += cfg.height + GAP;
1251
- frame.appendChild(page);
1252
- source.parentNode.insertBefore(frame, source);
1253
- reported.push({ id: pid, width: cfg.width, height: cfg.height });
1254
- });
1255
- // Promotion moved every page box out of the source frame; drop the now-empty
1256
- // source (its Paged.js .pagedjs_pages shell). Both modes render in place now,
1257
- // so there is no separate offscreen container to remove.
1258
- source.parentNode.removeChild(source);
1259
- expansions.push({ sourceId: id, title: cfg.title, pages: reported });
1260
- }
1261
- // A document's author CSS usually sets a body background (the page color). The
1262
- // bootstrap hoists the frame's inline style to head at runtime, so that rule wins
1263
- // over the platform's transparent canvas and paints the WHOLE hosted surface
1264
- // behind the page boxes -- the editor grid reads as a solid slab, the present
1265
- // view loses its page separation -- instead of pages floating on the canvas.
1266
- // Re-assert the transparent canvas LAST (after the hoisted author styles): the
1267
- // page boxes keep their own background, so pages stay their page color and the
1268
- // gaps show the canvas. HOSTED_CANVAS only (host iframe: editor grid + live
1269
- // present), so standalone export / PDF / PNG -- where body IS the document
1270
- // backdrop -- are untouched. Runs for both promote and present (present returns
1271
- // just below). (Only document frames reach this bootstrap. NOTE: no backticks in
1272
- // this string-built bootstrap -- they close the template literal.)
1273
- if (HOSTED_CANVAS) {
1274
- const canvasReset = document.createElement('style');
1275
- canvasReset.setAttribute('data-sg-canvas-reset', '');
1276
- canvasReset.textContent = 'html,body{background:transparent !important;}';
1277
- document.head.appendChild(canvasReset);
1269
+ if (PROMOTE) {
1270
+ // Renumber data-frame-index across all top-level frames in DOM order so
1271
+ // selection's frameIndex stays consistent after the tiles replace the source.
1272
+ Array.prototype.slice.call(document.querySelectorAll('[data-sg-frame]'))
1273
+ .forEach((el, i) => el.setAttribute('data-frame-index', String(i)));
1274
+ }
1275
+ } finally {
1276
+ finish();
1278
1277
  }
1279
- if (!PROMOTE) return;
1280
- // Renumber data-frame-index across all top-level frames in DOM order so
1281
- // selection's frameIndex stays consistent after promotion.
1282
- Array.prototype.slice.call(document.querySelectorAll('[data-sg-frame]'))
1283
- .forEach((el, i) => el.setAttribute('data-frame-index', String(i)));
1284
- // Always post — even with zero successful expansions — so the host knows
1285
- // pagination has SETTLED and stops holding the reveal (a frame that failed
1286
- // to paginate just renders as its un-paginated single flow).
1287
- try { parent.postMessage({ channel: 'studiograph-artifact', artifactId, type: 'frame-pages', renderId, expansions, failed }, '*'); } catch {}
1288
1278
  };
1289
- if (document.readyState === 'loading') window.addEventListener('DOMContentLoaded', () => { paginate(); });
1290
- else paginate();
1279
+ if (document.readyState === 'loading') window.addEventListener('DOMContentLoaded', () => { compose(); });
1280
+ else compose();
1291
1281
  })();`;
1292
1282
  }
1293
1283
  /**
@@ -1319,8 +1309,8 @@ export function buildFrameDocument(bundle, ctx) {
1319
1309
  const deckStandalone = ctx.host === 'none' && !!ctx.interactive
1320
1310
  && !whiteboard && !fullBleed && !isPagesArtifact(bundle);
1321
1311
  // A present document scrolls through its pages (PDF-style) instead of stepping
1322
- // frame-by-frame: it keeps Paged.js's natural vertical .pagedjs_pages flow (no
1323
- // promotion) and uses the scroll-column layout + a non-stepper runtime.
1312
+ // frame-by-frame: the compositor builds page tiles (no host promotion) laid out
1313
+ // by the scroll-column layout, with a non-stepper runtime.
1324
1314
  const presentDocument = isPresent && !fullBleed && isPagesArtifact(bundle);
1325
1315
  // A standalone (downloaded HTML) document reads the same way: a PDF-style page
1326
1316
  // column that scrolls natively. surface is 'export' (not 'present') and there is
@@ -1330,10 +1320,9 @@ export function buildFrameDocument(bundle, ctx) {
1330
1320
  const documentStandalone = ctx.host === 'none' && !!ctx.interactive && !fullBleed && isPagesArtifact(bundle);
1331
1321
  // A STATIC document export (PNG/PDF — host:'none', non-interactive) lays pages
1332
1322
  // out as a column too. The grid would give the single `pages` source frame a
1333
- // one-page fixed height + overflow:hidden, clipping every page after the first
1334
- // (Paged.js stacks them inside that clipped box); the column stacks each
1335
- // `.pagedjs_page` as a visible, measurable box so per-page PNG/PDF capture sees
1336
- // every page.
1323
+ // one-page fixed height + overflow:hidden, clipping every page after the first;
1324
+ // the column stacks each compositor-built page tile as a visible, measurable box
1325
+ // so per-page PNG/PDF capture sees every page.
1337
1326
  const documentExport = ctx.host === 'none' && !ctx.interactive && !fullBleed && isPagesArtifact(bundle);
1338
1327
  // Every path that lays pages out as a column rather than the grid.
1339
1328
  const scrollDocument = presentDocument || documentStandalone || documentExport;
@@ -1349,7 +1338,7 @@ export function buildFrameDocument(bundle, ctx) {
1349
1338
  : fullBleed
1350
1339
  ? fullBleedLayoutCss(bundle)
1351
1340
  : scrollDocument
1352
- ? presentDocumentLayoutCss(bundle, surface)
1341
+ ? presentDocumentLayoutCss(bundle)
1353
1342
  : isPresent && !whiteboard
1354
1343
  ? presentLayoutCss(bundle)
1355
1344
  : frameLayoutCss(bundle, layout, ctx.host === 'iframe' ? ctx.colorScheme : undefined);
@@ -1367,12 +1356,26 @@ export function buildFrameDocument(bundle, ctx) {
1367
1356
  : isPresent && !presentDocument && !whiteboard
1368
1357
  ? presentRuntimeScript(bundle.artifactId)
1369
1358
  : frameRuntimeScript(bundle.artifactId, surface, fullBleed, whiteboard);
1370
- // Document frames paginate when a Paged.js source is supplied (full-bleed is a
1371
- // single non-document frame, never paginated).
1372
- const paginate = !fullBleed && ctx.pagedSource ? paginatedFrames(bundle) : [];
1373
- // Promote each page to a top-level frame for the live host's grid (editor) but
1374
- // NOT for a present document, which keeps Paged.js's native vertical flow so its
1375
- // pages scroll as one column (export/host:none never promotes either).
1359
+ // Document frames paginate via the inlined native-columns compositor (no library
1360
+ // to load). Full-bleed is a single non-document frame, never paginated. The gate
1361
+ // is host-aware (matches the pre-columns pagedSource gate, so behavior is
1362
+ // unchanged): a LIVE host (editor/present iframe) composites ANY paginated frame
1363
+ // into the grid including a MIXED artifact's lone `pages` frame, which the host
1364
+ // promotes alongside the other frames. A HOST-LESS render only composites a PURE
1365
+ // pages document (every frame is `pages` → the scroll-column layout applies); a
1366
+ // mixed host-less render leaves the `pages` frame as a single grid box (no scroll
1367
+ // column to lay tiles in → unpositioned tiles would overlap), exactly as
1368
+ // `bundleNeedsPagination` (isPagesArtifact) gated before.
1369
+ const paginatable = !fullBleed ? paginatedFrames(bundle) : [];
1370
+ const paginate = paginatable.length > 0 && (ctx.host === 'iframe' || isPagesArtifact(bundle)) ? paginatable : [];
1371
+ // When the doc paginates, map its author CSS (shared + paginated frames' inline
1372
+ // <style>) for columns — forced page breaks → column breaks, position:running()
1373
+ // neutralized — so the head + body emit the column-ready CSS. Geometry/ids/layout
1374
+ // are unchanged, so everything else still reads from the source `bundle`.
1375
+ const docBundle = paginate.length ? rewriteBundleForColumns(bundle) : bundle;
1376
+ // Compose pages into the live host's grid as absolutely-placed tiles (editor) —
1377
+ // but NOT for a present document, which lays its page tiles out as a CSS scroll
1378
+ // column (export/host:none never promotes either).
1376
1379
  const promote = ctx.host === 'iframe' && !presentDocument;
1377
1380
  const head = [
1378
1381
  '<meta charset="utf-8">',
@@ -1380,21 +1383,24 @@ export function buildFrameDocument(bundle, ctx) {
1380
1383
  ctx.baseHref ? `<base href="${escapeAttribute(ctx.baseHref)}">` : '',
1381
1384
  '<meta name="generator" content="Studiograph Artifact Canvas">',
1382
1385
  `<title>${escapeHtml(bundle.title)}</title>`,
1383
- bundle.shared?.head ?? '',
1386
+ docBundle.shared?.head ?? '',
1384
1387
  tokensStyleTag(bundle.tokens),
1385
1388
  // The whiteboard ink-token contract (theme-aware --sg-ink/-muted/-surface).
1386
1389
  // Before the author's shared CSS so authored rules can reference the vars;
1387
1390
  // only a whiteboard surface is platform-themed, so other layouts emit nothing.
1388
1391
  whiteboard ? `<style data-artifact-whiteboard-tokens>${whiteboardThemeTokensCss(ctx.colorScheme, ctx.host, whiteboardStandalone)}</style>` : '',
1389
- sharedStyleTags(bundle),
1392
+ sharedStyleTags(docBundle),
1390
1393
  `<style data-artifact-frame-layout>\n${layoutCss}\n</style>`,
1391
1394
  // Bake the grab cursor for an always-hand surface (present whiteboard) so it
1392
1395
  // shows from first paint instead of waiting for the runtime's load handler.
1393
1396
  handCursorByDefault ? '<style data-artifact-cursor>html,body{cursor:grab;}</style>' : '',
1394
- // The editor/export page-neutralization + furniture CSS. A present document
1395
- // folds its own page chrome + furniture into presentDocumentLayoutCss (its
1396
- // pages scroll, they aren't promoted/printed), so it skips this.
1397
+ // The editor/export GRID furniture base CSS. A scroll document folds the same
1398
+ // furniture base into presentDocumentLayoutCss, so it skips this.
1397
1399
  paginate.length && !scrollDocument ? `<style data-artifact-pagination>\n${paginationHostCss()}\n</style>` : '',
1400
+ // Content reset for the multi-column flow (caps over-wide media/tables so they
1401
+ // don't overflow the column and inflate the page-count measure). Applies to the
1402
+ // measure master + every tile clone (both carry data-sg-doc-flow).
1403
+ paginate.length ? `<style data-artifact-columns-reset>\n${columnFlowResetCss()}\n</style>` : '',
1398
1404
  libraryTags(bundle.libraries ?? [], ctx.librarySources),
1399
1405
  // A live host (iframe) drives chrome/transform; a standalone interactive
1400
1406
  // whiteboard / deck self-hosts its own pan-zoom / stepper — all need the runtime.
@@ -1406,11 +1412,10 @@ export function buildFrameDocument(bundle, ctx) {
1406
1412
  // (a present document scrolls; a whiteboard/full-bleed present fills).
1407
1413
  const staticPresentActive = ctx.host === 'none' && isPresent && !scrollDocument && !whiteboard && !fullBleed;
1408
1414
  const body = [
1409
- frameContainersHtml(bundle, staticPresentActive),
1415
+ frameContainersHtml(docBundle, staticPresentActive),
1410
1416
  whiteboardStandalone ? whiteboardStandaloneToolbarHtml() : '',
1411
1417
  deckStandalone ? deckStandaloneControlsHtml() : '',
1412
- paginate.length ? `<script data-artifact-paged>${escapedPagedSource(ctx.pagedSource)}</script>` : '',
1413
- paginate.length ? `<script data-artifact-pagination>${escapeScript(paginationBootstrap(paginate, bundle.artifactId, promote, sharedCssText(bundle), ctx.renderId ?? '', ctx.host === 'iframe'))}</script>` : '',
1418
+ paginate.length ? `<script data-artifact-pagination>${escapeScript(columnsCompositorBootstrap(paginate, bundle.artifactId, promote, ctx.renderId ?? '', ctx.host === 'iframe', bundle.display?.background))}</script>` : '',
1414
1419
  brokenImageScriptTag(),
1415
1420
  sharedScriptTags(bundle),
1416
1421
  ].filter(Boolean).join('\n');