@storystick/preview 0.0.0-stage → 0.2.0-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (245) hide show
  1. package/LICENSE +112 -0
  2. package/PUBLIC-API.md +55 -0
  3. package/README.md +42 -2
  4. package/dist/.source-hash +1 -0
  5. package/dist/authoring/runtime-contract.d.ts +89 -0
  6. package/dist/authoring/runtime-contract.js +28 -0
  7. package/dist/channel-taxonomy.d.ts +17 -0
  8. package/dist/channel-taxonomy.js +46 -0
  9. package/dist/context-command.d.ts +18 -0
  10. package/dist/context-command.js +38 -0
  11. package/dist/dev/suspicious-codeword-detector.d.ts +15 -0
  12. package/dist/dev/suspicious-codeword-detector.js +220 -0
  13. package/dist/dev/suspicious-codeword-detector.noop.d.ts +3 -0
  14. package/dist/dev/suspicious-codeword-detector.noop.js +4 -0
  15. package/dist/framework/browser-context.d.ts +2 -0
  16. package/dist/framework/browser-context.js +2 -0
  17. package/dist/framework/generation-refresh.d.ts +23 -0
  18. package/dist/framework/generation-refresh.js +82 -0
  19. package/dist/framework/generation-signal.d.ts +8 -0
  20. package/dist/framework/generation-signal.js +17 -0
  21. package/dist/framework/projection-generation-watch.d.ts +5 -0
  22. package/dist/framework/projection-generation-watch.js +53 -0
  23. package/dist/framework/render-receipt.d.ts +8 -0
  24. package/dist/framework/render-receipt.js +10 -0
  25. package/dist/framework/runtime-adapter-store.d.ts +30 -0
  26. package/dist/framework/runtime-adapter-store.js +81 -0
  27. package/dist/grant-coordinator.d.ts +53 -0
  28. package/dist/grant-coordinator.js +329 -0
  29. package/dist/grant-lifecycle.d.ts +52 -0
  30. package/dist/grant-lifecycle.js +74 -0
  31. package/dist/identity/__test-adapter.d.ts +34 -0
  32. package/dist/identity/__test-adapter.js +59 -0
  33. package/dist/identity/browser-context-gate.d.ts +4 -0
  34. package/dist/identity/browser-context-gate.js +22 -0
  35. package/dist/identity/browser-context.d.ts +13 -0
  36. package/dist/identity/browser-context.js +22 -0
  37. package/dist/identity/carriers/postmessage.d.ts +84 -0
  38. package/dist/identity/carriers/postmessage.js +177 -0
  39. package/dist/identity/carriers/storage-state.d.ts +41 -0
  40. package/dist/identity/carriers/storage-state.js +99 -0
  41. package/dist/identity/carriers/storage.d.ts +39 -0
  42. package/dist/identity/carriers/storage.js +182 -0
  43. package/dist/identity/carriers/url.d.ts +19 -0
  44. package/dist/identity/carriers/url.js +38 -0
  45. package/dist/identity/identity-state-machine.d.ts +70 -0
  46. package/dist/identity/identity-state-machine.js +244 -0
  47. package/dist/identity/identity-types.d.ts +192 -0
  48. package/dist/identity/identity-types.js +22 -0
  49. package/dist/identity/index.d.ts +18 -0
  50. package/dist/identity/index.js +16 -0
  51. package/dist/identity/preview-cookie.d.ts +9 -0
  52. package/dist/identity/preview-cookie.js +12 -0
  53. package/dist/identity/preview-draft.d.ts +6 -0
  54. package/dist/identity/preview-draft.js +25 -0
  55. package/dist/identity/preview-identity.d.ts +28 -0
  56. package/dist/identity/preview-identity.js +84 -0
  57. package/dist/identity/preview-scope.d.ts +28 -0
  58. package/dist/identity/preview-scope.js +47 -0
  59. package/dist/identity/resolver.d.ts +110 -0
  60. package/dist/identity/resolver.js +334 -0
  61. package/dist/identity/session-query.d.ts +4 -0
  62. package/dist/identity/session-query.js +19 -0
  63. package/dist/index.d.ts +90 -0
  64. package/dist/index.js +788 -0
  65. package/dist/interaction-mode.d.ts +58 -0
  66. package/dist/interaction-mode.js +93 -0
  67. package/dist/nav-handler.d.ts +95 -0
  68. package/dist/nav-handler.js +84 -0
  69. package/dist/overlay-label.d.ts +79 -0
  70. package/dist/overlay-label.js +73 -0
  71. package/dist/provenance/anchor-map.d.ts +95 -0
  72. package/dist/provenance/anchor-map.js +177 -0
  73. package/dist/provenance/carrier-plan.d.ts +51 -0
  74. package/dist/provenance/carrier-plan.js +90 -0
  75. package/dist/provenance/coded-text.d.ts +20 -0
  76. package/dist/provenance/coded-text.js +302 -0
  77. package/dist/provenance/direct-edit-locator.d.ts +29 -0
  78. package/dist/provenance/direct-edit-locator.js +218 -0
  79. package/dist/provenance/editable-leaf.d.ts +134 -0
  80. package/dist/provenance/editable-leaf.js +483 -0
  81. package/dist/provenance/editor-target.d.ts +29 -0
  82. package/dist/provenance/editor-target.js +46 -0
  83. package/dist/provenance/field-leaf-walker.d.ts +21 -0
  84. package/dist/provenance/field-leaf-walker.js +98 -0
  85. package/dist/provenance/frame-dependencies.d.ts +212 -0
  86. package/dist/provenance/frame-dependencies.js +550 -0
  87. package/dist/provenance/index.d.ts +26 -0
  88. package/dist/provenance/index.js +26 -0
  89. package/dist/provenance/local-events.d.ts +1 -0
  90. package/dist/provenance/local-events.js +1 -0
  91. package/dist/provenance/markers.d.ts +21 -0
  92. package/dist/provenance/markers.js +70 -0
  93. package/dist/provenance/multi-frame-anchor-map.d.ts +54 -0
  94. package/dist/provenance/multi-frame-anchor-map.js +109 -0
  95. package/dist/provenance/normalize.d.ts +2 -0
  96. package/dist/provenance/normalize.js +19 -0
  97. package/dist/provenance/overlay-focus-echo.d.ts +17 -0
  98. package/dist/provenance/overlay-focus-echo.js +16 -0
  99. package/dist/provenance/overlay-projection.d.ts +65 -0
  100. package/dist/provenance/overlay-projection.js +201 -0
  101. package/dist/provenance/owner-map.d.ts +55 -0
  102. package/dist/provenance/owner-map.js +239 -0
  103. package/dist/provenance/read-carrier.d.ts +22 -0
  104. package/dist/provenance/read-carrier.js +92 -0
  105. package/dist/provenance/recorder.d.ts +38 -0
  106. package/dist/provenance/recorder.js +82 -0
  107. package/dist/provenance/render-context.d.ts +106 -0
  108. package/dist/provenance/render-context.js +338 -0
  109. package/dist/provenance/reveal-contract.d.ts +44 -0
  110. package/dist/provenance/reveal-contract.js +1 -0
  111. package/dist/provenance/richtext-slice-embed-address.d.ts +20 -0
  112. package/dist/provenance/richtext-slice-embed-address.js +35 -0
  113. package/dist/provenance/runtime-script.d.ts +1 -0
  114. package/dist/provenance/runtime-script.js +2217 -0
  115. package/dist/provenance/sidecar-store.d.ts +63 -0
  116. package/dist/provenance/sidecar-store.js +151 -0
  117. package/dist/provenance/sidecar.d.ts +34 -0
  118. package/dist/provenance/sidecar.js +227 -0
  119. package/dist/provenance/slice-model.d.ts +28 -0
  120. package/dist/provenance/slice-model.js +86 -0
  121. package/dist/provenance/source-identity.d.ts +3 -0
  122. package/dist/provenance/source-identity.js +7 -0
  123. package/dist/provenance/source-map-materializer.d.ts +76 -0
  124. package/dist/provenance/source-map-materializer.js +286 -0
  125. package/dist/provenance/source-registry.d.ts +103 -0
  126. package/dist/provenance/source-registry.js +562 -0
  127. package/dist/provenance/transformed-output-gate.d.ts +161 -0
  128. package/dist/provenance/transformed-output-gate.js +171 -0
  129. package/dist/provenance/types.d.ts +102 -0
  130. package/dist/provenance/types.js +1 -0
  131. package/dist/refresh-policy.d.ts +11 -0
  132. package/dist/refresh-policy.js +14 -0
  133. package/dist/reveal-target.d.ts +8 -0
  134. package/dist/reveal-target.js +47 -0
  135. package/dist/route-paths.d.ts +11 -0
  136. package/dist/route-paths.js +14 -0
  137. package/dist/router-subscriber.d.ts +92 -0
  138. package/dist/router-subscriber.js +44 -0
  139. package/dist/runtime/anchor-types.d.ts +5 -0
  140. package/dist/runtime/anchor-types.js +1 -0
  141. package/dist/runtime/authority-store.d.ts +31 -0
  142. package/dist/runtime/authority-store.js +56 -0
  143. package/dist/runtime/boot.d.ts +52 -0
  144. package/dist/runtime/boot.js +379 -0
  145. package/dist/runtime/content-refresh.d.ts +30 -0
  146. package/dist/runtime/content-refresh.js +68 -0
  147. package/dist/runtime/emit-runtime-error.d.ts +51 -0
  148. package/dist/runtime/emit-runtime-error.js +64 -0
  149. package/dist/runtime/field-proxy.d.ts +112 -0
  150. package/dist/runtime/field-proxy.js +471 -0
  151. package/dist/runtime/form-contract-reporter.d.ts +99 -0
  152. package/dist/runtime/form-contract-reporter.js +391 -0
  153. package/dist/runtime/invalidation-adapter.d.ts +17 -0
  154. package/dist/runtime/invalidation-adapter.js +42 -0
  155. package/dist/runtime/live-query-overlay.d.ts +39 -0
  156. package/dist/runtime/live-query-overlay.js +345 -0
  157. package/dist/runtime/momentary-navigate.d.ts +16 -0
  158. package/dist/runtime/momentary-navigate.js +106 -0
  159. package/dist/runtime/observation-scheduler.d.ts +15 -0
  160. package/dist/runtime/observation-scheduler.js +42 -0
  161. package/dist/runtime/perf-metrics.d.ts +28 -0
  162. package/dist/runtime/perf-metrics.js +90 -0
  163. package/dist/runtime/preview-interaction-policy.d.ts +1 -0
  164. package/dist/runtime/preview-interaction-policy.js +103 -0
  165. package/dist/runtime/preview-overlay-painter.d.ts +27 -0
  166. package/dist/runtime/preview-overlay-painter.js +1732 -0
  167. package/dist/runtime/preview-overlay-sidecar.d.ts +26 -0
  168. package/dist/runtime/preview-overlay-sidecar.js +45 -0
  169. package/dist/runtime/preview-perspective-alignment.d.ts +15 -0
  170. package/dist/runtime/preview-perspective-alignment.js +40 -0
  171. package/dist/runtime/preview-perspective-intent-state.d.ts +23 -0
  172. package/dist/runtime/preview-perspective-intent-state.js +5 -0
  173. package/dist/runtime/preview-transition-state.d.ts +142 -0
  174. package/dist/runtime/preview-transition-state.js +257 -0
  175. package/dist/runtime/render-mode-state.d.ts +51 -0
  176. package/dist/runtime/render-mode-state.js +76 -0
  177. package/dist/runtime/request-id.d.ts +4 -0
  178. package/dist/runtime/request-id.js +8 -0
  179. package/dist/runtime/resolve-schema-field.d.ts +99 -0
  180. package/dist/runtime/resolve-schema-field.js +356 -0
  181. package/dist/runtime/runtime-error-dispatch.d.ts +23 -0
  182. package/dist/runtime/runtime-error-dispatch.js +75 -0
  183. package/dist/runtime/schema-store.d.ts +121 -0
  184. package/dist/runtime/schema-store.js +667 -0
  185. package/dist/runtime/snapshot-assembly.d.ts +104 -0
  186. package/dist/runtime/snapshot-assembly.js +553 -0
  187. package/dist/runtime/snapshot-reporter.d.ts +55 -0
  188. package/dist/runtime/snapshot-reporter.js +375 -0
  189. package/dist/runtime/standalone-overlay-controller.d.ts +33 -0
  190. package/dist/runtime/standalone-overlay-controller.js +281 -0
  191. package/dist/runtime/store.d.ts +307 -0
  192. package/dist/runtime/store.js +1026 -0
  193. package/dist/runtime/windowed-minimap-projection.d.ts +28 -0
  194. package/dist/runtime/windowed-minimap-projection.js +189 -0
  195. package/dist/runtime/workspace-shortcuts.d.ts +30 -0
  196. package/dist/runtime/workspace-shortcuts.js +188 -0
  197. package/dist/sdk-error-copy.d.ts +48 -0
  198. package/dist/sdk-error-copy.js +182 -0
  199. package/dist/select-cursor.d.ts +2 -0
  200. package/dist/select-cursor.js +23 -0
  201. package/dist/server/admission-cookie.d.ts +38 -0
  202. package/dist/server/admission-cookie.js +122 -0
  203. package/dist/server/control-client.d.ts +29 -0
  204. package/dist/server/control-client.js +22 -0
  205. package/dist/server/control-errors.d.ts +22 -0
  206. package/dist/server/control-errors.js +165 -0
  207. package/dist/server/draft-route.d.ts +12 -0
  208. package/dist/server/draft-route.js +45 -0
  209. package/dist/server/extract-token.d.ts +30 -0
  210. package/dist/server/extract-token.js +98 -0
  211. package/dist/server/grant.d.ts +128 -0
  212. package/dist/server/grant.js +395 -0
  213. package/dist/server/ordinary-read-provenance.d.ts +11 -0
  214. package/dist/server/ordinary-read-provenance.js +124 -0
  215. package/dist/server/preview-read-metadata.d.ts +10 -0
  216. package/dist/server/preview-read-metadata.js +27 -0
  217. package/dist/server/provenance-frame-builder.d.ts +53 -0
  218. package/dist/server/provenance-frame-builder.js +102 -0
  219. package/dist/server/provenance-runtime.d.ts +70 -0
  220. package/dist/server/provenance-runtime.js +46 -0
  221. package/dist/server/read-context-command.d.ts +11 -0
  222. package/dist/server/read-context-command.js +53 -0
  223. package/dist/server/read-identity.d.ts +3 -0
  224. package/dist/server/read-identity.js +63 -0
  225. package/dist/server/read-lifecycle.d.ts +17 -0
  226. package/dist/server/read-lifecycle.js +52 -0
  227. package/dist/server/render-frame-provenance.d.ts +133 -0
  228. package/dist/server/render-frame-provenance.js +676 -0
  229. package/dist/server/render-id.d.ts +15 -0
  230. package/dist/server/render-id.js +22 -0
  231. package/dist/server/route-document-provenance.d.ts +77 -0
  232. package/dist/server/route-document-provenance.js +1327 -0
  233. package/dist/server/route-session-cookie.d.ts +33 -0
  234. package/dist/server/route-session-cookie.js +54 -0
  235. package/dist/session-projections.d.ts +33 -0
  236. package/dist/session-projections.js +43 -0
  237. package/dist/session-subscriber.d.ts +124 -0
  238. package/dist/session-subscriber.js +79 -0
  239. package/dist/sha256-fallback.d.ts +30 -0
  240. package/dist/sha256-fallback.js +126 -0
  241. package/dist/types.d.ts +446 -0
  242. package/dist/types.js +85 -0
  243. package/dist/validators.d.ts +61 -0
  244. package/dist/validators.js +110 -0
  245. package/package.json +206 -3
