@unitninety/site-kit 0.0.0-stage → 1.6.1

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 (204) hide show
  1. package/README.md +12 -2
  2. package/package.json +79 -4
  3. package/src/bridge/DraftBlocks.tsx +70 -0
  4. package/src/bridge/PreviewCommitBoundary.tsx +19 -0
  5. package/src/bridge/U90Bridge.tsx +54 -0
  6. package/src/bridge/caret.ts +317 -0
  7. package/src/bridge/client.tsx +66 -0
  8. package/src/bridge/createBridge.ts +1010 -0
  9. package/src/bridge/hooks.tsx +56 -0
  10. package/src/bridge/index.ts +9 -0
  11. package/src/bridge/lexical.ts +177 -0
  12. package/src/bridge/lexicalDom.ts +212 -0
  13. package/src/bridge/preserveBlockMoves.ts +88 -0
  14. package/src/bridge/preview.ts +179 -0
  15. package/src/bridge/protocol.ts +271 -0
  16. package/src/bridge/registry.ts +27 -0
  17. package/src/bridge/styles.ts +137 -0
  18. package/src/components/DateText.tsx +9 -0
  19. package/src/components/File.tsx +9 -0
  20. package/src/components/Image.tsx +30 -0
  21. package/src/components/Link.tsx +24 -0
  22. package/src/components/NumberText.tsx +9 -0
  23. package/src/components/RichText.tsx +47 -0
  24. package/src/components/Text.tsx +15 -0
  25. package/src/components/Video.tsx +12 -0
  26. package/src/components/VideoPlayback.tsx +19 -0
  27. package/src/components/draft.ts +9 -0
  28. package/src/components/imageProps.ts +26 -0
  29. package/src/components/index.ts +12 -0
  30. package/src/components/videoProps.ts +6 -0
  31. package/src/elements/containers/group.ts +33 -0
  32. package/src/elements/containers/list.ts +31 -0
  33. package/src/elements/containers/zone.ts +41 -0
  34. package/src/elements/declaration.ts +118 -0
  35. package/src/elements/define.ts +195 -0
  36. package/src/elements/ids.ts +50 -0
  37. package/src/elements/index.ts +35 -0
  38. package/src/elements/jsonSchema.ts +36 -0
  39. package/src/elements/leaf.ts +98 -0
  40. package/src/elements/leaves/boolean.ts +30 -0
  41. package/src/elements/leaves/choice.ts +53 -0
  42. package/src/elements/leaves/date.ts +39 -0
  43. package/src/elements/leaves/file.ts +56 -0
  44. package/src/elements/leaves/image.ts +160 -0
  45. package/src/elements/leaves/link.ts +149 -0
  46. package/src/elements/leaves/number.ts +52 -0
  47. package/src/elements/leaves/query.ts +101 -0
  48. package/src/elements/leaves/relation.ts +69 -0
  49. package/src/elements/leaves/rich-text.ts +155 -0
  50. package/src/elements/leaves/selection.ts +40 -0
  51. package/src/elements/leaves/single.ts +29 -0
  52. package/src/elements/leaves/text.ts +103 -0
  53. package/src/elements/leaves/video.ts +108 -0
  54. package/src/elements/ops/expand.ts +94 -0
  55. package/src/elements/ops/overlay.ts +59 -0
  56. package/src/elements/ops/refs.ts +45 -0
  57. package/src/elements/ops/seed.ts +185 -0
  58. package/src/elements/ops/text.ts +20 -0
  59. package/src/elements/ops/translatable.ts +19 -0
  60. package/src/elements/ops/upgrade.ts +43 -0
  61. package/src/elements/ops/validate.ts +56 -0
  62. package/src/elements/order.ts +10 -0
  63. package/src/elements/path.ts +43 -0
  64. package/src/elements/prefix.ts +2 -0
  65. package/src/elements/registry.ts +48 -0
  66. package/src/elements/tokens.ts +14 -0
  67. package/src/elements/walk.ts +42 -0
  68. package/src/features/client/AskAiProvider.tsx +551 -0
  69. package/src/features/client/AskAiWidget.tsx +222 -0
  70. package/src/features/client/ConsentRuntime.tsx +95 -0
  71. package/src/features/client/FeedbackProvider.tsx +231 -0
  72. package/src/features/client/FeedbackWidget.tsx +101 -0
  73. package/src/features/client/PageReporter.tsx +26 -0
  74. package/src/features/client/README.md +131 -0
  75. package/src/features/client/SiteFeatures.tsx +250 -0
  76. package/src/features/client/askAiRecovery.ts +92 -0
  77. package/src/features/client/askAiStream.ts +59 -0
  78. package/src/features/client/context.tsx +24 -0
  79. package/src/features/client/index.ts +24 -0
  80. package/src/features/client/overlay.tsx +129 -0
  81. package/src/features/client/styles.css +337 -0
  82. package/src/features/client/transport.ts +310 -0
  83. package/src/features/client/useAskAI.ts +45 -0
  84. package/src/features/client/useConsent.ts +15 -0
  85. package/src/features/client/useFeedback.ts +33 -0
  86. package/src/features/client/useSiteSearch.ts +50 -0
  87. package/src/features/client/verification.ts +141 -0
  88. package/src/features/consent/README.md +70 -0
  89. package/src/features/consent/cookieScript.ts +53 -0
  90. package/src/features/consent/custom.ts +89 -0
  91. package/src/features/consent/defaults.ts +40 -0
  92. package/src/features/consent/drivers.ts +233 -0
  93. package/src/features/consent/loader.ts +248 -0
  94. package/src/features/consent/registry.ts +398 -0
  95. package/src/features/contracts/askAi.ts +166 -0
  96. package/src/features/contracts/askAiSettings.ts +42 -0
  97. package/src/features/contracts/base.ts +107 -0
  98. package/src/features/contracts/consent.ts +150 -0
  99. package/src/features/contracts/feedback.ts +22 -0
  100. package/src/features/contracts/feedbackSettings.ts +49 -0
  101. package/src/features/contracts/index.ts +61 -0
  102. package/src/features/contracts/knowledge.ts +99 -0
  103. package/src/features/contracts/knowledgePolicy.ts +249 -0
  104. package/src/features/contracts/search.ts +24 -0
  105. package/src/features/contracts/verification.ts +52 -0
  106. package/src/features/contracts/verificationConfig.ts +9 -0
  107. package/src/features/server/ConsentDefaults.tsx +15 -0
  108. package/src/features/server/README.md +185 -0
  109. package/src/features/server/gateway.ts +318 -0
  110. package/src/features/server/http.ts +179 -0
  111. package/src/features/server/index.ts +6 -0
  112. package/src/features/server/session.ts +23 -0
  113. package/src/features/server/verification.ts +39 -0
  114. package/src/features/server/verifyVisitor.ts +69 -0
  115. package/src/languages/config.d.mts +5 -0
  116. package/src/languages/config.mjs +53 -0
  117. package/src/languages/decide.ts +72 -0
  118. package/src/languages/index.ts +4 -0
  119. package/src/languages/proxy.ts +108 -0
  120. package/src/languages/routing.ts +93 -0
  121. package/src/languages/wire.ts +19 -0
  122. package/src/legacy/bridge.tsx +1987 -0
  123. package/src/legacy/caret.ts +246 -0
  124. package/src/legacy/cli.mjs +189 -0
  125. package/src/legacy/content.ts +993 -0
  126. package/src/legacy/contract.ts +664 -0
  127. package/src/legacy/defineBlocks.ts +1602 -0
  128. package/src/legacy/defineSite.ts +623 -0
  129. package/src/legacy/defineSite.types.ts +270 -0
  130. package/src/legacy/draft.tsx +128 -0
  131. package/src/legacy/image.ts +433 -0
  132. package/src/legacy/index.ts +209 -0
  133. package/src/legacy/language-switcher.tsx +58 -0
  134. package/src/legacy/languageHints.ts +153 -0
  135. package/src/legacy/languages/fields.ts +131 -0
  136. package/src/legacy/languages/hash.ts +98 -0
  137. package/src/legacy/languages/health.ts +97 -0
  138. package/src/legacy/languages/identity.ts +247 -0
  139. package/src/legacy/languages/links.ts +81 -0
  140. package/src/legacy/languages/resolution.ts +56 -0
  141. package/src/legacy/languages/routing.ts +169 -0
  142. package/src/legacy/languages/runtime.ts +216 -0
  143. package/src/legacy/languages/seo.ts +95 -0
  144. package/src/legacy/languages/settings.ts +211 -0
  145. package/src/legacy/languages/types.ts +85 -0
  146. package/src/legacy/languages.ts +8 -0
  147. package/src/legacy/lexical.ts +170 -0
  148. package/src/legacy/lexicalDom.ts +166 -0
  149. package/src/legacy/media.ts +272 -0
  150. package/src/legacy/nav.ts +176 -0
  151. package/src/legacy/preview.ts +79 -0
  152. package/src/legacy/redirects.ts +474 -0
  153. package/src/legacy/richText.tsx +104 -0
  154. package/src/legacy/routes.ts +414 -0
  155. package/src/legacy/seo.ts +327 -0
  156. package/src/legacy/shape.ts +412 -0
  157. package/src/legacy/uploads.ts +233 -0
  158. package/src/legacy/verification.ts +42 -0
  159. package/src/preview-lock/contracts.ts +42 -0
  160. package/src/preview-lock/index.ts +3 -0
  161. package/src/preview-lock/screen.tsx +244 -0
  162. package/src/preview-lock/server.ts +416 -0
  163. package/src/redirects/config.d.mts +5 -0
  164. package/src/redirects/config.mjs +22 -0
  165. package/src/redirects/index.ts +3 -0
  166. package/src/redirects/location.ts +31 -0
  167. package/src/redirects/match.ts +199 -0
  168. package/src/redirects/proxy.ts +91 -0
  169. package/src/redirects/wire.ts +30 -0
  170. package/src/seo/jsonLd.ts +70 -0
  171. package/src/seo/metadata.ts +127 -0
  172. package/src/seo/robots.ts +14 -0
  173. package/src/seo/sitemap.ts +22 -0
  174. package/src/seo/title.ts +22 -0
  175. package/src/server/adapters.ts +73 -0
  176. package/src/server/createU90.ts +96 -0
  177. package/src/server/handlers.ts +164 -0
  178. package/src/server/index.ts +6 -0
  179. package/src/server/read.ts +207 -0
  180. package/src/server/render.tsx +46 -0
  181. package/src/server/routes.tsx +171 -0
  182. package/src/site/assemble.ts +109 -0
  183. package/src/site/defineSite.ts +132 -0
  184. package/src/site/documents.ts +18 -0
  185. package/src/site/fields.ts +59 -0
  186. package/src/site/index.ts +14 -0
  187. package/src/site/replies.ts +118 -0
  188. package/src/site/store.ts +45 -0
  189. package/src/site/types.ts +75 -0
  190. package/src/site/version.ts +1 -0
  191. package/src/site/wire.ts +125 -0
  192. package/src/switcher/LanguageSwitcher.tsx +42 -0
  193. package/src/test-preview/README.md +90 -0
  194. package/src/test-preview/binding.ts +54 -0
  195. package/src/test-preview/bridge.ts +16 -0
  196. package/src/test-preview/guard.ts +186 -0
  197. package/src/test-preview/media.ts +23 -0
  198. package/src/test-preview/protocol.d.mts +67 -0
  199. package/src/test-preview/protocol.mjs +222 -0
  200. package/src/test-preview/runtime.ts +168 -0
  201. package/src/test-preview/server.ts +10 -0
  202. package/src/testing/fake.ts +148 -0
  203. package/src/testing/index.ts +2 -0
  204. package/src/testing/seed.ts +2 -0