@@ -0,0 +1,471 @@
1
+ import { isPreviewBrowserContext } from '../identity/browser-context.js';
2
+ // ─────────────────────────────────────────────
3
+ // Activation gate (production zero-overhead — ADR-125 D3, Lane G)
4
+ // ─────────────────────────────────────────────
5
+ /**
6
+ * Field tracking is active inside the preview iframe or on a top-level page
7
+ * carrying the explicit scrubbed `_ss_session` selector. In a
8
+ * production customer app the package isn't even loaded; this is the
9
+ * belt-and-braces second line so `wrapWithProxy` is a referential
10
+ * identity no-op everywhere else. Overridable for tests.
11
+ */
12
+ let trackingForced = null;
13
+ export function __setFieldTrackingForcedForTests(v) {
14
+ trackingForced = v;
15
+ }
16
+ export function isFieldTrackingActive() {
17
+ if (trackingForced !== null)
18
+ return trackingForced;
19
+ return isPreviewBrowserContext();
20
+ }
21
+ // ─────────────────────────────────────────────
22
+ // Module model — accessed leaves + registered roots
23
+ // ─────────────────────────────────────────────
24
+ /** Deduped accessed-leaf set (lazy path). Keyed `${docId}\x00${path}` so
25
+ * the same path under two docs (cross-doc) is kept distinct and a
26
+ * re-access overwrites rather than unbounded-grows. */
27
+ const accessed = new Map();
28
+ /**
29
+ * Roots registered by `wrapWithProxy`, keyed by `(docId, basePath)` —
30
+ * the LATEST `data` for a given scope REPLACES the prior one. This is
31
+ * the load-bearing leak fix (cross-model /review P1, Claude + Codex):
32
+ * `useStorystickQuery` produces a fresh `QueryResult` object on every
33
+ * Convex content revision, so an identity-keyed append-only list grows
34
+ * unbounded for the iframe's lifetime — memory leak AND, once the
35
+ * `COLLECT_MAX_LEAVES` cap is hit by stale earlier roots, the CURRENT
36
+ * doc's leaves stop being emitted → field overlays freeze/vanish
37
+ * mid-edit-session (the exact "feels broken" the ADR exists to
38
+ * prevent). Keying by scope bounds `roots` to the small, stable set of
39
+ * distinct (docId, basePath) scopes on the page and always reflects
40
+ * latest content. `MAX_ROOTS` is a hard belt-and-braces cap (a
41
+ * pathological page with thousands of distinct cross-doc scopes evicts
42
+ * oldest-first rather than growing without bound). `clearTrackedFields`
43
+ * is the per-generation / test reset, NOT a per-publish call (the
44
+ * matcher does NOT clear each pass — scope-keying makes that
45
+ * unnecessary and a per-publish clear would race the customer render
46
+ * that registers roots). */
47
+ const MAX_ROOTS = 256;
48
+ const roots = [];
49
+ /**
50
+ * ADR-126 — roots sourced from the RSC render-path field-model island
51
+ * (`<script data-ss-model>` emitted server-side by `@storystick/react`'s
52
+ * `<SliceZone>`). Kept in a SEPARATE list from the Proxy `roots`, NOT
53
+ * merged into it, because both sources legitimately register the same
54
+ * `(docId, '')` scope on a hybrid page (RSC shell + client islands): a
55
+ * single scope-keyed list would let the lazy Proxy subset REPLACE the
56
+ * full server-resolved island root (or vice versa) — codex outside-voice
57
+ * #7, "poorer must not replace richer". `collectFieldModel()` walks the
58
+ * UNION (`roots` ∪ `islandRoots`); the per-leaf `out` Map dedupes
59
+ * identical leaves, so a leaf present in EITHER source survives and no
60
+ * source can erase another's coverage. Same `MAX_ROOTS` discipline. */
61
+ const islandRoots = [];
62
+ /** Proxy identity cache — same target ⇒ same Proxy, so wrapping a
63
+ * reference-stable object (slice-zone hands the same `hydrated` ref
64
+ * when nothing to hydrate) does not break `React.memo`. */
65
+ const proxyCache = new WeakMap();
66
+ function leafKey(docId, path) {
67
+ return `${docId ?? ''}\x00${path}`;
68
+ }
69
+ function recordLeaf(path, value, docId) {
70
+ // Only primitives are leaves; `path === ''` would be a root primitive
71
+ // (not a field) — never record it.
72
+ if (path === '')
73
+ return;
74
+ accessed.set(leafKey(docId, path), { path, value, docId });
75
+ }
76
+ // ─────────────────────────────────────────────
77
+ // Shape predicates
78
+ // ─────────────────────────────────────────────
79
+ function isPlainPrimitive(v) {
80
+ return (v === null ||
81
+ v === undefined ||
82
+ typeof v === 'string' ||
83
+ typeof v === 'number' ||
84
+ typeof v === 'boolean');
85
+ }
86
+ /** A `QueryResult` document scope: `{ documentId, fields, ... }`. The
87
+ * presence of a string `documentId` is the discriminator (mirrors
88
+ * `@storystick/client` `QueryResult`). */
89
+ function isQueryResult(v) {
90
+ return (v !== null &&
91
+ typeof v === 'object' &&
92
+ typeof v.documentId === 'string' &&
93
+ v.documentId.length > 0);
94
+ }
95
+ /** Non-content `QueryResult` metadata keys — returned raw, never a
96
+ * recordable content path, never recursed as content. `fields` and
97
+ * `resolved` are handled specially (not here). */
98
+ const QUERY_RESULT_META = new Set([
99
+ 'documentId',
100
+ 'storyType',
101
+ 'snapshotId',
102
+ 'publishedAt',
103
+ 'resolvedPathname',
104
+ 'slug',
105
+ 'isRoutable',
106
+ '_zoneMappings',
107
+ ]);
108
+ // ─────────────────────────────────────────────
109
+ // Path grammar (ADR-068 walker-canonical)
110
+ // ─────────────────────────────────────────────
111
+ function joinKey(base, key) {
112
+ return base === '' ? key : `${base}.${key}`;
113
+ }
114
+ function joinIndex(base, index) {
115
+ return `${base}[${index}]`;
116
+ }
117
+ // ─────────────────────────────────────────────
118
+ // Recursive wrap
119
+ // ─────────────────────────────────────────────
120
+ const MAX_DEPTH = 16;
121
+ /**
122
+ * Wrap a query-builder result. Returns `data` unchanged (by reference,
123
+ * zero allocation) when tracking is inactive or `data` is a primitive.
124
+ * Registers the root for the eager `collectFieldModel()` walk and
125
+ * returns a recursive get-trap Proxy for the lazy accessed-leaf path.
126
+ *
127
+ * `docId` scopes recorded paths; pass the document's `documentId` (or
128
+ * omit — `collectFieldModel` re-derives it when the root is itself a
129
+ * `QueryResult`). `basePath` prefixes recorded paths (a sliceZone item
130
+ * wrap passes e.g. `sections[2]`); defaults to the content root `''`.
131
+ */
132
+ export function wrapWithProxy(data, docId = null, basePath = '') {
133
+ if (!isFieldTrackingActive())
134
+ return data;
135
+ if (data === null || typeof data !== 'object')
136
+ return data;
137
+ // Register the root once per (identity, docId, basePath) so the eager
138
+ // walk has it even when no get-trap ever fires (RSC).
139
+ registerRoot(data, docId, basePath);
140
+ return wrapNode(data, docId, basePath, 0);
141
+ }
142
+ function registerRoot(data, docId, basePath) {
143
+ for (const r of roots) {
144
+ if (r.docId === docId && r.basePath === basePath) {
145
+ // Same scope re-registered (new content revision → new object):
146
+ // REPLACE the data in place. No append → no unbounded growth,
147
+ // model always reflects latest content. Identity-equal is a
148
+ // no-op (the common stable-render case).
149
+ r.data = data;
150
+ return;
151
+ }
152
+ }
153
+ roots.push({ data, docId, basePath });
154
+ // Hard cap (pathological many-distinct-scope page) — evict oldest.
155
+ if (roots.length > MAX_ROOTS)
156
+ roots.shift();
157
+ }
158
+ /**
159
+ * Wrap one node. Objects/arrays return a recursive Proxy; primitives are
160
+ * returned raw (the parent's get-trap recorded them). A `QueryResult`
161
+ * boundary re-scopes: `.fields` stays in-doc with the content path,
162
+ * `.resolved[field]` opens the child doc scope (docId = child
163
+ * `documentId`, path reset), metadata keys pass through raw.
164
+ */
165
+ function wrapNode(node, docId, path, depth) {
166
+ if (node === null || typeof node !== 'object')
167
+ return node;
168
+ if (depth >= MAX_DEPTH)
169
+ return node;
170
+ // Identity cache — same target+scope ⇒ same Proxy (memo-stable).
171
+ const cacheKey = `${docId ?? ''}\x00${path}\x00${depth >= MAX_DEPTH ? 'L' : ''}`;
172
+ let perTarget = proxyCache.get(node);
173
+ if (perTarget) {
174
+ const hit = perTarget.get(cacheKey);
175
+ if (hit !== undefined)
176
+ return hit;
177
+ }
178
+ else {
179
+ perTarget = new Map();
180
+ proxyCache.set(node, perTarget);
181
+ }
182
+ const isQR = isQueryResult(node);
183
+ const isArr = Array.isArray(node);
184
+ const proxy = new Proxy(node, {
185
+ get(target, prop, receiver) {
186
+ const raw = Reflect.get(target, prop, receiver);
187
+ if (typeof prop === 'symbol')
188
+ return raw;
189
+ if (typeof raw === 'function')
190
+ return raw;
191
+ // ── QueryResult boundary ──────────────────────────────
192
+ if (isQR) {
193
+ const childDocId = target.documentId;
194
+ if (prop === 'fields') {
195
+ // Content root of THIS doc — path stays, docId becomes this
196
+ // doc's id (the root may have been registered with a null
197
+ // docId; the QueryResult is authoritative).
198
+ return wrapNode(raw, childDocId, path, depth + 1);
199
+ }
200
+ if (prop === 'resolved') {
201
+ // The resolved map itself is a traversal node; each key is a
202
+ // resolved-ref boundary handled one level down.
203
+ return wrapResolvedMap(raw, depth + 1);
204
+ }
205
+ if (QUERY_RESULT_META.has(prop))
206
+ return raw;
207
+ // Any other key on a QueryResult: treat as content under the
208
+ // doc scope (defensive — schemas evolve).
209
+ return descend(raw, childDocId, joinKey(path, prop), depth);
210
+ }
211
+ // ── Array hop ─────────────────────────────────────────
212
+ if (isArr) {
213
+ const idx = typeof prop === 'string' ? Number(prop) : Number.NaN;
214
+ if (Number.isInteger(idx) && idx >= 0) {
215
+ return descend(raw, docId, joinIndex(path, idx), depth);
216
+ }
217
+ return raw; // length, iterator helpers, etc.
218
+ }
219
+ // ── Plain content object ──────────────────────────────
220
+ return descend(raw, docId, joinKey(path, prop), depth);
221
+ },
222
+ });
223
+ perTarget.set(cacheKey, proxy);
224
+ return proxy;
225
+ }
226
+ /** Resolve one property value: record if primitive, recurse if not. */
227
+ function descend(value, docId, childPath, depth) {
228
+ if (isPlainPrimitive(value)) {
229
+ recordLeaf(childPath, value, docId);
230
+ return value;
231
+ }
232
+ return wrapNode(value, docId, childPath, depth + 1);
233
+ }
234
+ /**
235
+ * The `.resolved` map. Each key is a resolved-ref boundary: the value is
236
+ * a child `QueryResult` (scalar ref / `.resolve()`) or an array of them
237
+ * (`.resolveSlices()` / `reference[]`). Crossing it opens the child doc
238
+ * scope with the path RESET — the child's fields are canonical relative
239
+ * to ITS own `data-ss-doc` (ADR-125 D2).
240
+ */
241
+ function wrapResolvedMap(map, depth) {
242
+ if (map === null || typeof map !== 'object')
243
+ return map;
244
+ if (depth >= MAX_DEPTH)
245
+ return map;
246
+ return new Proxy(map, {
247
+ get(target, prop, receiver) {
248
+ const raw = Reflect.get(target, prop, receiver);
249
+ if (typeof prop === 'symbol' || typeof raw === 'function')
250
+ return raw;
251
+ if (raw === null || typeof raw !== 'object')
252
+ return raw;
253
+ if (Array.isArray(raw)) {
254
+ // Array of resolved children — wrap the array; each element is
255
+ // its own doc scope (handled by the QueryResult branch below).
256
+ return new Proxy(raw, {
257
+ get(arrT, arrP, arrR) {
258
+ const item = Reflect.get(arrT, arrP, arrR);
259
+ if (isQueryResult(item)) {
260
+ registerRoot(item, item.documentId, '');
261
+ return wrapNode(item, item.documentId, '', depth + 1);
262
+ }
263
+ return item;
264
+ },
265
+ });
266
+ }
267
+ if (isQueryResult(raw)) {
268
+ registerRoot(raw, raw.documentId, '');
269
+ return wrapNode(raw, raw.documentId, '', depth + 1);
270
+ }
271
+ return raw;
272
+ },
273
+ });
274
+ }
275
+ // ─────────────────────────────────────────────
276
+ // Eager structural enumeration (RSC-safe; matcher consumes this)
277
+ // ─────────────────────────────────────────────
278
+ /** Defensive caps — a pathological doc (deep nesting / huge arrays)
279
+ * must never let the per-rAF matcher walk blow the frame budget. */
280
+ const COLLECT_MAX_LEAVES = 8000;
281
+ /**
282
+ * Walk every registered root and return all canonical leaves
283
+ * `{ path, value, docId }`, deduped + bounded. RSC-safe: does not depend
284
+ * on any get-trap having fired. Resolved-ref boundaries re-scope exactly
285
+ * like the lazy path (docId = child `documentId`, path reset).
286
+ */
287
+ export function collectFieldModel() {
288
+ // ADR-126 — walk the UNION of the RSC-island roots and the Proxy
289
+ // roots. `collectFieldModelFromRoots` is the pure, singleton-free,
290
+ // window-free core (RSC-safe): the SAME `walk()` both the client
291
+ // Proxy path and the server island path converge on, which is what
292
+ // makes the parity invariant hold by construction (one walker, two
293
+ // model sources — never a second path grammar).
294
+ //
295
+ // Two INDEPENDENT walks merged with explicit precedence (cross-model
296
+ // /review R2, codex #1): a single concatenated walk let whichever
297
+ // source came first fill the shared `COLLECT_MAX_LEAVES` budget and
298
+ // starve the other (R1 island-first only moved the starvation onto
299
+ // the Proxy on a huge hybrid page). Walking each source independently
300
+ // removes the order-vs-cap coupling entirely:
301
+ //
302
+ // • island = the RSC / cross-doc BASELINE (the pure-RSC content +
303
+ // the independently-queried `getPageContext()` docs the client
304
+ // Proxy never wrapped),
305
+ // • Proxy `roots` = the LIVE client render — AUTHORITATIVE on a
306
+ // same-`(docId,path)` conflict (the island only refreshes on an
307
+ // RSC re-render; the Proxy reflects the current client value).
308
+ //
309
+ // Build the island model first, then overlay the Proxy model so a
310
+ // conflicting key takes the Proxy (live) value. Union ⇒ a leaf in
311
+ // EITHER source survives; neither can erase the other (codex #7 /
312
+ // D-RECONCILE) and neither can starve the other at the cap. On the
313
+ // dominant pure-RSC path `roots` is empty so this is exactly the
314
+ // island model. Fast path: no island ⇒ the Proxy walk alone.
315
+ if (islandRoots.length === 0)
316
+ return collectFieldModelFromRoots(roots);
317
+ const merged = new Map();
318
+ for (const f of collectFieldModelFromRoots(islandRoots)) {
319
+ merged.set(`${f.docId ?? ''}\x00${f.path}`, f);
320
+ }
321
+ for (const f of collectFieldModelFromRoots(roots)) {
322
+ merged.set(`${f.docId ?? ''}\x00${f.path}`, f); // Proxy overrides
323
+ }
324
+ return [...merged.values()];
325
+ }
326
+ /**
327
+ * ADR-126 — pure extraction of the eager walk. No module singleton, no
328
+ * `window`: callable with any explicit `RegisteredRoot[]`. Behaviour for
329
+ * the module `roots` is byte-identical to the prior `collectFieldModel`
330
+ * (a strict no-behaviour-change extraction — pinned by a parity test).
331
+ */
332
+ export function collectFieldModelFromRoots(rootsList) {
333
+ const out = new Map();
334
+ for (const r of rootsList) {
335
+ if (out.size >= COLLECT_MAX_LEAVES)
336
+ break;
337
+ walk(r.data, r.docId, r.basePath, 0, out, new Set());
338
+ }
339
+ return [...out.values()];
340
+ }
341
+ /**
342
+ * ADR-126 — register the documents the RSC render-path island reported.
343
+ * Each `entry` is a projected `QueryResult` (`{ documentId, fields,
344
+ * resolved? }`) the SDK's `<SliceZone>` deep-scanned out of its rendered
345
+ * `context` and serialized server-side. `isQueryResult` is satisfied by
346
+ * the string `documentId`, so the existing `walk()` re-scopes docId and
347
+ * resets path at every `QueryResult`/`.resolved` boundary EXACTLY like
348
+ * the Proxy path — zero downstream change. Scope-keyed `(documentId,'')`
349
+ * replace. Defensive: a non-object / no-`documentId` entry is skipped,
350
+ * never thrown on (the island is parsed from possibly-hostile page HTML).
351
+ *
352
+ * Cap is `MAX_ISLAND_ROOTS` (NOT the Proxy `MAX_ROOTS=256`): callers
353
+ * replace the per-render snapshot through `clearIslandRoots()` followed
354
+ * by `registerIslandRoots()`, so it cannot leak the way the
355
+ * identity-keyed Proxy `roots` could
356
+ * (the original 256 cap was that leak's belt-and-braces). Aligning it to
357
+ * the producer/reader `MAX_ISLAND_DOCS` (4096) stops a large context
358
+ * (>256 docs, e.g. a big blogIndex) from evicting the FIRST-registered
359
+ * root — which is the previewed page itself, scanned first — and
360
+ * silently dropping the main page's own field overlays (codex #6).
361
+ */
362
+ const MAX_ISLAND_ROOTS = 4096;
363
+ /** ADR-126 — drop the whole island snapshot. It reflects ONE render;
364
+ * callers replace it wholesale so a doc removed from the rendered
365
+ * context on an RSC refresh cannot linger and
366
+ * re-introduce the non-rendered-doc false-positive class γ′ exists to
367
+ * kill (cross-model /review R1, codex #2). Also called by
368
+ * `clearTrackedFields()` on the render-mode epoch bump. */
369
+ export function clearIslandRoots() {
370
+ islandRoots.length = 0;
371
+ }
372
+ export function registerIslandRoots(entries) {
373
+ for (const entry of entries) {
374
+ if (!isQueryResult(entry))
375
+ continue;
376
+ const docId = entry.documentId;
377
+ let replaced = false;
378
+ for (const r of islandRoots) {
379
+ if (r.docId === docId && r.basePath === '') {
380
+ r.data = entry;
381
+ replaced = true;
382
+ break;
383
+ }
384
+ }
385
+ if (replaced)
386
+ continue;
387
+ islandRoots.push({ data: entry, docId, basePath: '' });
388
+ if (islandRoots.length > MAX_ISLAND_ROOTS)
389
+ islandRoots.shift();
390
+ }
391
+ }
392
+ function walk(node, docId, path, depth, out, seen) {
393
+ if (out.size >= COLLECT_MAX_LEAVES)
394
+ return;
395
+ if (isPlainPrimitive(node)) {
396
+ if (path !== '')
397
+ out.set(leafKey(docId, path), { path, value: node, docId });
398
+ return;
399
+ }
400
+ if (typeof node !== 'object' || node === null)
401
+ return;
402
+ if (depth >= MAX_DEPTH)
403
+ return;
404
+ if (seen.has(node))
405
+ return; // cycle guard
406
+ seen.add(node);
407
+ if (isQueryResult(node)) {
408
+ const childDocId = node.documentId;
409
+ const fields = node.fields;
410
+ if (fields && typeof fields === 'object') {
411
+ walk(fields, childDocId, path, depth + 1, out, seen);
412
+ }
413
+ const resolved = node.resolved;
414
+ if (resolved && typeof resolved === 'object') {
415
+ for (const child of Object.values(resolved)) {
416
+ if (Array.isArray(child)) {
417
+ for (const c of child) {
418
+ if (isQueryResult(c)) {
419
+ walk(c, c.documentId, '', depth + 1, out, seen);
420
+ }
421
+ }
422
+ }
423
+ else if (isQueryResult(child)) {
424
+ walk(child, child.documentId, '', depth + 1, out, seen);
425
+ }
426
+ }
427
+ }
428
+ return;
429
+ }
430
+ if (Array.isArray(node)) {
431
+ for (let i = 0; i < node.length; i++) {
432
+ walk(node[i], docId, joinIndex(path, i), depth + 1, out, seen);
433
+ }
434
+ return;
435
+ }
436
+ for (const [k, v] of Object.entries(node)) {
437
+ walk(v, docId, joinKey(path, k), depth + 1, out, seen);
438
+ }
439
+ }
440
+ // ─────────────────────────────────────────────
441
+ // Public accessors (preserve the index.ts surface contract)
442
+ // ─────────────────────────────────────────────
443
+ /** Accessed leaves (lazy path). The matcher prefers `collectFieldModel`
444
+ * (RSC-safe) but this stays for client-render precision + tests. */
445
+ export function getTrackedFields() {
446
+ return [...accessed.values()];
447
+ }
448
+ /**
449
+ * Hard reset — clears the accessed set AND every registered root.
450
+ * NOT called per-publish (scope-keyed `registerRoot` already bounds
451
+ * `roots` and keeps it current; a per-publish clear would race the
452
+ * customer render that registers roots). It is the test reset and the
453
+ * teardown hook a future generation-scoped reset would call if the
454
+ * model ever needs hard isolation across a doc swap (today scope-keying
455
+ * makes the swap self-correct: the new doc replaces its scope entry,
456
+ * cross-model /review P1 fix).
457
+ */
458
+ export function clearTrackedFields() {
459
+ accessed.clear();
460
+ roots.length = 0;
461
+ // ADR-126 — the island roots are generation-scoped too: a render-mode
462
+ // epoch BUMP (cross-doc / mode / Form-entry) must drop the prior doc's
463
+ // island so a stale cross-doc-switch field never overlays. The fresh
464
+ // RSC render emits a new `<script data-ss-model>`; the next publish's
465
+ // island read repopulates from the current DOM (the island reader's
466
+ // parse cache is keyed by script text, so a content change re-parses;
467
+ // `registerIslandRoots` is called every publish and is idempotent, so
468
+ // it re-fills `islandRoots` after this clear even if the script text
469
+ // is unchanged — overlays never get stuck empty post-epoch).
470
+ clearIslandRoots();
471
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * In-iframe SDK runtime — Form-preview viewport-contract reporter
3
+ * (ADR-119 D2/D3/D6).
4
+ *
5
+ * Form preview (render-mode `'navigation'`) is a live, SDK-cooperative
6
+ * editorial mini-map. This runtime is the SDK half of the ADR-119
7
+ * contract; the Studio half is the disciplined controller in
8
+ * `apps/studio/.../preview-surface.tsx`.
9
+ *
10
+ * 1. On `render-mode: 'navigation'` it sets
11
+ * `data-storystick-form-preview` on the customer document's
12
+ * `<html>`. The migrated starter routes viewport-relative
13
+ * primitives (`body { min-height: var(--ss-screen-h, 100dvh) }`,
14
+ * `min-h-dvh`/`h-screen` utilities, full-screen sections) through
15
+ * `--ss-screen-h`, which `html[data-storystick-form-preview]`
16
+ * pins to 800px. This neutralises the EXP-3 cross-doc viewport
17
+ * ratchet **at the source**: the customer's own primitives
18
+ * resolve to a real 800, never the tall measuring iframe.
19
+ *
20
+ * 2. It measures `documentElement.scrollHeight` **under the applied
21
+ * contract** and reports it to the Studio parent as
22
+ * `{type:'storystick.preview.form-contract', epoch, applied:true,
23
+ * pageHeight, viewportHeight}`. The FIRST report after an `epoch`
24
+ * change is the
25
+ * controller's hard ACK gate (ADR-119 D3 — "NEVER take the first
26
+ * measurement without a confirmed ack"). Subsequent same-`epoch`
27
+ * reports (layout-observer driven) feed the controller's
28
+ * debounced in-doc resize cadence.
29
+ *
30
+ * 3. On `render-mode: 'device'` it removes the attribute and reports
31
+ * `{applied:false, pageHeight:0}`. Device is strict browser truth
32
+ * (1280×800), never virtualised.
33
+ *
34
+ * `epoch` is the Studio controller's instance-scoped generation
35
+ * counter (ADR-119 D1), carried on `MsgRenderMode` and echoed back
36
+ * verbatim on every report so the controller can discard stale
37
+ * cross-doc async measurements soundly (the ratchet-class guard).
38
+ *
39
+ * Principle (ADR-119 D6 / no-customer-DOM-interference, relaxed for
40
+ * Form only, contractualised): the ONLY customer-DOM write is the
41
+ * single `data-storystick-form-preview` attribute on `<html>`, set by
42
+ * the SDK the customer already embeds — NOT Studio reaching across
43
+ * origins, NOT CSS injection (the retracted ADR-102/112 §D4 path).
44
+ * Reads are limited to `documentElement.scrollHeight` and the iframe's own
45
+ * `window.innerHeight`. No content mutation, no DOM-tree inspection. A site not using the starter
46
+ * primitives simply gets an honest, slightly-less-faithful overview;
47
+ * Device stays exact.
48
+ */
49
+ export declare const FORM_PREVIEW_ATTR = "data-storystick-form-preview";
50
+ export interface FormContractReporterOptions {
51
+ /** Document the contract attribute is written on. Defaults to `window.document`. */
52
+ doc?: Document;
53
+ /** Window to read `parent` / attach the listener on. Defaults to `window`. */
54
+ win?: Window;
55
+ /** Override `requestAnimationFrame` for deterministic tests. */
56
+ raf?: (cb: () => void) => number;
57
+ /** Pair with `raf` to cancel pending settle/observer ticks on stop. */
58
+ caf?: (handle: number) => void;
59
+ }
60
+ export interface FormContractReport {
61
+ epoch: number;
62
+ applied: boolean;
63
+ pageHeight: number;
64
+ viewportHeight: number;
65
+ windowedProjection?: 'pending' | 'active' | 'unsupported';
66
+ windowedProjectionReason?: string;
67
+ windowStart?: number;
68
+ renderSpan?: number;
69
+ compression?: number;
70
+ }
71
+ export interface FormContractReporterHandle {
72
+ /**
73
+ * Tear down observers + listener AND remove the contract attribute
74
+ * (a torn-down SDK must not leave the customer document virtualised).
75
+ * Idempotent.
76
+ */
77
+ stop(): void;
78
+ /**
79
+ * Force a synchronous measure+publish for the current mode — used by
80
+ * tests to assert post-rAF state without real animation frames.
81
+ * Returns the report that was posted (or `null` if nothing has been
82
+ * driven yet).
83
+ */
84
+ flush(): FormContractReport | null;
85
+ }
86
+ /**
87
+ * Conceptual viewport for customer JS that needs the Form-preview
88
+ * height (ADR-119 D5 — explicit helper, NOT a global `window.
89
+ * innerHeight` shim). When the contract is active the computed
90
+ * `--ss-screen-h` resolves to `800px` and that is returned as the
91
+ * height; otherwise the real `innerHeight` (Device / no contract /
92
+ * non-starter site). Width is always the real `innerWidth` (Form does
93
+ * not virtualise width — the iframe is a true 1280 logical box).
94
+ */
95
+ export declare function getStorystickViewport(win?: Window): {
96
+ width: number;
97
+ height: number;
98
+ };
99
+ export declare function startFormContractReporter(options?: FormContractReporterOptions): FormContractReporterHandle;