@@ -0,0 +1,1010 @@
1
+ import type { LinkTo } from '../elements/leaves/link'
2
+ import { activeBlock, activeMarks, applyCaret, caretTargetIn, isEditableLeaf } from './caret'
3
+ import { toRichText, type DomLike } from './lexicalDom'
4
+ import { applyPreview, type Undo } from './preview'
5
+ import { BRIDGE_VERSION, MAX_OFFENDER_LENGTH, MAX_OFFENDERS, parseCanvasMessage, type BridgeStamp, type PreviewInstruction, type SiteMessage } from './protocol'
6
+ import { registry } from './registry'
7
+ import { EDITING_CSS } from './styles'
8
+
9
+ export type BridgeOptions = {
10
+ platformOrigin: string
11
+ parent: Pick<Window, 'postMessage'>
12
+ refresh: () => void
13
+ onSelect?: (path: string | null) => void
14
+ }
15
+
16
+ const DEBOUNCE = 150
17
+
18
+ let warnedOfCeiling = false
19
+ /** Development only, and once: see `setRefreshing`. */
20
+ const warnOnceThatRefreshIsSlow = () => {
21
+ if (warnedOfCeiling || (typeof process !== 'undefined' && process.env.NODE_ENV === 'production')) return
22
+ warnedOfCeiling = true
23
+ console.warn('[u90] a refresh is still in flight after 5s; React-owned block moves remain available, but reconciliation waits for the actual commit (BridgeClient isPending).')
24
+ }
25
+ /** For tests: forget that the warning has been given. */
26
+ export const resetCeilingWarning = () => {
27
+ warnedOfCeiling = false
28
+ }
29
+
30
+ /**
31
+ * A real element as the structural node `toRichText` walks (`lexicalDom.ts` takes a `DomLike`, not
32
+ * an `Element`, so it can be tested without a browser). Copied from `legacy/bridge.tsx`'s
33
+ * `asDomLike`; before it was ported, the live element went to `toRichText` as it was, and every
34
+ * rich `edit` threw on its `HTMLCollection` (`nodes.flatMap is not a function`) when it flushed.
35
+ */
36
+ const asDomLike = (node: Node): DomLike => {
37
+ if (node.nodeType === 3) return { text: node.textContent ?? '' }
38
+ const element = node as HTMLElement
39
+ const href = element.getAttribute?.('href')
40
+ const singleKey = element.getAttribute?.('data-single-key')
41
+ // `data-u90-link` (stamped by `RichText.tsx` in draft, and by `applyFormat`'s own `link` action
42
+ // below) is what lets `toRichText` read an untouched or freshly-linked anchor's `LinkTo` back
43
+ // exactly, rather than reclassifying it from `href` alone — forwarded here so it survives the
44
+ // trip from a live element into the structural `DomLike` `toRichText` walks.
45
+ const linkTarget = element.getAttribute?.('data-u90-link')
46
+ return {
47
+ attributes: {
48
+ ...(href ? { href } : {}),
49
+ ...(singleKey ? { 'data-single-key': singleKey } : {}),
50
+ ...(linkTarget ? { 'data-u90-link': linkTarget } : {}),
51
+ },
52
+ children: [...(element.childNodes ?? [])].map(asDomLike),
53
+ tag: element.tagName?.toLowerCase(),
54
+ }
55
+ }
56
+
57
+ /**
58
+ * A text leaf's value as it is stored: its text, with each Single chip a draft render marked
59
+ * (`data-single-key`) written back as its `{key}` token, so an edit never saves the number a token
60
+ * resolved to (R38), and each non-breaking space normalised to a plain one — the same thing
61
+ * `lexicalDom.ts`'s `inlines` does on the rich path, for the same reason: the words on record must
62
+ * match the words typed, not what the browser inserted for a second consecutive space.
63
+ */
64
+ export const textWithTokens = (node: Node): string => {
65
+ if (node.nodeType === 3) return (node.textContent ?? '').replace(/ /g, ' ')
66
+ if (node.nodeType !== 1) return ''
67
+ const key = (node as Element).getAttribute('data-single-key')
68
+ if (key && /^[a-z][a-zA-Z0-9_-]*$/.test(key)) return `{${key}}`
69
+ return [...node.childNodes].map(textWithTokens).join('')
70
+ }
71
+
72
+ /**
73
+ * `Omit` is not distributive over a union (`keyof (A | B)` is the *intersection* of their keys),
74
+ * so `Omit<SiteMessage, 'version'>` would collapse the whole `SiteMessage` union down to the
75
+ * fields every variant happens to share — which is none of them, `version` aside — rather than
76
+ * "each variant, minus `version`". This re-distributes over the union first.
77
+ */
78
+ type DistributiveOmit<T, K extends keyof never> = T extends unknown ? Omit<T, K> : never
79
+
80
+ /**
81
+ * The DOM half of the bridge, independent of React so it can be driven from jsdom in tests.
82
+ * `bridge/client.tsx`'s `BridgeClient` is the only caller in the running app; `bridge/createBridge.test.ts`
83
+ * drives it directly.
84
+ */
85
+ export function createBridge(win: Window, opts: BridgeOptions) {
86
+ const doc = win.document
87
+ /**
88
+ * The stamp the last `u90:hello` carried, put on every message sent after it; null until a hello
89
+ * carries one, and again after a hello that carries none. Without one, every message is the bytes
90
+ * it was before 1.3.0.
91
+ */
92
+ let stamp: BridgeStamp | null = null
93
+ const send = (m: DistributiveOmit<SiteMessage, 'version' | keyof BridgeStamp>) =>
94
+ opts.parent.postMessage((stamp ? { ...m, ...stamp, version: BRIDGE_VERSION } : { ...m, version: BRIDGE_VERSION }) as SiteMessage, opts.platformOrigin)
95
+
96
+ let pending: { path: string; kind: 'text' | 'rich' | 'link-label'; el: HTMLElement } | null = null
97
+ let timer: ReturnType<typeof setTimeout> | null = null
98
+ const valueOf = (el: HTMLElement, kind: 'text' | 'rich' | 'link-label') => (kind === 'rich' ? toRichText(asDomLike(el)) : textWithTokens(el))
99
+ const flush = () => {
100
+ if (timer) clearTimeout(timer)
101
+ timer = null
102
+ if (pending) {
103
+ send({ type: 'u90:edit', path: pending.path, kind: pending.kind, value: valueOf(pending.el, pending.kind) })
104
+ pending = null
105
+ }
106
+ }
107
+ /** Debounces an `edit` for the marked field holding `el` — typing, or a formatting command applied to it. */
108
+ const schedule = (el: HTMLElement) => {
109
+ pending = { path: el.getAttribute('data-u90-field')!, kind: el.getAttribute('data-u90-edit') as 'text' | 'rich' | 'link-label', el }
110
+ if (timer) clearTimeout(timer)
111
+ timer = setTimeout(flush, DEBOUNCE)
112
+ }
113
+
114
+ /*
115
+ * ── Ported from `legacy/bridge.tsx` (R40): which element is being edited, the selection inside
116
+ * it, and the formatting commands the Canvas's toolbar sends. ──
117
+ */
118
+ /** The one element the bridge made `contenteditable`, or null. */
119
+ let editing: HTMLElement | null = null
120
+ /**
121
+ * The selection inside `editing`, remembered while it still exists (legacy `lastRange`). The
122
+ * formatting toolbar is drawn by the Canvas, in the parent window: pressing it blurs this frame,
123
+ * and a blurred document reports a collapsed selection, so by the time `u90:format` arrives there
124
+ * is nothing left to format unless the range was kept. Only a range inside `editing` is kept — a
125
+ * selection elsewhere on the page is not this field's.
126
+ */
127
+ let lastRange: Range | null = null
128
+ const rememberRange = () => {
129
+ if (!editing) return
130
+ const selection = win.getSelection?.()
131
+ if (!selection || selection.rangeCount === 0) return
132
+ const range = selection.getRangeAt(0)
133
+ if (editing.contains(range.commonAncestorContainer)) lastRange = range.cloneRange()
134
+ }
135
+ const beginEditing = (el: HTMLElement, kind: 'text' | 'rich' | 'link-label') => {
136
+ if (editing === el) return
137
+ endEditing()
138
+ editing = el
139
+ el.setAttribute('contenteditable', kind === 'rich' ? 'true' : 'plaintext-only')
140
+ el.focus()
141
+ }
142
+ /** Hands the element back: what was typed goes out first, so releasing the field cannot drop it. */
143
+ const endEditing = () => {
144
+ const el = editing
145
+ if (!el) return
146
+ editing = null
147
+ lastRange = null
148
+ flush()
149
+ el.removeAttribute('contenteditable')
150
+ }
151
+ // Attribute-selector escaping; jsdom has no CSS.escape. Paths are generated (digits, letters,
152
+ // dots, `@`, `~`), so escaping quotes and backslashes is sufficient — same approach as legacy
153
+ // `EditingStyles`' own `escaped = selected?.replace(/"/g, '\\"')`. Used below by `markSelected`
154
+ // for a path, and by `applyFormat`'s `link` action for a freshly-linked url.
155
+ const q = (path: string) => path.replace(/["\\]/g, '\\$&')
156
+
157
+ /** A `DOMRect` narrowed to the four numbers `u90:caret`'s `field` carries (`protocol.ts`). */
158
+ const rectOf = (r: DOMRect): { top: number; left: number; width: number; height: number } => ({ top: r.top, left: r.left, width: r.width, height: r.height })
159
+
160
+ /**
161
+ * What each toolbar button asks the browser for (legacy `COMMANDS`). `execCommand` is deprecated
162
+ * and still the only formatting engine a `contenteditable` has; it emits exactly the tags
163
+ * `lexicalDom.ts` inverts. An action not listed here, and not `single` or `link`, does nothing.
164
+ */
165
+ const COMMANDS: Record<string, [string, string | undefined]> = {
166
+ bold: ['bold', undefined],
167
+ bullet: ['insertUnorderedList', undefined],
168
+ h1: ['formatBlock', '<h1>'],
169
+ h2: ['formatBlock', '<h2>'],
170
+ h3: ['formatBlock', '<h3>'],
171
+ italic: ['italic', undefined],
172
+ number: ['insertOrderedList', undefined],
173
+ paragraph: ['formatBlock', '<p>'],
174
+ strike: ['strikeThrough', undefined],
175
+ underline: ['underline', undefined],
176
+ }
177
+ /**
178
+ * Where a structured target's anchor should point. `url`/`email`/`tel` use the target's own
179
+ * address — the same thing `hrefOf` (`elements/leaves/link.ts`) computes for those three kinds,
180
+ * copied rather than called because `hrefOf` also resolves `page`/`entry` against an `ExpandCtx`
181
+ * (the site's page/entry graph) that this in-frame bridge has no access to and no need of: a
182
+ * page or entry target's `href` is never read back (`data-u90-link` carries the exact `to`), it
183
+ * only has to be *some* href so `execCommand('createLink', …)` has an anchor to build.
184
+ */
185
+ const hrefFor = (to: LinkTo): string => {
186
+ if ('url' in to) return to.url
187
+ if ('email' in to) return `mailto:${to.email}`
188
+ if ('tel' in to) return `tel:${to.tel.replace(/[^+\d]/g, '')}`
189
+ return '#'
190
+ }
191
+
192
+ /**
193
+ * `u90:format`, applied only while a rich field is being edited (legacy `applyFormat`): the
194
+ * command is resolved first, so an unknown action has no effect at all; then focus comes back and
195
+ * the remembered range is restored — in that order, since focusing collapses the selection — and
196
+ * the command runs. `single` inserts the non-editable `{key}` chip `lexicalDom.ts` reads back as a
197
+ * token; `link` with a `to` links the selection to that structured target — the anchor's `href`
198
+ * from `hrefFor`, its `data-u90-link` stamped `{ to, newTab: newTab ?? false }` exactly, so the
199
+ * read `asDomLike` takes next is exact rather than reclassified from `href`. `link` with a plain
200
+ * `url` and no `to` behaves as before (`{ to: { url }, newTab: false }`); with neither, unlinks.
201
+ * The edit is scheduled explicitly rather than trusting `execCommand` to fire `input`.
202
+ */
203
+ const applyFormat = (action: unknown, url?: string, to?: LinkTo, newTab?: boolean) => {
204
+ const el = editing
205
+ if (!el || el.getAttribute('data-u90-edit') !== 'rich' || typeof action !== 'string') return
206
+ let run: () => void
207
+ if (action === 'single') {
208
+ if (!url || !/^[^{}\s]+$/.test(url) || url === 'link') return
209
+ const chip = doc.createElement('span')
210
+ chip.setAttribute('data-single-key', url)
211
+ chip.setAttribute('contenteditable', 'false') // legacy set `.contentEditable`; the attribute is the same thing, and survives a DOM without the IDL property
212
+ chip.textContent = `{${url}}`
213
+ run = () => doc.execCommand('insertHTML', false, chip.outerHTML)
214
+ } else if (action === 'link') {
215
+ run = () => {
216
+ if (!url && !to) {
217
+ doc.execCommand('unlink')
218
+ return
219
+ }
220
+ const href = to ? hrefFor(to) : (url as string)
221
+ const stamp = to ? { to, newTab: newTab ?? false } : { to: { url: href }, newTab: false }
222
+ doc.execCommand('createLink', false, href)
223
+ // The selection this just linked is not reliably still addressable afterwards (execCommand
224
+ // moves/collapses it), so the new anchor is found by the href it was just given rather than
225
+ // through the selection.
226
+ el.querySelectorAll(`a[href="${q(href)}"]:not([data-u90-link])`).forEach((a) =>
227
+ a.setAttribute('data-u90-link', JSON.stringify(stamp)),
228
+ )
229
+ }
230
+ } else {
231
+ const command = COMMANDS[action]
232
+ if (!command) return
233
+ run = () => doc.execCommand(command[0], false, command[1])
234
+ }
235
+ el.focus({ preventScroll: true })
236
+ const selection = win.getSelection?.()
237
+ if (lastRange && selection) {
238
+ selection.removeAllRanges()
239
+ selection.addRange(lastRange)
240
+ }
241
+ run()
242
+ rememberRange()
243
+ schedule(el)
244
+ }
245
+
246
+ /** Outline the element at `path` (field or block) and clear any previous outline. */
247
+ const markSelected = (path: string | null) => {
248
+ doc.querySelectorAll('[data-u90-selected]').forEach((e) => e.removeAttribute('data-u90-selected'))
249
+ if (path) doc.querySelector(`[data-u90-field="${q(path)}"], [data-u90-block="${q(path)}"]`)?.setAttribute('data-u90-selected', '')
250
+ opts.onSelect?.(path)
251
+ }
252
+
253
+ /** `u90:select` from the Canvas also fires any `useReveal` listener whose prefix matches. */
254
+ const reveal = (path: string | null) => {
255
+ if (!path) return
256
+ for (const r of registry.reveals) if (path.startsWith(r.prefix)) r.fn()
257
+ }
258
+
259
+ /** Whether navigation is enabled (`u90:navigation`); off by default, matching v3's default. */
260
+ /*
261
+ * Whether the Canvas has this screen selected.
262
+ *
263
+ * **True until told otherwise, and the default is the deployment-order question.** A site
264
+ * re-vendored to this kit may be framed by a Canvas that predates `u90:active` and so never
265
+ * sends it. Defaulting to false there would mean every wheel is both forwarded AND prevented,
266
+ * leaving a page that cannot scroll at all — worse than the bug this fixes. Defaulting to
267
+ * true costs that pairing only the ability to pan the Canvas from over a frame, and panning
268
+ * over bare canvas still works.
269
+ *
270
+ * Against a Canvas that does send it the default barely exists: the frame is told on ready,
271
+ * before a client can reach for the wheel.
272
+ */
273
+ let isActive = true
274
+ let navigationEnabled = false
275
+
276
+ /*
277
+ * `hover`: no v3 equivalent to port — legacy never sent a hover message at all. New in v4, to
278
+ * spec (review round 1, finding 1): `pointerover`/`pointerout` on `document`, capture phase,
279
+ * resolved through the same `[data-u90-field], [data-u90-block]` marker pair `onClick` uses.
280
+ * `data-u90-hover` is the attribute `styles.ts`'s `[data-u90-hover]` rule keys on — set on the
281
+ * hovered element and cleared from whichever one held it before, so at most one element ever
282
+ * carries it.
283
+ */
284
+ let hoveredEl: HTMLElement | null = null
285
+ let hoveredPath: string | null = null
286
+ const onPointerOver = (e: Event) => {
287
+ const el = (e.target as Element | null)?.closest<HTMLElement>('[data-u90-field], [data-u90-block]') ?? null
288
+ const path = el?.getAttribute('data-u90-field') ?? el?.getAttribute('data-u90-block') ?? null
289
+ if (path === hoveredPath) return
290
+ hoveredEl?.removeAttribute('data-u90-hover')
291
+ hoveredEl = el
292
+ hoveredPath = path
293
+ hoveredEl?.setAttribute('data-u90-hover', '')
294
+ send({ type: 'u90:hover', path })
295
+ }
296
+ const onPointerOut = (e: Event) => {
297
+ // `pointerover` on whatever comes next handles a marked-to-marked transition; this only
298
+ // has to notice "left every marked element", which a null/unmarked `relatedTarget` means.
299
+ const related = (e as PointerEvent).relatedTarget as Element | null
300
+ if (related?.closest?.('[data-u90-field], [data-u90-block]')) return
301
+ if (hoveredPath === null) return
302
+ hoveredEl?.removeAttribute('data-u90-hover')
303
+ hoveredEl = null
304
+ hoveredPath = null
305
+ send({ type: 'u90:hover', path: null })
306
+ }
307
+
308
+ /*
309
+ * `caret`: no v3 equivalent to port either — legacy's `u90:caret` carried a `block` kind for
310
+ * its own floating toolbar, a concept this v1 kit has no equivalent of; v4's `u90:caret` is
311
+ * `path, top, left, width, height` (`protocol.ts`), so this reports the live caret's screen
312
+ * rect for whichever field is being typed into. `selectionchange` is a document-level event
313
+ * with no useful detail of its own, so the actual read happens on the next animation frame,
314
+ * guarded at every step per review round 1, finding 1: not (currently) editing at all, no
315
+ * selection, no range, or the field stopped being editable before the frame arrived.
316
+ */
317
+ const caretField = (): HTMLElement | null => {
318
+ const active = doc.activeElement as HTMLElement | null
319
+ if (!active || !active.hasAttribute('contenteditable')) return null
320
+ return active.closest<HTMLElement>('[data-u90-edit]')
321
+ }
322
+ const onSelectionChange = () => {
323
+ rememberRange()
324
+ if (!caretField()) return
325
+ const nextFrame: (fn: () => void) => void =
326
+ typeof win.requestAnimationFrame === 'function' ? (fn) => win.requestAnimationFrame(fn) : (fn) => win.setTimeout(fn, 0)
327
+ nextFrame(() => {
328
+ const el = caretField()
329
+ const path = el?.getAttribute('data-u90-field')
330
+ if (!el || !path) return
331
+ const selection = win.getSelection?.()
332
+ if (!selection || selection.rangeCount === 0) return
333
+ try {
334
+ const rect = selection.getRangeAt(0).getBoundingClientRect()
335
+ // `marks`/`block`/`field` are Canvas's rich-text toolbar's own business — the active
336
+ // formats and the outline of the field they apply to — so they are only worth reporting
337
+ // for a rich field; a plain text field has no formatting elements or block structure to
338
+ // read, and the caret message it sends stays exactly what it was before this change.
339
+ const anchor = selection.anchorNode
340
+ const rich = el.getAttribute('data-u90-edit') === 'rich' && anchor
341
+ const block = rich ? activeBlock(anchor, el) : undefined
342
+ const extra = rich ? { marks: activeMarks(anchor, el), field: rectOf(el.getBoundingClientRect()), ...(block ? { block } : {}) } : {}
343
+ send({ type: 'u90:caret', path, top: rect.top, left: rect.left, width: rect.width, height: rect.height, ...extra })
344
+ } catch {
345
+ // `getBoundingClientRect` is not implemented in every DOM (jsdom included); a caret
346
+ // report is a nicety, not a correctness requirement, so this is silently skipped.
347
+ }
348
+ })
349
+ }
350
+
351
+ const onClick = (e: Event) => {
352
+ const mouse = e as MouseEvent
353
+
354
+ // Navigation on: any plain link click browses, exactly as it would outside the Canvas —
355
+ // ahead of the marked-element selection below, so a marked field wrapped in a link still
356
+ // navigates while navigation is on (ported from `legacy/bridge.tsx`'s `onClick`, which
357
+ // checks `navigationRef.current` before it ever looks for `FIELD_ATTR`).
358
+ if (navigationEnabled) {
359
+ const a = (e.target as Element | null)?.closest?.('a[href]') as HTMLAnchorElement | null
360
+ const navigable = a && !a.hasAttribute('download') && (!a.target || a.target === '_self')
361
+ if (navigable && !mouse.metaKey && !mouse.ctrlKey && !mouse.shiftKey && !mouse.altKey) {
362
+ const url = new URL(a.href, win.location.href)
363
+ if (url.origin === win.location.origin) {
364
+ e.preventDefault()
365
+ send({ type: 'u90:navigate', path: url.pathname + url.search + url.hash })
366
+ return
367
+ }
368
+ }
369
+ }
370
+
371
+ const el = (e.target as Element | null)?.closest<HTMLElement>('[data-u90-field], [data-u90-block]')
372
+ const path = el?.getAttribute('data-u90-field') ?? el?.getAttribute('data-u90-block') ?? null
373
+ if (el && el.hasAttribute('data-u90-field')) {
374
+ // Ported from legacy (R40): inside the editor a click on a marked field is for selecting it,
375
+ // so it neither follows a link nor submits a form around it, and the page's own handlers
376
+ // never see it.
377
+ e.preventDefault()
378
+ e.stopPropagation()
379
+ } else if (!navigationEnabled && (e.target as Element | null)?.closest?.('a[href], button')) {
380
+ // Navigation off, a link or button with no field of its own — a card's stretched anchor, the
381
+ // padding around a marked label: swallowed, so the frame never navigates away or submits.
382
+ // Legacy also stopped the event here; v4 only prevents the default, so a block's own button
383
+ // (a menu toggle) still works while the Canvas is open.
384
+ e.preventDefault()
385
+ }
386
+ markSelected(path)
387
+ send({ type: 'u90:select', path })
388
+ const edit = el?.getAttribute('data-u90-edit') as 'text' | 'rich' | 'link-label' | null
389
+ if (el && edit && isEditableLeaf(el)) {
390
+ // Resolve the caret target before the element becomes editable, apply it after —
391
+ // `contenteditable` changes hit-testing (see `caret.ts`'s `caretTargetIn` doc).
392
+ const target = caretTargetIn(el, mouse.clientX, mouse.clientY)
393
+ beginEditing(el, edit)
394
+ applyCaret(target)
395
+ }
396
+ }
397
+
398
+ const onInput = (e: Event) => {
399
+ const el = (e.target as Element | null)?.closest<HTMLElement>('[data-u90-edit]')
400
+ if (el) {
401
+ schedule(el)
402
+ // The Canvas owns save debouncing. Its inspector needs each input immediately, including
403
+ // during continuous typing and just before focus crosses into the parent document.
404
+ flush()
405
+ // Other copies of a responsive button live in this same frame, which Canvas excludes
406
+ // when echoing an in-place edit. Preview them locally; applyPreview leaves the caret alone.
407
+ if (el.getAttribute('data-u90-edit') === 'link-label')
408
+ preview({ kind: 'link-label', path: el.getAttribute('data-u90-field')!, value: textWithTokens(el) })
409
+ }
410
+ }
411
+
412
+ /*
413
+ * Ported from legacy `onFocusOut` (R40). Leaving the document is not leaving the field: pressing
414
+ * the Canvas's formatting toolbar, switching applications or clicking the browser's own chrome all
415
+ * fire `focusout` here exactly as tabbing to the next field does, and ending the edit on them made
416
+ * the toolbar inert. `relatedTarget` tells them apart — focus moving out of this document reports
417
+ * none — so the element stays editable and its selection is remembered for `u90:format`. Only
418
+ * focus moving to another element of this document, outside the field, ends the edit.
419
+ */
420
+ const onBlur = (e: Event) => {
421
+ if (!editing || e.target !== editing) return
422
+ const next = (e as FocusEvent).relatedTarget as Node | null
423
+ if (!next || !doc.contains(next)) {
424
+ rememberRange()
425
+ // The inspector can write the same field next. Hand off the current value now, while
426
+ // retaining the caret for a formatting-toolbar command that merely borrowed focus.
427
+ flush()
428
+ return
429
+ }
430
+ if (editing.contains(next)) return
431
+ endEditing()
432
+ }
433
+
434
+ /**
435
+ * How to take back every preview applied since the last refresh, oldest first. React writes only
436
+ * what changed in its own model of the page, which a preview does not touch, so the refresh is
437
+ * authoritative only over a page that is exactly React's again: `u90:refresh` replays these newest
438
+ * first before it asks for the refresh (see `applyPreview`).
439
+ */
440
+ let journal: Undo[] = []
441
+ let previewed: PreviewInstruction[] = []
442
+ let stopped = false
443
+ /** Text and media previews already journaled for a path: the first undo restores the original, so later ones add nothing. */
444
+ let coalesced = new Set<string>()
445
+ /** Keep the live editor across an undo/React commit. Clones isolate its current
446
+ * typing from the original nodes React is about to update; a fresh undo retains
447
+ * those server nodes so ending the edit still allows an authoritative refresh. */
448
+ const preserveEditing = (): null | (() => void) => {
449
+ const el = editing
450
+ if (!el?.isConnected) return null
451
+ const children = [...el.childNodes].map((node) => node.cloneNode(true))
452
+ const bookmark = (node: Node | null, offset: number) => {
453
+ if (!node || !el.contains(node)) return null
454
+ const path: number[] = []
455
+ for (let current = node; current !== el; current = current.parentNode!) {
456
+ path.unshift(Array.prototype.indexOf.call(current.parentNode!.childNodes, current))
457
+ }
458
+ return { path, offset }
459
+ }
460
+ const selection = win.getSelection?.()
461
+ const liveAnchor = bookmark(selection?.anchorNode ?? null, selection?.anchorOffset ?? 0)
462
+ const liveFocus = bookmark(selection?.focusNode ?? null, selection?.focusOffset ?? 0)
463
+ const anchor = liveAnchor ?? bookmark(lastRange?.startContainer ?? null, lastRange?.startOffset ?? 0)
464
+ const focus = liveFocus ?? bookmark(lastRange?.endContainer ?? null, lastRange?.endOffset ?? 0)
465
+ const field = el.getAttribute('data-u90-field')!
466
+ return () => {
467
+ if (editing !== el || !el.isConnected) return
468
+ const serverChildren = [...el.childNodes]
469
+ el.replaceChildren(...children)
470
+ // An inspector preview predating these keystrokes must never be replayed.
471
+ previewed = previewed.filter((entry) => !((entry.kind === 'text' || entry.kind === 'rich' || entry.kind === 'link-label') && entry.path === field))
472
+ coalesced.add(`${el.getAttribute('data-u90-edit')}:${field}`)
473
+ journal.push(() => el.replaceChildren(...serverChildren))
474
+ if (el.getAttribute('data-u90-edit') === 'link-label') {
475
+ // The active label was restored above, but a delayed React commit also wrote its older
476
+ // words into responsive copies. Rebase those copies onto the live words, keeping their
477
+ // new server nodes for the next undo. Do not hand focus back as an inspector preview would.
478
+ const instruction: PreviewInstruction = { kind: 'link-label', path: field, value: textWithTokens(el) }
479
+ const undo = applyPreview(doc, instruction)
480
+ if (undo) {
481
+ journal.push(undo)
482
+ previewed.push(instruction)
483
+ }
484
+ }
485
+ if (anchor && focus && selection) {
486
+ const at = (path: number[]) => path.reduce<Node>((node, index) => node.childNodes[index]!, el)
487
+ const range = doc.createRange()
488
+ if (liveAnchor && liveFocus) {
489
+ selection.setBaseAndExtent(at(anchor.path), anchor.offset, at(focus.path), focus.offset)
490
+ lastRange = selection.getRangeAt(0).cloneRange()
491
+ } else {
492
+ range.setStart(at(anchor.path), anchor.offset)
493
+ range.setEnd(at(focus.path), focus.offset)
494
+ lastRange = range
495
+ }
496
+ }
497
+ }
498
+ }
499
+ /** Counts refreshes, so a fragment fetched before one and answered after it is dropped. */
500
+ let generation = 0
501
+ /**
502
+ * Whether a refresh has been asked for and its render has not yet committed. `router.refresh()` takes
503
+ * seconds: its payload commits well after `opts.refresh()` returns, and React reconciles it against its
504
+ * own model of the page, which excludes any preview made meanwhile. A node-moving preview made in that
505
+ * window leaves the document and model disagreeing. React-owned block orders remain live;
506
+ * legacy node-moving previews wait until commit. Field previews are rebased around the
507
+ * actual commit so a later refresh cannot restore values from an obsolete server render.
508
+ */
509
+ let refreshing = false
510
+ let refreshAgain = false
511
+ let refreshingTimer: ReturnType<typeof setTimeout> | null = null
512
+ /**
513
+ * Report a slow refresh without pretending it committed. Clearing the boundary
514
+ * on a timer lets a late response overwrite moves made after a queued refresh.
515
+ */
516
+ const REFRESH_CEILING_MS = 5000
517
+ const setRefreshing = (value: boolean) => {
518
+ refreshing = value
519
+ if (refreshingTimer) clearTimeout(refreshingTimer)
520
+ refreshingTimer = null
521
+ if (value) {
522
+ refreshingTimer = setTimeout(() => {
523
+ refreshingTimer = null
524
+ warnOnceThatRefreshIsSlow()
525
+ }, REFRESH_CEILING_MS)
526
+ // A pending timer must not keep a Node process alive (tests); browsers return a number.
527
+ ;(refreshingTimer as unknown as { unref?: () => void }).unref?.()
528
+ }
529
+ else if (refreshAgain) requestRefresh()
530
+ }
531
+ function requestRefresh() {
532
+ if (refreshing && [...registry.blockOrders].some((owner) => owner.document === doc)) {
533
+ refreshAgain = true
534
+ return
535
+ }
536
+ refreshAgain = false
537
+ if ([...registry.blockOrders].some((owner) => owner.document === doc && owner.needsReload())) {
538
+ win.location.reload()
539
+ return
540
+ }
541
+ // One refresh boundary at a time: newer moves stay visible through the older render.
542
+ // Clear only the moves included when THIS request starts, never when another is queued.
543
+ for (const owner of registry.blockOrders) if (owner.document === doc) owner.beforeRefresh()
544
+ const restoreEditing = preserveEditing()
545
+ try {
546
+ undoPreviews()
547
+ restoreEditing?.()
548
+ } finally {
549
+ setRefreshing(true)
550
+ opts.refresh()
551
+ }
552
+ }
553
+ const MOVES_NODES = new Set<PreviewInstruction['kind']>(['move', 'insert', 'replace', 'fragment'])
554
+ const preview = (instruction: PreviewInstruction) => {
555
+ // React-owned order is safe even while a refresh is pending. Only legacy DOM moves
556
+ // still need the guard below; they can disagree with React's tree during a commit.
557
+ if (instruction.kind === 'move') {
558
+ for (const owner of registry.blockOrders) {
559
+ if (owner.document === doc && owner.move(instruction.path, instruction.before)) return
560
+ }
561
+ }
562
+ if (refreshing && MOVES_NODES.has(instruction.kind)) return
563
+ if ((instruction.kind === 'text' || instruction.kind === 'rich' || instruction.kind === 'link-label') && editing?.getAttribute('data-u90-field') === instruction.path && !doc.hasFocus()) {
564
+ // Focus moved to the inspector. This is a new edit, not an echo into a field being typed in.
565
+ endEditing()
566
+ }
567
+ const key = instruction.kind === 'text' || instruction.kind === 'rich' || instruction.kind === 'link-label' || instruction.kind === 'media' ? `${instruction.kind}:${instruction.path}` : null
568
+ const undo = applyPreview(doc, instruction)
569
+ if (!undo) return
570
+ if (key) previewed = previewed.filter((entry) => !(entry.kind === instruction.kind && entry.path === instruction.path))
571
+ previewed.push(instruction)
572
+ if (key) {
573
+ if (coalesced.has(key)) return
574
+ coalesced.add(key)
575
+ }
576
+ journal.push(undo)
577
+ }
578
+ /** Puts the page back as React last rendered it. One failing undo (a stale one) never stops the rest. */
579
+ const undoPreviews = () => {
580
+ const undos = journal.reverse()
581
+ journal = []
582
+ previewed = []
583
+ coalesced = new Set()
584
+ generation++
585
+ for (const undo of undos) {
586
+ try {
587
+ undo()
588
+ } catch {
589
+ // A node the page no longer has; the sweep below and the refresh deal with what is left.
590
+ }
591
+ }
592
+ // Anything a stale journal missed (a frame that reloaded mid-flight): React never created these.
593
+ doc.querySelectorAll('[data-u90-skeleton], [data-u90-block*="/u90-pending-"]').forEach((el) => el.remove())
594
+ doc.querySelectorAll('[data-u90-removed]').forEach((el) => {
595
+ // The attribute holds the `style` attribute the node had (JSON; null for none), as `applyPreview` hid it.
596
+ let written: unknown = null
597
+ try {
598
+ written = JSON.parse(el.getAttribute('data-u90-removed') ?? 'null')
599
+ } catch {
600
+ // Not ours to read: the node loses its style attribute, below.
601
+ }
602
+ if (typeof written === 'string') el.setAttribute('style', written)
603
+ else el.removeAttribute('style')
604
+ el.removeAttribute('data-u90-removed')
605
+ })
606
+ }
607
+
608
+ /**
609
+ * Fetches `block` rendered on its own from this site's fragment page and puts it where the
610
+ * skeleton at `path` is. The request is relative: the frame is same-origin with the site, so the
611
+ * draft cookie goes with it and no CORS question arises. The page is the site's own, inside its
612
+ * layout, so the block is found in the response by its marker rather than taken as the body.
613
+ *
614
+ * Never reports and never retries. A failed, refused or empty answer leaves the skeleton for the
615
+ * `u90:refresh` that follows the save, which is what would have happened anyway. An answer that
616
+ * arrives after a refresh is dropped: that refresh cleared the skeleton, and a block put in now
617
+ * would be one nothing will undo.
618
+ */
619
+ const loadFragment = async ({ path, block }: Extract<PreviewInstruction, { kind: 'fragment' }>) => {
620
+ const asked = generation
621
+ try {
622
+ const response = await win.fetch(`/_u90/fragment?block=${encodeURIComponent(block)}&path=${encodeURIComponent(path)}`)
623
+ if (!response.ok) return
624
+ const page = new (win as Window & typeof globalThis).DOMParser().parseFromString(await response.text(), 'text/html')
625
+ const found = [...page.querySelectorAll('[data-u90-block]')].find((el) => el.getAttribute('data-u90-block') === path)
626
+ if (found && asked === generation) preview({ kind: 'replace', path, html: found.outerHTML })
627
+ } catch {
628
+ // The skeleton stays until the refresh.
629
+ }
630
+ }
631
+
632
+ const onMessage = (e: MessageEvent) => {
633
+ if (e.origin !== opts.platformOrigin || e.source !== (opts.parent as unknown)) return
634
+ const m = parseCanvasMessage(e.data)
635
+ if (!m) return
636
+ if (m.type === 'u90:hello') {
637
+ // The Canvas registered this frame after its `start()` ready went out, and asks again. The
638
+ // height `start()` reported was lost the same way, so it is reported again. The stamp is taken
639
+ // first, so the answer already carries it.
640
+ stamp = m.frame === undefined ? null : { frame: m.frame, locale: m.locale ?? null, revision: m.revision ?? 0 }
641
+ send({ type: 'u90:ready', path: win.location.pathname })
642
+ // The right-to-left check was lost the same way. Forgotten before the height, so a height that changed sends it once, not twice.
643
+ lastHeight = 0
644
+ lastDirection = ''
645
+ reportHeight()
646
+ reportDirection()
647
+ } else if (m.type === 'u90:refresh') {
648
+ flush()
649
+ requestRefresh()
650
+ } else if (m.type === 'u90:preview') {
651
+ if (m.instruction.kind === 'fragment') {
652
+ // No skeleton was placed while a refresh is in flight (`preview` drops it), so there is nothing to fill.
653
+ if (!refreshing) void loadFragment(m.instruction)
654
+ } else preview(m.instruction)
655
+ } else if (m.type === 'u90:select') {
656
+ markSelected(m.path)
657
+ reveal(m.path)
658
+ } else if (m.type === 'u90:flush') {
659
+ flush()
660
+ send({ type: 'u90:flushed', requestId: m.requestId })
661
+ } else if (m.type === 'u90:format') {
662
+ applyFormat(m.action, m.url, m.to, m.newTab)
663
+ } else if (m.type === 'u90:scroll') {
664
+ win.scrollTo({ top: m.to === 'top' ? 0 : (m.fraction ?? 0) * (doc.documentElement.scrollHeight - win.innerHeight) })
665
+ } else if (m.type === 'u90:viewport') {
666
+ // Ported from legacy's own `u90:viewport` branch (`bridge.tsx`, its `onMessage`): pin only
667
+ // on a real change, and re-measure the document's height afterwards — pinning changes it.
668
+ const height = typeof m.height === 'number' && m.height > 0 ? m.height : null
669
+ if (height !== pinnedHeight) {
670
+ pinnedHeight = height
671
+ pinViewport(height)
672
+ reportHeight()
673
+ }
674
+ } else if (m.type === 'u90:active') {
675
+ isActive = m.active
676
+ } else if (m.type === 'u90:navigation') {
677
+ navigationEnabled = m.enabled
678
+ // `styles.ts`'s `EDITING_CSS` keys both of legacy's `navigation` on/off branches off this
679
+ // attribute, since the stylesheet is a static string and can no longer take `navigation`
680
+ // as a per-render prop the way legacy's `EditingStyles` did.
681
+ doc.documentElement.toggleAttribute('data-u90-navigation', navigationEnabled)
682
+ }
683
+ }
684
+
685
+ /*
686
+ * ── Ported from `legacy/bridge.tsx`, unchanged in shape: `height`/`scrolled`/the gesture group
687
+ * below all still only ever send (site → canvas); `u90:viewport` is the one Canvas → site
688
+ * message among them, placed here because it calls `reportHeight`. Every one of them was gated
689
+ * in v3 behind `isActive`/`u90:focus` multi-screen bookkeeping that has no v4 equivalent —
690
+ * bridge v4's Canvas→Site table has no `active`/`focus` message at all — so that gating is
691
+ * dropped rather than ported. `height`/`scrolled`/the gesture group are not unit-tested beyond
692
+ * not throwing at `start()`; see the task report for the line-range each handler came from.
693
+ * `u90:viewport`, `u90:hover` and `u90:caret` (above) are unit-tested. ──
694
+ */
695
+
696
+ // `height`: legacy `reportHeight`/`ResizeObserver`, ~legacy/bridge.tsx:1057-1067.
697
+ let lastHeight = 0
698
+ const reportHeight = () => {
699
+ const height = doc.documentElement.scrollHeight
700
+ if (Math.abs(height - lastHeight) < 1) return
701
+ lastHeight = height
702
+ send({ type: 'u90:height', height })
703
+ reportDirection()
704
+ }
705
+
706
+ /** Draft only: the language a `u90-locale` page is shown in (`data-u90-language`), put on `<html>`, which the root layout rendered without it. */
707
+ const applyDraftLanguage = () => {
708
+ const marker = doc.querySelector('[data-u90-language]')
709
+ if (!marker) return
710
+ doc.documentElement.lang = marker.getAttribute('data-u90-language') ?? doc.documentElement.lang
711
+ doc.documentElement.dir = marker.getAttribute('data-u90-dir') === 'rtl' ? 'rtl' : 'ltr'
712
+ }
713
+ let lastDirection = ''
714
+ /**
715
+ * The right-to-left check: on a `dir="rtl"` page, how far it overflows the screen and which fields reach past an edge. Sent when it
716
+ * changes. A left-to-right page sends none. A field path longer than `isSiteMessage` accepts is left out, so the message is never refused.
717
+ */
718
+ const reportDirection = () => {
719
+ if (doc.documentElement.dir !== 'rtl') return
720
+ const width = doc.documentElement.clientWidth
721
+ const overflow = Math.max(0, Math.round(doc.documentElement.scrollWidth - width))
722
+ const offenders: string[] = []
723
+ for (const el of doc.querySelectorAll('[data-u90-field]')) {
724
+ const box = el.getBoundingClientRect()
725
+ const path = el.getAttribute('data-u90-field')
726
+ if (path && path.length <= MAX_OFFENDER_LENGTH && box.width > 0 && (box.left < -1 || box.right > width + 1) && !offenders.includes(path)) offenders.push(path)
727
+ if (offenders.length === MAX_OFFENDERS) break
728
+ }
729
+ const key = `${overflow}|${offenders.join(' ')}`
730
+ if (key === lastDirection) return
731
+ lastDirection = key
732
+ send({ type: 'u90:direction', dir: 'rtl', overflow, offenders })
733
+ }
734
+ let heights: InstanceType<typeof ResizeObserver> | null = null
735
+
736
+ /*
737
+ * `u90:viewport` (Canvas → site): what a `vh`/`svh`/`lvh`/`dvh`/`vmin`/`vmax` declaration
738
+ * measures against while this frame is drawn at a fixed height in the Canvas, rather than
739
+ * whatever the browser's own (irrelevant, cross-origin-driven) viewport happens to be. Ported
740
+ * from `legacy/bridge.tsx`'s `VIEWPORT_UNIT`/`PinnedDeclaration`/`releaseViewport`/`pinValue`/
741
+ * `pinViewport` (lines 238–333) — real CSSOM rewriting, not a custom property, because these
742
+ * sites reach for viewport units inside `min()`/`calc()` (a real fixture: `min-height:
743
+ * min(100svh, 2000px)`), which a rule of our own could not out-cascade without matching the
744
+ * site's own specificity, layer and position. `doc`/`win` replace legacy's bare `document`/
745
+ * `window` (legacy is browser-only; this runs from jsdom in tests too); everything else,
746
+ * including the comments, is unchanged.
747
+ */
748
+ const VIEWPORT_UNIT = /(-?\d*\.?\d+)(svh|lvh|dvh|vh|vmin|vmax)\b/gi
749
+
750
+ /** What a locked declaration said before it was pinned, so it can be given back. */
751
+ type PinnedDeclaration = {
752
+ priority: string
753
+ property: string
754
+ rule: CSSStyleRule
755
+ value: string
756
+ }
757
+
758
+ let pinned: PinnedDeclaration[] = []
759
+ let pinnedHeight: number | null = null
760
+
761
+ /** Put every pinned declaration back exactly as it was. */
762
+ const releaseViewport = (): void => {
763
+ for (const entry of pinned) {
764
+ try {
765
+ entry.rule.style.setProperty(entry.property, entry.value, entry.priority)
766
+ } catch {
767
+ // The stylesheet was replaced underneath us — there is nothing to restore to.
768
+ }
769
+ }
770
+ pinned = []
771
+ }
772
+
773
+ /** Rewrite the viewport units in one declaration to the pixels they would be at `height`. */
774
+ const pinValue = (value: string, height: number, width: number): string =>
775
+ value.replace(VIEWPORT_UNIT, (_whole, amount: string, unit: string) => {
776
+ const lower = unit.toLowerCase()
777
+ const basis = lower === 'vmin' ? Math.min(width, height) : lower === 'vmax' ? Math.max(width, height) : height
778
+ return `${(Number(amount) * basis) / 100}px`
779
+ })
780
+
781
+ /** Pin every viewport unit on the page to `height`, or release them all when it is null. */
782
+ const pinViewport = (height: null | number): void => {
783
+ releaseViewport()
784
+ if (height === null) return
785
+
786
+ const width = doc.documentElement.clientWidth
787
+
788
+ const walk = (rules: CSSRuleList): void => {
789
+ for (const rule of Array.from(rules)) {
790
+ const style = (rule as CSSStyleRule).style
791
+ if (style) {
792
+ for (const property of Array.from(style)) {
793
+ const value = style.getPropertyValue(property)
794
+ if (!VIEWPORT_UNIT.test(value)) continue
795
+ // `test` on a `g` regex carries `lastIndex` between calls; left alone, every
796
+ // second declaration would be skipped.
797
+ VIEWPORT_UNIT.lastIndex = 0
798
+
799
+ const priority = style.getPropertyPriority(property)
800
+ try {
801
+ style.setProperty(property, pinValue(value, height, width), priority)
802
+ } catch {
803
+ continue
804
+ }
805
+ pinned.push({ priority, property, rule: rule as CSSStyleRule, value })
806
+ }
807
+ }
808
+
809
+ // `@media`, `@supports`, `@layer`, `@container`, and nested rules.
810
+ const grouped = (rule as CSSGroupingRule).cssRules
811
+ if (grouped?.length) walk(grouped)
812
+ }
813
+ }
814
+
815
+ for (const sheet of Array.from(doc.styleSheets)) {
816
+ try {
817
+ walk(sheet.cssRules)
818
+ } catch {
819
+ // Cross-origin, and not readable from here.
820
+ }
821
+ }
822
+ }
823
+
824
+ // `scrolled`: legacy `reportScroll`, ~legacy/bridge.tsx:1108-1121 (rAF-coalesced).
825
+ let scrollPending = 0
826
+ const reportScroll = () => {
827
+ if (scrollPending) return
828
+ scrollPending = win.requestAnimationFrame(() => {
829
+ scrollPending = 0
830
+ send({ type: 'u90:scrolled', top: win.scrollY, max: doc.documentElement.scrollHeight - win.innerHeight })
831
+ })
832
+ }
833
+
834
+ /*
835
+ * gesture — wheel → `zoom`/`wheel`: legacy `onWheel`, ~legacy/bridge.tsx:1079-1090.
836
+ *
837
+ * Two lines here are the whole of the active-screen rule, and the v4 port dropped both on the
838
+ * reasoning that the Canvas→Site table had no `active` message. It does now, because this is
839
+ * what needed it: without them every wheel was forwarded AND the page scrolled under it, so
840
+ * one gesture moved the page and panned the Canvas at the same time.
841
+ *
842
+ * The active screen keeps its own wheel — return before sending, and WITHOUT preventing the
843
+ * default, so the page scrolls as a page does.
844
+ *
845
+ * An inactive screen's wheel belongs to the Canvas, and the page must not also take it. Only
846
+ * the site can stop that: `preventDefault` has to happen in the document that owns the scroll,
847
+ * and that is never the Canvas. `stopImmediatePropagation` goes with it because a smooth-scroll
848
+ * library listens for the wheel itself and then scrolls programmatically, which `preventDefault`
849
+ * alone does not touch — this listener is on the capture phase so it runs before any of them.
850
+ */
851
+ const onWheel = (e: WheelEvent) => {
852
+ if (e.ctrlKey || e.metaKey) {
853
+ e.preventDefault()
854
+ send({ type: 'u90:zoom', deltaY: e.deltaY, x: e.clientX, y: e.clientY })
855
+ return
856
+ }
857
+ if (isActive) return
858
+ e.preventDefault()
859
+ e.stopPropagation()
860
+ e.stopImmediatePropagation()
861
+ send({ type: 'u90:wheel', deltaX: e.deltaX, deltaY: e.deltaY })
862
+ }
863
+
864
+ // gesture — middle-button pan → `grab`/`drag`/`drop`: legacy `onPointerDown`/`onPointerMove`/
865
+ // `onPointerUp`/`onAuxClick`, ~legacy/bridge.tsx:1130-1190.
866
+ let panning: number | null = null
867
+ const onPointerDown = (e: PointerEvent) => {
868
+ // A press anywhere else releases the field being edited (legacy `onPointerDown`, R40): with
869
+ // `onBlur` keeping the field when focus leaves for nowhere in particular, a press on plain page
870
+ // copy — which moves focus to no element, the same `relatedTarget: null` as leaving the frame —
871
+ // would otherwise leave it editable.
872
+ if (editing && !editing.contains(e.target as Node)) endEditing()
873
+ if (e.button !== 1 || panning !== null) return
874
+ e.preventDefault()
875
+ panning = e.pointerId
876
+ try {
877
+ doc.documentElement.setPointerCapture(e.pointerId)
878
+ } catch {
879
+ // Capture is a nicety; the window listeners still see the moves.
880
+ }
881
+ send({ type: 'u90:grab', screenX: e.screenX, screenY: e.screenY })
882
+ }
883
+ const onPointerMove = (e: PointerEvent) => {
884
+ if (e.pointerId !== panning) return
885
+ send({ type: 'u90:drag', screenX: e.screenX, screenY: e.screenY })
886
+ }
887
+ const onPointerUp = (e: PointerEvent) => {
888
+ if (e.pointerId !== panning) return
889
+ panning = null
890
+ try {
891
+ doc.documentElement.releasePointerCapture(e.pointerId)
892
+ } catch {
893
+ // Already released.
894
+ }
895
+ send({ type: 'u90:drop' })
896
+ }
897
+ const onAuxClick = (e: MouseEvent) => {
898
+ if (e.button === 1) e.preventDefault()
899
+ }
900
+
901
+ // gesture — keydown/keyup → `key`: legacy `CANVAS_KEYS`/`isTyping`/`onKey`, ~legacy/bridge.tsx:1211-1259.
902
+ const CANVAS_KEYS = new Set(['KeyF', 'KeyG', 'KeyH', 'KeyN', 'KeyT', 'KeyV', 'Space'])
903
+ const isTyping = (target: EventTarget | null): boolean => {
904
+ const element = target as HTMLElement | null
905
+ const tag = element?.tagName
906
+ return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || Boolean(element?.isContentEditable)
907
+ }
908
+ const onKey = (e: KeyboardEvent) => {
909
+ if (e.metaKey || e.ctrlKey || e.altKey || isTyping(e.target)) return
910
+ if (!CANVAS_KEYS.has(e.code)) return
911
+ if (e.code === 'Space') e.preventDefault()
912
+ send({ type: 'u90:key', code: e.code, down: e.type === 'keydown' })
913
+ }
914
+
915
+ const style = doc.createElement('style')
916
+ style.setAttribute('data-u90-editing', '')
917
+ style.textContent = EDITING_CSS
918
+
919
+ const bridge = {
920
+ start() {
921
+ stopped = false
922
+ // First, so a test observing `start()`'s very first message never races height/scroll
923
+ // reporting (both are no-ops against jsdom's zero layout anyway, but this holds regardless
924
+ // of that).
925
+ send({ type: 'u90:ready', path: win.location.pathname })
926
+
927
+ doc.head.appendChild(style)
928
+ applyDraftLanguage()
929
+ doc.addEventListener('click', onClick, true)
930
+ doc.addEventListener('input', onInput, true)
931
+ doc.addEventListener('focusout', onBlur, true)
932
+ doc.addEventListener('pointerover', onPointerOver, true)
933
+ doc.addEventListener('pointerout', onPointerOut, true)
934
+ doc.addEventListener('selectionchange', onSelectionChange)
935
+ win.addEventListener('message', onMessage)
936
+ win.addEventListener('wheel', onWheel, { capture: true, passive: false })
937
+ win.addEventListener('scroll', reportScroll, { passive: true })
938
+ win.addEventListener('pointerdown', onPointerDown)
939
+ win.addEventListener('pointermove', onPointerMove)
940
+ win.addEventListener('pointerup', onPointerUp)
941
+ win.addEventListener('auxclick', onAuxClick)
942
+ win.addEventListener('keydown', onKey)
943
+ win.addEventListener('keyup', onKey)
944
+
945
+ // Not in lib.dom.d.ts's `Window` interface (only as the ambient global constructor), and
946
+ // absent altogether in jsdom — hence the cast to check for it and the guard around it.
947
+ const ResizeObserverCtor = (win as unknown as { ResizeObserver?: typeof ResizeObserver }).ResizeObserver
948
+ if (typeof ResizeObserverCtor !== 'undefined') {
949
+ heights = new ResizeObserverCtor(reportHeight)
950
+ heights.observe(doc.documentElement)
951
+ }
952
+ reportHeight()
953
+ reportDirection()
954
+
955
+ registry.current = bridge
956
+ },
957
+ stop() {
958
+ stopped = true
959
+ endEditing() // what was typed goes out, and no element is left contenteditable
960
+ flush()
961
+ releaseViewport()
962
+ style.remove()
963
+ doc.removeEventListener('click', onClick, true)
964
+ doc.removeEventListener('input', onInput, true)
965
+ doc.removeEventListener('focusout', onBlur, true)
966
+ doc.removeEventListener('pointerover', onPointerOver, true)
967
+ doc.removeEventListener('pointerout', onPointerOut, true)
968
+ doc.removeEventListener('selectionchange', onSelectionChange)
969
+ win.removeEventListener('message', onMessage)
970
+ win.removeEventListener('wheel', onWheel, { capture: true })
971
+ win.removeEventListener('scroll', reportScroll)
972
+ win.removeEventListener('pointerdown', onPointerDown)
973
+ win.removeEventListener('pointermove', onPointerMove)
974
+ win.removeEventListener('pointerup', onPointerUp)
975
+ win.removeEventListener('auxclick', onAuxClick)
976
+ win.removeEventListener('keydown', onKey)
977
+ win.removeEventListener('keyup', onKey)
978
+ heights?.disconnect()
979
+ heights = null
980
+ refreshAgain = false
981
+ setRefreshing(false)
982
+ registry.current = null
983
+ },
984
+ /** Called once before a server subtree commits. The first boundary consumes
985
+ * the journal, so sibling/nested boundaries cannot replay it twice. */
986
+ prepareCommit(): null | (() => void) {
987
+ if (journal.length === 0) return null
988
+ const restoreEditing = preserveEditing()
989
+ const field = editing?.getAttribute('data-u90-field')
990
+ const instructions = previewed.filter((entry) => !((entry.kind === 'text' || entry.kind === 'rich' || entry.kind === 'link-label') && entry.path === field))
991
+ undoPreviews()
992
+ return () => {
993
+ if (stopped) return
994
+ for (const instruction of instructions) preview(instruction)
995
+ restoreEditing?.()
996
+ }
997
+ },
998
+ flush,
999
+ /** The host says its refresh has committed (`false`), or is running (`true`); see `refreshing`. */
1000
+ setRefreshing,
1001
+ markSelected,
1002
+ /** `u90:path`, called from `BridgeClient`'s `usePathname()` effect after a client-side navigation. */
1003
+ notifyPath(path: string) {
1004
+ send({ type: 'u90:path', path })
1005
+ },
1006
+ }
1007
+ return bridge
1008
+ }
1009
+
1010
+ export type Bridge = ReturnType<typeof createBridge>