@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,1987 @@
1
+ 'use client'
2
+
3
+ import { acceptsLanguageFrame, languageFrameNavigation, type LanguageFrameContext } from './preview'
4
+
5
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
6
+
7
+ import {
8
+ BRIDGE_VERSION,
9
+ CANVAS_FRAME_NAME,
10
+ type EditorBlock,
11
+ type ManifestCollection,
12
+ } from './contract'
13
+ import type { CollectionRow, ContentResult, Section } from './content'
14
+ import {
15
+ applyCaret,
16
+ caretTargetIn,
17
+ EDITABLE_LEAF_SELECTOR,
18
+ isEditableLeaf,
19
+ MEDIA,
20
+ MEDIA_SELECTOR,
21
+ } from './caret'
22
+ import { toRichText, type DomLike } from './lexicalDom'
23
+ import { FIELD_ATTR, isSettledInView, revealSelectors, RICH_ATTR, SECTION_ATTR } from './shape'
24
+ import { resolveUploadsIn } from './uploads'
25
+ import { createLanguageHints } from './languageHints'
26
+
27
+ /**
28
+ * How much of a quote travels to the dashboard.
29
+ *
30
+ * The words are for a chat prefill the client then edits, and a whole paragraph pasted in
31
+ * front of their prompt is worse than none of it — they would delete it before typing. The
32
+ * dashboard holds the same figure in `canvas/agentSelection.ts`; if they ever disagree the
33
+ * shorter one wins, which is the safe direction.
34
+ */
35
+ const MAX_QUOTE = 400
36
+
37
+ /** One line, no runs of whitespace — rendered markup is full of newlines and indentation. */
38
+ const collapseSpace = (value: string): string =>
39
+ value.replace(/\s+/g, ' ').trim().slice(0, MAX_QUOTE)
40
+
41
+ /**
42
+ * An element's own words, or null when it has none worth quoting.
43
+ *
44
+ * `innerText` rather than `textContent`, for the reason `caret.ts` gives about buttons: it
45
+ * respects rendering, so an `<svg>` chevron inside a link contributes nothing and the value is
46
+ * the label alone. A media field returns nothing here, which is correct — its value is an
47
+ * upload id and quoting the empty string back would claim it had no content.
48
+ */
49
+ const quotable = (element: HTMLElement): string | undefined => {
50
+ const words = collapseSpace(element.innerText ?? element.textContent ?? '')
51
+ return words || undefined
52
+ }
53
+
54
+ /**
55
+ * The site half of the UnitNinety editing protocol.
56
+ *
57
+ * When a page is framed by the dashboard's Canvas in draft mode, the dashboard pushes
58
+ * the editor's working copy in on every keystroke and this re-renders from it — so the
59
+ * client edits their actual page, with real components, CSS, fonts and video, rather
60
+ * than an approximation. Clicking any element carrying `data-u90-field` reports that
61
+ * field back, which is what makes the page directly editable.
62
+ *
63
+ * Outside a frame the hook does nothing at all, so a public visitor pays no cost.
64
+ *
65
+ * **Why this is a package rather than a file each site copies.** Almost everything below
66
+ * exists because a cross-origin iframe keeps its own input, and each fix was found the
67
+ * hard way:
68
+ *
69
+ * - ⌘/ctrl + wheel and trackpad pinch never reach the dashboard, so zoom stops working
70
+ * wherever the pointer is over the page — which is most of the stage.
71
+ * - A middle-button pan lands here, and the dashboard sees no pointer events until
72
+ * release, so the canvas appeared to stick and then jump.
73
+ * - Tool shortcuts and space-to-pan die the moment focus moves into the frame.
74
+ * - An unselected screen must not scroll, and `preventDefault` is not enough against a
75
+ * smooth-scroll library that moves the page programmatically.
76
+ *
77
+ * Copy-pasted, those fixes live in one repo and the next client silently gets the
78
+ * broken version. `BRIDGE_VERSION` is the enforcement: the dashboard refuses to render
79
+ * its editor against a version it doesn't speak, so a stale site degrades loudly.
80
+ *
81
+ * Security: every inbound message is checked against the platform's own origin, so
82
+ * another page that frames this site cannot drive it.
83
+ */
84
+
85
+ /** This site's collection rows, keyed by slug — the same shape `getCollection` returns. */
86
+ export type EntriesByCollection = Record<string, CollectionRow[]>
87
+
88
+ export type LiveContent = {
89
+ content: ContentResult
90
+ /** True when this page is inside the dashboard's editing frame. */
91
+ embedded: boolean
92
+ /**
93
+ * The collection rows, live while framed and the server-fetched ones otherwise.
94
+ *
95
+ * Keyed by slug, so a component reads `entries.projects` and gets exactly what
96
+ * `getCollection('projects')` would have returned. A collection the dashboard has not
97
+ * pushed is simply absent, which is what `?? initialProjects` already handles.
98
+ */
99
+ entries: EntriesByCollection
100
+ /**
101
+ * Whether links in this frame navigate. False while the operator is editing, which
102
+ * is the default — pass it to `EditingStyles` so the affordances agree with the
103
+ * behaviour.
104
+ */
105
+ navigation: boolean
106
+ selected: null | string
107
+ }
108
+
109
+ type MediaMap = Record<
110
+ string,
111
+ { alt?: string; focalX?: null | number; focalY?: null | number; url?: null | string }
112
+ >
113
+ type PagesMap = Record<string, { slug?: string; title?: string }>
114
+
115
+ /**
116
+ * A hydration boundary that actively owns its one transition.
117
+ *
118
+ * The server and hydrating client both render `false`; a timer then performs an ordinary React
119
+ * state transition. This used to be expressed through `useSyncExternalStore`, whose browser
120
+ * snapshot remained on its hydration value under the sandbox's Next development runtime even
121
+ * after its subscriber fired. The result was a correctly compiled page that never mounted the
122
+ * editing effect: no ready message, no click selection and no active-screen scroll ownership.
123
+ *
124
+ * Timed rather than set synchronously inside the effect, both to give cleanup a real job and to
125
+ * keep the hydration transition outside the effect body. It runs once; whether a document is
126
+ * mounted cannot change during its lifetime.
127
+ */
128
+ export const useHasMounted = (): boolean => {
129
+ const [mounted, setMounted] = useState(false)
130
+
131
+ useEffect(() => {
132
+ const timer = window.setTimeout(() => setMounted(true), 0)
133
+ return () => window.clearTimeout(timer)
134
+ }, [])
135
+
136
+ return mounted
137
+ }
138
+
139
+ /** Whether this mounted document is framed. */
140
+ export const useIsFramed = (): boolean => {
141
+ const mounted = useHasMounted()
142
+ return mounted && (window.parent !== window || window.name === CANVAS_FRAME_NAME)
143
+ }
144
+
145
+ /**
146
+ * Bring the selected thing into view inside this page.
147
+ *
148
+ * The dashboard can move the canvas to a screen, but only the page itself can scroll to
149
+ * something inside it — so a client arriving from the CMS to edit one project landed on the
150
+ * right screen looking at the top of it.
151
+ *
152
+ * **Where to look is `revealSelectors`' decision, not this function's**, and it is not the
153
+ * exact field alone. Canvas selects containers as readily as leaves — a whole section, a
154
+ * list, a link's group — while only leaves carry a marker, so exact matching found the target
155
+ * exactly when a client clicked a field and nothing the rest of the time. Clicking "Quote" in
156
+ * the rail outlined the block, because the outline resolves the section marker, and left the
157
+ * page where it was: the outline and the scroll disagreed about what a selection means.
158
+ *
159
+ * Nothing is found when the thing genuinely is not on the page — jdsi's home carousel marks
160
+ * only its active slide — and nothing then happens, which is the honest outcome.
161
+ */
162
+ const revealField = (field: string): void => {
163
+ let target: HTMLElement | null = null
164
+ for (const selector of revealSelectors(field)) {
165
+ target = document.querySelector<HTMLElement>(selector)
166
+ if (target) break
167
+ }
168
+
169
+ if (!target) return
170
+
171
+ /*
172
+ * Already on screen: leave the page exactly where it is.
173
+ *
174
+ * `u90:select` also arrives when the client clicks an element *in* this frame — the site
175
+ * reports it and the dashboard echoes it back — so scrolling unconditionally would
176
+ * re-centre the page under every click, which reads as the page fighting you.
177
+ */
178
+ if (isSettledInView(target.getBoundingClientRect(), window.innerHeight)) return
179
+
180
+ /*
181
+ * Instant, not smooth. These sites run Lenis, which owns the scroll position and animates
182
+ * it itself — a smooth native scroll competes with it and can land somewhere neither
183
+ * intended. An instant jump is one position change Lenis picks up on its next frame, and
184
+ * "take me to the thing I asked to edit" wants to arrive rather than travel.
185
+ */
186
+ target.scrollIntoView({ block: 'center', inline: 'nearest' })
187
+ }
188
+
189
+ /**
190
+ * What the client has typed, as the dashboard's text control would hold it.
191
+ *
192
+ * `innerText` rather than `textContent`, because it is the one that respects layout:
193
+ * a rich text field's paragraphs come back separated by blank lines, which is exactly
194
+ * what `textToLexical` splits on. `textContent` would run them together and three
195
+ * paragraphs would save as one.
196
+ *
197
+ * Non-breaking spaces are put back to ordinary ones. Chromium's `plaintext-only` does not
198
+ * produce them, but the `contenteditable="true"` fallback above and other engines do — for
199
+ * a trailing or repeated space — and one saved into the client's copy is a stray character
200
+ * on the published page that nothing on screen explains.
201
+ */
202
+ const readEditedText = (element: HTMLElement): string =>
203
+ element.innerText.replace(/ /g, ' ').replace(/\r\n?/g, '\n')
204
+
205
+ /*
206
+ * Formatting in the frame is `contentEditable`'s own -- ⌘B, ⌘I, and nothing else.
207
+ *
208
+ * The design said these should drive Lexical's commands, which cannot be done here: this kit
209
+ * is vendored into every client site and takes no dependencies, so `lexical` is not available
210
+ * on this side of the bridge and should not become available.
211
+ *
212
+ * That is a smaller loss than it sounds. The browser gives bold and italic for free, the
213
+ * serialiser reads both spellings of each, and everything structural -- headings, lists, links --
214
+ * is a control in the panel where the client can see what they are choosing. What is not
215
+ * acceptable is a keystroke that looks applied and does not survive a reload, which is why the
216
+ * round trip asserts `b` and `i` rather than assuming them.
217
+ */
218
+
219
+ /** A real element as the structural node `toRichText` walks. See `lexicalDom.ts`. */
220
+ const asDomLike = (node: Node): DomLike => {
221
+ if (node.nodeType === 3) return { text: node.textContent ?? '' }
222
+ const element = node as HTMLElement
223
+ const href = element.getAttribute?.('href')
224
+ const singleKey = element.getAttribute?.('data-single-key')
225
+ return {
226
+ attributes: { ...(href ? { href } : {}), ...(singleKey ? { 'data-single-key': singleKey } : {}) },
227
+ children: [...(element.childNodes ?? [])].map(asDomLike),
228
+ tag: element.tagName?.toLowerCase(),
229
+ }
230
+ }
231
+
232
+ /**
233
+ * The viewport-height units, and the ones that depend on height through a comparison.
234
+ *
235
+ * `vw` is deliberately absent: a full-length frame is exactly as wide as the device it is
236
+ * standing in for, so a width unit already measures against the right thing.
237
+ */
238
+ const VIEWPORT_UNIT = /(-?\d*\.?\d+)(svh|lvh|dvh|vh|vmin|vmax)\b/gi
239
+
240
+ /** What a locked declaration said before it was pinned, so it can be given back. */
241
+ type PinnedDeclaration = {
242
+ priority: string
243
+ property: string
244
+ rule: CSSStyleRule
245
+ value: string
246
+ }
247
+
248
+ let pinned: PinnedDeclaration[] = []
249
+
250
+ /** Put every pinned declaration back exactly as it was. */
251
+ const releaseViewport = (): void => {
252
+ for (const entry of pinned) {
253
+ try {
254
+ entry.rule.style.setProperty(entry.property, entry.value, entry.priority)
255
+ } catch {
256
+ // The stylesheet was replaced underneath us — there is nothing to restore to.
257
+ }
258
+ }
259
+ pinned = []
260
+ }
261
+
262
+ /**
263
+ * Rewrite the viewport units in one declaration to the pixels they would be at `height`.
264
+ *
265
+ * The whole value is rewritten rather than matched against a known shape, because these
266
+ * sites reach for them inside `min()` and `calc()` — jdsi's hero is
267
+ * `min-height: min(100svh, 2000px)` — and a rule that only understood a bare `100vh`
268
+ * would walk straight past the one declaration that matters.
269
+ */
270
+ const pinValue = (value: string, height: number, width: number): string =>
271
+ value.replace(VIEWPORT_UNIT, (_whole, amount: string, unit: string) => {
272
+ const lower = unit.toLowerCase()
273
+ const basis =
274
+ lower === 'vmin'
275
+ ? Math.min(width, height)
276
+ : lower === 'vmax'
277
+ ? Math.max(width, height)
278
+ : height
279
+ return `${(Number(amount) * basis) / 100}px`
280
+ })
281
+
282
+ /**
283
+ * Pin every viewport unit on the page to `height`, or release them all when it is null.
284
+ *
285
+ * The declarations are edited **in place**, in the site's own stylesheets, rather than
286
+ * overridden from a stylesheet of our own. An override has to win the cascade against a
287
+ * rule it cannot see — matching its specificity, its layer, and its position — and Tailwind
288
+ * puts these in `@layer utilities` under generated class names like
289
+ * `.min-h-\[min\(100svh\,2000px\)\]`. Editing the declaration itself sidesteps all of it:
290
+ * same rule, same specificity, same layer, so whatever won before still wins, at a value
291
+ * that no longer moves when the frame does. The originals are kept and restored on release,
292
+ * and this only ever runs inside the editor's frame.
293
+ *
294
+ * A stylesheet the browser will not open — one served from another origin, a font service —
295
+ * throws on `cssRules` and is skipped. Its `vh` keeps stretching; there is no reading it
296
+ * from here, and the alternative is failing on every page that loads a webfont.
297
+ */
298
+ const pinViewport = (height: null | number): void => {
299
+ releaseViewport()
300
+ if (height === null) return
301
+
302
+ const width = document.documentElement.clientWidth
303
+
304
+ const walk = (rules: CSSRuleList): void => {
305
+ for (const rule of Array.from(rules)) {
306
+ /*
307
+ * Declarations first, children second, and **never one instead of the other**.
308
+ *
309
+ * Since CSS nesting, a plain style rule is itself a grouping rule: it carries a
310
+ * `cssRules` list, empty in the ordinary case. Treating "has `cssRules`" as "is a
311
+ * container, so has no declarations of its own" therefore walks straight past every
312
+ * rule in the document — which is exactly what it did, silently, pinning nothing.
313
+ */
314
+ const style = (rule as CSSStyleRule).style
315
+ if (style) {
316
+ for (const property of Array.from(style)) {
317
+ const value = style.getPropertyValue(property)
318
+ if (!VIEWPORT_UNIT.test(value)) continue
319
+ // `test` on a `g` regex carries `lastIndex` between calls; left alone, every
320
+ // second declaration would be skipped.
321
+ VIEWPORT_UNIT.lastIndex = 0
322
+
323
+ const priority = style.getPropertyPriority(property)
324
+ try {
325
+ style.setProperty(property, pinValue(value, height, width), priority)
326
+ } catch {
327
+ continue
328
+ }
329
+ pinned.push({ priority, property, rule: rule as CSSStyleRule, value })
330
+ }
331
+ }
332
+
333
+ // `@media`, `@supports`, `@layer`, `@container`, and nested rules — Tailwind puts
334
+ // nearly everything inside at least one of them.
335
+ const grouped = (rule as CSSGroupingRule).cssRules
336
+ if (grouped?.length) walk(grouped)
337
+ }
338
+ }
339
+
340
+ for (const sheet of Array.from(document.styleSheets)) {
341
+ try {
342
+ walk(sheet.cssRules)
343
+ } catch {
344
+ // Cross-origin, and not readable from here.
345
+ }
346
+ }
347
+ }
348
+
349
+ const indexSections = (sections: Section[]): Record<string, number> => {
350
+ const index: Record<string, number> = {}
351
+ sections.forEach((section, position) => {
352
+ if (section.blockType && index[section.blockType] === undefined) {
353
+ index[section.blockType] = position
354
+ }
355
+ })
356
+ return index
357
+ }
358
+
359
+ /**
360
+ * The chrome from a content push, or null when the push said nothing about it.
361
+ *
362
+ * Read as untrusted, for the reason `readLayout` in `./content` gives about the server
363
+ * fetch: nothing between the two halves enforces this shape, and a platform running ahead
364
+ * of this kit could send anything under the key. Null on anything unusable, which is also
365
+ * what "not pushed at all" reads as — and the caller turns both into "keep the chrome the
366
+ * server rendered", so a malformed layout costs a client nothing.
367
+ *
368
+ * Restated here rather than imported from `./content`, which is the server module that
369
+ * holds the API key: this file is a client component, and pulling that module in to share
370
+ * nine lines would carry the fetch layer into every visitor's bundle.
371
+ */
372
+ const toLayout = (value: unknown): ContentResult['layout'] => {
373
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null
374
+ const entries = (value as { entries?: unknown }).entries
375
+ if (!Array.isArray(entries)) return null
376
+ const rows = entries as (Record<string, unknown> & { blockType: string })[]
377
+ /*
378
+ * The id is the half a site cannot work out for itself, and the half it may be handed
379
+ * nothing for. Kept when it is there — as a string, since the address it prefixes is text
380
+ * and the platform's row ids are `number | string` — and simply absent when it is not.
381
+ * Refusing the layout over a missing id would blank a visitor's header for a reason only
382
+ * the editor has.
383
+ */
384
+ const id = (value as { id?: unknown }).id
385
+ return typeof id === 'number' || typeof id === 'string'
386
+ ? { entries: rows, id: String(id) }
387
+ : { entries: rows }
388
+ }
389
+
390
+ /**
391
+ * Turn the dashboard's working copy into what the components expect.
392
+ *
393
+ * The working copy stores an upload as a bare id — that is what gets saved — and the
394
+ * maps arrive alongside it. Resolving here means the components see the same shape they
395
+ * get from a server fetch, so a just-picked image appears immediately and nothing
396
+ * downstream has to know the editor exists.
397
+ */
398
+ const toContent = (
399
+ sections: Section[],
400
+ blocks: EditorBlock[],
401
+ media: MediaMap,
402
+ pages: PagesMap,
403
+ layout: ContentResult['layout'],
404
+ ): ContentResult => {
405
+ const byId = new Map(
406
+ /*
407
+ Passed through rather than rebuilt key by key. Enumerating them here is what let the
408
+ focal point reach a published page but not the editing frame, and every field added
409
+ after it would have had the same silent gap.
410
+ */
411
+ Object.entries(media).map(([id, entry]) => [
412
+ id,
413
+ { ...entry, alt: entry.alt ?? '', url: entry.url ?? null },
414
+ ]),
415
+ )
416
+
417
+ resolveUploadsIn(sections, blocks, byId)
418
+ /*
419
+ * The layout's blocks are declared in the same manifest as the page's, so the one walk
420
+ * resolves both. It has to reach here as well as the sections: a client who picks a new
421
+ * logo has picked it in the header, and an unresolved id renders as no image at all —
422
+ * the frame would blank the very thing they were looking at.
423
+ */
424
+ if (layout) resolveUploadsIn(layout.entries, blocks, byId)
425
+
426
+ // Page links are shallow and always named `page`, so a targeted walk is enough.
427
+ const resolvePages = (value: unknown): void => {
428
+ if (Array.isArray(value)) {
429
+ value.forEach(resolvePages)
430
+ return
431
+ }
432
+ if (!value || typeof value !== 'object') return
433
+ const record = value as Record<string, unknown>
434
+ for (const [key, entry] of Object.entries(record)) {
435
+ if (key === 'page' && (typeof entry === 'number' || typeof entry === 'string')) {
436
+ const found = pages[String(entry)]
437
+ record[key] = found ? { slug: found.slug ?? '', title: found.title ?? '' } : null
438
+ continue
439
+ }
440
+ resolvePages(entry)
441
+ }
442
+ }
443
+ resolvePages(sections)
444
+ if (layout) resolvePages(layout.entries)
445
+
446
+ /*
447
+ * `index` stays the page's own. Layout entries are addressed by their position in the
448
+ * layout — `~12.0.tagline` — never by `blockType`, so indexing them here would build a
449
+ * second, conflicting answer to "where is the hero" for the one field path that has to
450
+ * mean the page.
451
+ */
452
+ /*
453
+ * `seo` and `title` stay null: the dashboard's working copy has nothing to push for them
454
+ * yet — the editing bridge does not carry SEO fields — and `null` is the same "inherit"
455
+ * answer a real fetch gives a page that has none.
456
+ */
457
+ return { index: indexSections(sections), layout, seo: null, sections, status: 'ok', title: null }
458
+ }
459
+
460
+ /**
461
+ * Resolve the uploads inside pushed rows, so a just-picked photo appears immediately.
462
+ *
463
+ * Each row is wrapped as a one-section page against its collection's own fields — the
464
+ * same trick the platform uses in `manifest/entrySchema.ts` — so the one upload walker
465
+ * in `./uploads` applies unchanged rather than growing a second traversal for rows.
466
+ *
467
+ * A collection the site didn't declare is passed through untouched: nothing can be said
468
+ * about the shape of rows there are no field definitions for, and guessing is how images
469
+ * get lost.
470
+ */
471
+ const toEntries = (
472
+ pushed: EntriesByCollection,
473
+ collections: ManifestCollection[],
474
+ media: MediaMap,
475
+ ): EntriesByCollection => {
476
+ const byId = new Map(
477
+ /*
478
+ Passed through rather than rebuilt key by key. Enumerating them here is what let the
479
+ focal point reach a published page but not the editing frame, and every field added
480
+ after it would have had the same silent gap.
481
+ */
482
+ Object.entries(media).map(([id, entry]) => [
483
+ id,
484
+ { ...entry, alt: entry.alt ?? '', url: entry.url ?? null },
485
+ ]),
486
+ )
487
+
488
+ const resolved: EntriesByCollection = {}
489
+
490
+ for (const [slug, rows] of Object.entries(pushed)) {
491
+ if (!Array.isArray(rows)) continue
492
+
493
+ const declared = collections.find((entry) => entry.slug === slug)
494
+ const blocks: EditorBlock[] = declared
495
+ ? [{ fields: declared.fields, label: declared.label, slug: '__entry' }]
496
+ : []
497
+
498
+ resolved[slug] = rows.map((row) => {
499
+ const data = row?.data && typeof row.data === 'object' ? row.data : {}
500
+ if (blocks.length === 0) return { data, id: row.id }
501
+
502
+ /*
503
+ * A copy, wrapped. `resolveUploadsIn` works in place, and the pushed object is the
504
+ * dashboard's working copy — resolving into it would replace the ids it is about to
505
+ * save with objects. The wrapper comes off so `blockType` never reaches a component.
506
+ */
507
+ const copy: Record<string, unknown> = { ...data, blockType: '__entry' }
508
+ resolveUploadsIn([copy], blocks, byId)
509
+ delete copy.blockType
510
+
511
+ return { data: copy, id: row.id }
512
+ })
513
+ }
514
+
515
+ return resolved
516
+ }
517
+
518
+ export const useLiveContent = ({
519
+ blocks,
520
+ collections = [],
521
+ initial = { index: {}, layout: null, seo: null, sections: [], status: 'ok', title: null },
522
+ initialEntries = {},
523
+ platformOrigin,
524
+ language,
525
+ }: {
526
+ /** The site's own schema, so uploads are found precisely rather than by name. */
527
+ blocks: EditorBlock[]
528
+ /**
529
+ * The site's collection declarations, from `defineCollections`.
530
+ *
531
+ * Needed for the same reason `blocks` is: to find the uploads inside a pushed row by
532
+ * what the site actually declares rather than by guessing at field names. Omitted, rows
533
+ * still arrive — their images just won't update until the next save.
534
+ */
535
+ collections?: ManifestCollection[]
536
+ /**
537
+ * The server-fetched page content. Optional: a route rendered entirely from collection
538
+ * rows — a project's own page — has no page document behind it and passes nothing.
539
+ */
540
+ initial?: ContentResult
541
+ /** The server-fetched rows, returned as `entries` until the dashboard pushes any. */
542
+ initialEntries?: EntriesByCollection
543
+ platformOrigin: string
544
+ language?: LanguageFrameContext
545
+ }): LiveContent => {
546
+ /*
547
+ * Only the dashboard's pushes are held in state; `initial` is read straight through.
548
+ * Mirroring the prop into state would need an effect to keep the two in step, and
549
+ * would make a server navigation silently lose an unsaved edit.
550
+ */
551
+ const [override, setOverride] = useState<ContentResult | null>(null)
552
+ /*
553
+ * Held apart from `override` even though both arrive in one message, because they have
554
+ * different fallbacks: a page with no sections is a real answer, while a collection the
555
+ * dashboard has not pushed must fall back to what the server fetched rather than to an
556
+ * empty list. Merged over `initialEntries` on read, below.
557
+ */
558
+ const [entriesOverride, setEntriesOverride] = useState<EntriesByCollection | null>(null)
559
+ const [selected, setSelected] = useState<null | string>(null)
560
+ const languageLocale = language?.locale
561
+ const languageRevision = language?.revision
562
+ const identity = JSON.stringify([languageLocale, languageRevision])
563
+ const [contextIdentity, setContextIdentity] = useState(identity)
564
+ // Adjust this component before React commits a new context. Old-locale overrides
565
+ // never reach its first paint, and no effect resets a newly received content push.
566
+ if (contextIdentity !== identity) {
567
+ setContextIdentity(identity)
568
+ setOverride(null)
569
+ setEntriesOverride(null)
570
+ setSelected(null)
571
+ }
572
+
573
+ /**
574
+ * The field this frame last reported as clicked, so its own echo does not scroll the page.
575
+ *
576
+ * A ref rather than state: it is read once by the message that comes straight back and never
577
+ * rendered, and putting it in state would re-render the page mid-click for no one's benefit.
578
+ */
579
+ const selfSelected = useRef<null | string>(null)
580
+ /*
581
+ * Links are inert until the operator says otherwise.
582
+ *
583
+ * Held twice, and both are needed. The click listener is registered once and would
584
+ * capture the initial value forever, so it reads the ref — the same problem
585
+ * `isActive` solves with a closure variable. But this one also drives the hover
586
+ * affordances, so it has to be state as well. Both are written together in the
587
+ * message handler, never during render.
588
+ */
589
+ const [navigation, setNavigation] = useState(false)
590
+ const navigationRef = useRef(false)
591
+ const embedded = useIsFramed()
592
+
593
+ /*
594
+ * The pushed working copy over the server-fetched page — but the chrome falls back the
595
+ * way a collection the dashboard never pushed falls back to its fetched rows.
596
+ *
597
+ * A push carrying no layout means "this push says nothing about the chrome": a platform
598
+ * older than bridge 3, or a page whose layout resolved to nothing. Letting the override's
599
+ * `null` win there would blank a header the server had already rendered, the moment the
600
+ * client touched anything — the one case where the live copy knows *less* than the page
601
+ * it is replacing. Nothing changes outside the editor, where there is no override at all
602
+ * and `initial` is read through untouched.
603
+ */
604
+ const content = useMemo(
605
+ () => (override ? { ...override, layout: override.layout ?? initial.layout } : initial),
606
+ [initial, override],
607
+ )
608
+
609
+ /*
610
+ * Pushed rows over the server-fetched ones, per collection.
611
+ *
612
+ * Merged rather than replaced: a frame showing a project's own page is pushed every
613
+ * collection the tenant has, but a site that only fetched `projects` server-side should
614
+ * not suddenly gain the others — and, more importantly, a collection the dashboard has
615
+ * *not* pushed must keep the rows this page rendered with rather than becoming empty.
616
+ */
617
+ const entries = useMemo(
618
+ () => (entriesOverride ? { ...initialEntries, ...entriesOverride } : initialEntries),
619
+ [entriesOverride, initialEntries],
620
+ )
621
+
622
+ const send = useCallback(
623
+ (message: Record<string, unknown>) => {
624
+ if (!platformOrigin || window.parent === window) return
625
+ window.parent.postMessage(
626
+ {
627
+ ...message,
628
+ version: BRIDGE_VERSION,
629
+ ...(languageLocale
630
+ ? { languageVersion: 1, locale: languageLocale, languageRevision }
631
+ : {}),
632
+ },
633
+ platformOrigin,
634
+ )
635
+ },
636
+ [platformOrigin, languageLocale, languageRevision],
637
+ )
638
+
639
+ useEffect(() => {
640
+ if (!platformOrigin || !embedded) return
641
+
642
+ const languageHints = createLanguageHints()
643
+
644
+ /*
645
+ * Whether this frame is the active screen.
646
+ *
647
+ * An active screen owns its scrolling — the wheel scrolls the page, as it would in a
648
+ * tab. Otherwise the wheel is handed back so it pans the canvas: the pointer is
649
+ * nearly always over some screen, so without this a stage of screens couldn't be
650
+ * moved around at all.
651
+ *
652
+ * A closure rather than React state: the wheel listener has to read the current
653
+ * value, and re-registering a non-passive listener on every change is needless churn.
654
+ */
655
+ let isActive = false
656
+
657
+ /*
658
+ * What this frame's viewport units are currently pinned to, or null while they measure
659
+ * against the frame the way a browser would. Held so a repeated instruction — the
660
+ * dashboard re-sends this whenever the frame reloads — does not tear the pins down and
661
+ * rebuild them for no change.
662
+ */
663
+ let pinnedHeight: null | number = null
664
+
665
+ /*
666
+ * ── Editing in place ──────────────────────────────────────────────────────
667
+ *
668
+ * Double-clicking a leaf text field makes that very element contentEditable, so the
669
+ * client types into their own heading in their own typeface rather than into a box on
670
+ * the right that happens to drive it.
671
+ *
672
+ * Closure variables rather than state, for the same reason `isActive` is: every one of
673
+ * them is read by listeners registered once, and none of them should re-render the page
674
+ * — the DOM the client is typing into is already showing the truth.
675
+ */
676
+ let editing: HTMLElement | null = null
677
+ /**
678
+ * The last edit typed but not yet sent, so a blur can never lose the final keystrokes.
679
+ *
680
+ * A discriminated union rather than one shape with both fields optional, so a rich edit
681
+ * carrying a plain string — or a plain edit carrying an element — cannot be constructed.
682
+ *
683
+ * The rich branch carries the **element**, not `toRichText`'s result. Serialising is a
684
+ * full recursive walk of the field's DOM plus a fresh tree allocation, and `onInput` runs
685
+ * once per keystroke; storing the element and calling `toRichText` only from `flushEdit`
686
+ * means a whole document is walked once per *flush*, not once per character typed into
687
+ * it. This is safe to defer: `heldContent` holds back any content push for as long as
688
+ * `editing` is set, so nothing else touches this element's children before the flush
689
+ * runs, and even an element removed from the document in between keeps its `childNodes`.
690
+ */
691
+ let unsent:
692
+ | null
693
+ | { element: HTMLElement; field: string; rich: true }
694
+ | { field: string; rich: false; value: string } = null
695
+ let editTimer = 0
696
+
697
+ /**
698
+ * A content push held back while the client is typing.
699
+ *
700
+ * Applying it would re-render the very element the caret is in. React would assign the
701
+ * text node — with the same string it already holds, since the dashboard is echoing back
702
+ * what was just typed — and the browser collapses the selection when it does, so the
703
+ * caret jumps to the start of the field on every keystroke.
704
+ *
705
+ * Held instead, and applied when editing ends. Only the frame being typed in holds back —
706
+ * every other screen on the stage updates live, which is the point of having them.
707
+ *
708
+ * Nothing is lost *while this element is where the copy came from*, which is the premise
709
+ * and the whole of the limit: a client editing the same field from the inspector panel is
710
+ * a second author for it, and against that one the hold is simply a stale frame. So the
711
+ * content handler lets go of the field when this document has no focus — see there.
712
+ */
713
+ let heldContent: null | Record<string, unknown> = null
714
+
715
+ /** Stop an event reaching anything else, including the page's own scroller. */
716
+ const stopScrolling = (event: Event) => {
717
+ event.preventDefault()
718
+ event.stopPropagation()
719
+ event.stopImmediatePropagation()
720
+ }
721
+
722
+ const applyContent = (data: Record<string, unknown>) => {
723
+ const media = (data.media as MediaMap | undefined) ?? {}
724
+
725
+ setOverride(
726
+ toContent(
727
+ data.sections as Section[],
728
+ blocks,
729
+ media,
730
+ (data.pages as PagesMap | undefined) ?? {},
731
+ toLayout(data.layout),
732
+ ),
733
+ )
734
+
735
+ /*
736
+ * Rows, when the dashboard sends any. Guarded on presence rather than assumed:
737
+ * `entries` arrived with bridge version 2, and a site on this kit talking to an
738
+ * older platform would otherwise wipe its own rows on the first push.
739
+ */
740
+ const pushed = data.entries
741
+ if (pushed && typeof pushed === 'object' && !Array.isArray(pushed)) {
742
+ setEntriesOverride(toEntries(pushed as EntriesByCollection, collections, media))
743
+ }
744
+ }
745
+
746
+ const flushEdit = () => {
747
+ window.clearTimeout(editTimer)
748
+ if (!unsent) return false
749
+ if (unsent.rich) {
750
+ send({
751
+ field: unsent.field,
752
+ type: 'u90:edit-rich',
753
+ value: toRichText(asDomLike(unsent.element)),
754
+ })
755
+ } else {
756
+ send({ field: unsent.field, type: 'u90:edit', value: unsent.value })
757
+ }
758
+ unsent = null
759
+ return true
760
+ }
761
+
762
+ /**
763
+ * Hand the element back to React.
764
+ *
765
+ * The last edit goes out first, so releasing the field can't drop what was typed into
766
+ * it. If something did go out, the held content is dropped rather than applied: it
767
+ * predates that keystroke, and the push answering it is already on its way — applying
768
+ * the older one would flash the previous words back for a frame.
769
+ */
770
+ const endEditing = () => {
771
+ const element = editing
772
+ if (!element) return
773
+ editing = null
774
+ clearCaret()
775
+
776
+ const flushed = flushEdit()
777
+
778
+ element.removeAttribute('contenteditable')
779
+ element.removeAttribute('data-u90-editing')
780
+
781
+ if (heldContent && !flushed) applyContent(heldContent)
782
+ heldContent = null
783
+ }
784
+
785
+ const beginEditing = (element: HTMLElement) => {
786
+ if (editing === element) return
787
+ endEditing()
788
+ editing = element
789
+
790
+ /*
791
+ * The two field kinds want opposite things here, and that asymmetry is deliberate,
792
+ * not a gap.
793
+ *
794
+ * A plain field wants the browser to refuse markup outright: `plaintext-only` is what
795
+ * stops a paste bringing the source page's own markup into a field that has nowhere to
796
+ * store it, and what stops the client bolding half a heading with no way to keep that.
797
+ * Firefox only learned `plaintext-only` recently; where it isn't understood the
798
+ * attribute leaves `isContentEditable` false, so plain `true` takes over there too.
799
+ *
800
+ * A rich field wants the opposite: its whole point is the browser's own formatting
801
+ * commands, ⌘B and ⌘I (see the note above `asDomLike`). `plaintext-only` disables
802
+ * exactly those commands on Chromium and WebKit, which would make a rich field's
803
+ * formatting inert the moment it became editable — so a rich field is always `true`,
804
+ * never `plaintext-only`, and a plain field is never flipped the other way: that
805
+ * paste protection must not change.
806
+ */
807
+ if (element.hasAttribute(RICH_ATTR)) {
808
+ element.setAttribute('contenteditable', 'true')
809
+ } else {
810
+ element.setAttribute('contenteditable', 'plaintext-only')
811
+ if (!element.isContentEditable) element.setAttribute('contenteditable', 'true')
812
+ }
813
+ element.setAttribute('data-u90-editing', '')
814
+
815
+ /*
816
+ * `preventScroll`, because the element is under the pointer by definition — the client
817
+ * just clicked it — so there is nothing to scroll to, and the default would scroll
818
+ * anyway. These sites run Lenis, which owns the scroll position: a native scroll into
819
+ * view fights it and can land somewhere neither chose. Harmless when editing needed a
820
+ * double-click and the element was already centred by the earlier select; now that one
821
+ * click starts it, this fires on every click into text.
822
+ */
823
+ element.focus({ preventScroll: true })
824
+ }
825
+
826
+ const onMessage = (event: MessageEvent) => {
827
+ if (event.origin !== platformOrigin || event.source !== window.parent) return
828
+ const data = event.data as null | Record<string, unknown>
829
+ if (!data || typeof data.type !== 'string') return
830
+ if (!acceptsLanguageFrame(language, data)) return
831
+
832
+ if (
833
+ data.type === 'u90:flush' &&
834
+ data.version === BRIDGE_VERSION &&
835
+ typeof data.requestId === 'string' &&
836
+ data.requestId.length > 0 &&
837
+ data.requestId.length < 100
838
+ ) {
839
+ // postMessage ordering guarantees the parent stages this edit before observing the ack.
840
+ endEditing()
841
+ flushEdit()
842
+ send({ type: 'u90:flushed', requestId: data.requestId })
843
+ return
844
+ }
845
+
846
+ if (data.type === 'u90:content' && Array.isArray(data.sections)) {
847
+ languageHints.update(data.languageHints)
848
+ /*
849
+ * A push arriving while this document has no focus is the dashboard's panel typing,
850
+ * not this frame's — so let go of the field rather than hold the push back.
851
+ *
852
+ * The hold below exists to protect a caret in *this* frame (see `heldContent`). Its
853
+ * premise is that the element is where the copy came from, which stops being true the
854
+ * moment the client edits the same field from the inspector: every keystroke there
855
+ * was then queued behind an element nobody was typing in, and the frame only caught
856
+ * up when they clicked back into it and `endEditing` flushed. That is the whole of the
857
+ * "it only updates when I click out of the box" report.
858
+ *
859
+ * `endEditing` rather than merely skipping the hold: it flushes anything typed here
860
+ * first, so nothing is lost, and it clears `editing` so the element React is about to
861
+ * replace is not left behind as a detached node this branch keeps consulting.
862
+ *
863
+ * **Asked here and not in `onFocusOut`, which is the important part.** That handler
864
+ * deliberately ignores a focus loss with no `relatedTarget`, and the note there
865
+ * records why `document.hasFocus()` cannot help it: `focusout` fires *while* focus is
866
+ * leaving, and the document can still claim to have it. A message handler runs long
867
+ * after that transition has settled, so the same question has a reliable answer here.
868
+ *
869
+ * The formatting toolbar is unaffected. It also takes focus out of this frame, but it
870
+ * speaks through `u90:format`, and a `u90:content` push means the dashboard's own copy
871
+ * changed — which a press on Heading 1 does not do until the format has already been
872
+ * applied and echoed back.
873
+ */
874
+ if (editing && !document.hasFocus()) endEditing()
875
+ if (editing) heldContent = data
876
+ else applyContent(data)
877
+ }
878
+
879
+ if (data.type === 'u90:format') {
880
+ applyFormat(data.action, typeof data.url === 'string' ? data.url : undefined)
881
+ }
882
+
883
+ if (data.type === 'u90:select') {
884
+ const field = typeof data.field === 'string' ? data.field : null
885
+ setSelected(field)
886
+
887
+ /*
888
+ * A cleared selection is the one authority that can end an edit from outside.
889
+ *
890
+ * `onFocusOut` deliberately holds the edit open when focus leaves this document,
891
+ * because clicking the formatting toolbar in the parent window looks exactly like
892
+ * clicking away — both report no `relatedTarget`. From in here the two are the same
893
+ * event, so the toolbar's needs win and the field stays live.
894
+ *
895
+ * Clicking the empty canvas is the case that left behind: the frame is told the
896
+ * screen was let go, and until this ran the element kept `contenteditable` and its
897
+ * outline, still taking keystrokes for a screen the dashboard had already dropped.
898
+ * The parent is the only side that can tell the two apart, and `field: null` is it
899
+ * saying so.
900
+ *
901
+ * `endEditing` flushes first, so nothing typed before the click is lost.
902
+ */
903
+ if (field === null) endEditing()
904
+
905
+ /*
906
+ * One frame later, deliberately. `u90:select` and `u90:content` are separate
907
+ * messages with no ordering guarantee, so a selection arriving with fresh content
908
+ * may name an element React has not laid out yet — measuring it in this tick would
909
+ * read a stale rect, or nothing at all.
910
+ */
911
+ /*
912
+ * Only travel for a selection made somewhere else — the inspector's rail, a deep link.
913
+ * The echo of a click the client just made must not move the page under their cursor;
914
+ * see the note where `selfSelected` is set.
915
+ */
916
+ const echo = field !== null && field === selfSelected.current
917
+ selfSelected.current = null
918
+ if (field && !echo) requestAnimationFrame(() => revealField(field))
919
+ }
920
+
921
+ /*
922
+ * Back to the top of this page.
923
+ *
924
+ * Instant, not smooth, for `revealField`'s reason: these sites run Lenis, which owns
925
+ * the scroll position and animates it itself, and a smooth native scroll competes with
926
+ * it and can land somewhere neither intended. `scrollTo` with no behaviour is one
927
+ * position change Lenis picks up on its next frame.
928
+ *
929
+ * Unconditional, unlike `revealField`'s settled check. There is no target to be
930
+ * already-looking-at here: the client asked for the top, and a page already at the top
931
+ * is unmoved by being sent there.
932
+ */
933
+ if (data.type === 'u90:scroll' && data.to === 'top') {
934
+ window.scrollTo({ top: 0 })
935
+ }
936
+
937
+ /*
938
+ * Synced scrolling: sit at the same proportion of this page as the screen the client is
939
+ * scrolling. The dashboard has already clamped the fraction, and re-reads it here anyway
940
+ * — it arrives from another window, and a NaN would resolve to the top and make every
941
+ * other screen jump while the one being scrolled stayed put.
942
+ *
943
+ * `scrollTo` with no behaviour for the same reason the top handler uses it: one position
944
+ * change that a smooth-scroll library picks up on its next frame, rather than an animation
945
+ * it fights. Smooth here would be worse than useless anyway — the positions arrive as fast
946
+ * as the client scrolls, and each one would cancel the last.
947
+ */
948
+ if (data.type === 'u90:scroll' && data.to === 'fraction') {
949
+ const fraction = typeof data.fraction === 'number' ? data.fraction : NaN
950
+ if (Number.isFinite(fraction)) {
951
+ const max = document.documentElement.scrollHeight - window.innerHeight
952
+ window.scrollTo({ top: max > 0 ? Math.min(1, Math.max(0, fraction)) * max : 0 })
953
+ }
954
+ }
955
+
956
+ if (data.type === 'u90:active') {
957
+ isActive = data.active === true
958
+ }
959
+
960
+ /*
961
+ * What a viewport unit measures against, while this frame is drawn full length.
962
+ *
963
+ * Applied before the height is re-reported, and that order is the point: pinning
964
+ * changes how tall the document is, so the dashboard has to be told the pinned
965
+ * height, not the one from a moment ago. `reportHeight` filters an unchanged
966
+ * number, so when nothing moved this costs a measurement and no message.
967
+ */
968
+ if (data.type === 'u90:viewport') {
969
+ const height = typeof data.height === 'number' && data.height > 0 ? data.height : null
970
+ if (height !== pinnedHeight) {
971
+ pinnedHeight = height
972
+ pinViewport(height)
973
+ reportHeight()
974
+ }
975
+ }
976
+
977
+ /*
978
+ * Whether links in this frame behave like links.
979
+ *
980
+ * Off by default while embedded, because inside the editor a click means "edit
981
+ * this", and the alternative is unusable: a button's marker sits on its label,
982
+ * so clicking the padding around the words used to navigate, and a card whose
983
+ * whole surface is a stretched anchor could not be selected at all.
984
+ *
985
+ * The operator turns it on to walk to another page and edit that. Kept in state
986
+ * rather than a closure — unlike `isActive` — because the affordance styles
987
+ * change with it, so a render is exactly what should happen.
988
+ */
989
+ if (data.type === 'u90:navigation') {
990
+ const enabled = data.enabled === true
991
+ navigationRef.current = enabled
992
+ setNavigation(enabled)
993
+ }
994
+ }
995
+
996
+ window.addEventListener('message', onMessage)
997
+
998
+ // Announce readiness, then report which fields this page actually renders, so the
999
+ // dashboard can tell modelled fields from ones that reach the page.
1000
+ send({ type: 'u90:ready' })
1001
+
1002
+ /*
1003
+ * Tell the dashboard which route this frame is showing, and keep telling it.
1004
+ *
1005
+ * A client browsing their own site inside a screen — clicking "Who We Are" in the
1006
+ * real nav — leaves the dashboard's page readout showing whatever the screen was
1007
+ * opened on, because the dashboard cannot see inside a cross-origin frame and
1008
+ * nothing reported the change.
1009
+ *
1010
+ * `popstate` covers the back button. A client-side push does not fire an event of
1011
+ * its own, so `pushState` and `replaceState` are wrapped — the alternative is
1012
+ * polling `location.pathname`, which spends a timer forever to catch something
1013
+ * that happens a few times a session.
1014
+ *
1015
+ * Both originals are restored on cleanup, so a site that navigates after the
1016
+ * editor closes is left exactly as it was.
1017
+ */
1018
+ const reportPath = () => send({ path: window.location.pathname, type: 'u90:path' })
1019
+
1020
+ reportPath()
1021
+
1022
+ const { pushState, replaceState } = window.history
1023
+
1024
+ window.history.pushState = function patchedPushState(...args) {
1025
+ pushState.apply(this, args)
1026
+ reportPath()
1027
+ }
1028
+ window.history.replaceState = function patchedReplaceState(...args) {
1029
+ replaceState.apply(this, args)
1030
+ reportPath()
1031
+ }
1032
+
1033
+ window.addEventListener('popstate', reportPath)
1034
+
1035
+ const raf = requestAnimationFrame(() => {
1036
+ const fields = [...document.querySelectorAll<HTMLElement>(`[${FIELD_ATTR}]`)]
1037
+ .map((element) => element.getAttribute(FIELD_ATTR))
1038
+ .filter((value): value is string => Boolean(value))
1039
+ send({ fields: [...new Set(fields)], type: 'u90:fields' })
1040
+ })
1041
+
1042
+ /*
1043
+ * How tall this page is, so a screen can be shown as one whole strip.
1044
+ *
1045
+ * The dashboard cannot measure it — the frame is cross-origin — so a screen set to
1046
+ * "whole page" is drawn at whatever this last reported. It has to keep reporting:
1047
+ * images settle, fonts swap, and every content push from the editor can change the
1048
+ * height as the client types.
1049
+ *
1050
+ * `scrollHeight` of the root element rather than `body`, because a body with collapsed
1051
+ * margins or a fixed-position layout reports its own box and not the document's.
1052
+ *
1053
+ * Reported only when it has actually moved by more than a pixel. A `ResizeObserver`
1054
+ * fires on sub-pixel reflows, and each report crosses an origin boundary and resizes
1055
+ * an element on the stage — so an unfiltered stream would have the artboard shivering
1056
+ * for the whole of an animation.
1057
+ */
1058
+ let lastHeight = 0
1059
+ const reportHeight = () => {
1060
+ const height = document.documentElement.scrollHeight
1061
+ if (Math.abs(height - lastHeight) < 1) return
1062
+ lastHeight = height
1063
+ send({ height, type: 'u90:height' })
1064
+ }
1065
+
1066
+ reportHeight()
1067
+ const heights = new ResizeObserver(reportHeight)
1068
+ heights.observe(document.documentElement)
1069
+
1070
+ /*
1071
+ * Wheel: capture phase on `window`, and the event is stopped outright rather than
1072
+ * merely default-prevented.
1073
+ *
1074
+ * A smooth-scroll library listens for the wheel itself and then moves the page
1075
+ * *programmatically*, so `preventDefault` doesn't touch it and an inactive screen
1076
+ * kept scrolling. Those libraries bind non-capture, so a capture-phase listener
1077
+ * always runs first and `stopImmediatePropagation` means they never see it.
1078
+ */
1079
+ const onWheel = (event: WheelEvent) => {
1080
+ if (event.ctrlKey || event.metaKey) {
1081
+ event.preventDefault()
1082
+ send({ deltaY: event.deltaY, type: 'u90:zoom', x: event.clientX, y: event.clientY })
1083
+ return
1084
+ }
1085
+ if (isActive) return
1086
+ stopScrolling(event)
1087
+ send({ deltaX: event.deltaX, deltaY: event.deltaY, type: 'u90:wheel' })
1088
+ }
1089
+
1090
+ window.addEventListener('wheel', onWheel, { capture: true, passive: false })
1091
+
1092
+ /*
1093
+ * Where this page is scrolled, for the dashboard to fan out to the other screens showing
1094
+ * it. See `u90:scrolled` in the platform's `bridge.ts`.
1095
+ *
1096
+ * **Only while this frame is the active screen.** That is the whole feedback guard: a
1097
+ * frame the relay has just scrolled is not the active one, so it says nothing back, and
1098
+ * the loop that would otherwise run round the stage never starts. Cheaper than any
1099
+ * echo-suppression scheme, and it cannot drift out of step with itself.
1100
+ *
1101
+ * Coalesced to one report per frame. `scroll` fires far faster than paint on a trackpad,
1102
+ * and every one of these crosses a window boundary and moves other documents; sending
1103
+ * them all would spend the client's frame budget on positions already superseded.
1104
+ *
1105
+ * Passive, because this never wants to block the scroll it is watching.
1106
+ */
1107
+ let scrollPending = 0
1108
+ const reportScroll = () => {
1109
+ if (!isActive || scrollPending) return
1110
+ scrollPending = requestAnimationFrame(() => {
1111
+ scrollPending = 0
1112
+ if (!isActive) return
1113
+ send({
1114
+ max: document.documentElement.scrollHeight - window.innerHeight,
1115
+ top: window.scrollY,
1116
+ type: 'u90:scrolled',
1117
+ })
1118
+ })
1119
+ }
1120
+
1121
+ window.addEventListener('scroll', reportScroll, { passive: true })
1122
+
1123
+ /*
1124
+ * A middle-button pan, streamed until release.
1125
+ *
1126
+ * The press lands here, so the dashboard receives no pointer events until it ends —
1127
+ * the canvas would stick and then jump into place. Capturing the pointer and
1128
+ * streaming fixes it at the source.
1129
+ *
1130
+ * Screen coordinates, not client: panning moves this very frame under the pointer,
1131
+ * so a client coordinate would stop changing and the pan would stall after one step.
1132
+ *
1133
+ * Only the middle button. A left press stays with the page — it is how the client
1134
+ * selects what to edit.
1135
+ */
1136
+ let panning: null | number = null
1137
+
1138
+ const onPointerDown = (event: PointerEvent) => {
1139
+ /*
1140
+ * A press anywhere else releases the field being edited.
1141
+ *
1142
+ * `focusout` alone is not enough: pressing on ordinary page copy moves focus to
1143
+ * nothing in particular, and in that case some browsers leave the editable focused —
1144
+ * so the client clicks away, sees no caret, and the element is still editable.
1145
+ */
1146
+ if (editing && !editing.contains(event.target as Node)) endEditing()
1147
+
1148
+ /*
1149
+ * A left press makes this the active screen.
1150
+ *
1151
+ * The dashboard cannot see it any other way — this is a cross-origin frame, so
1152
+ * it consumes its own clicks and the canvas never learns one happened. Without
1153
+ * this message a screen can never become active: no selection outline, and
1154
+ * `isActive` below stays false forever, so the wheel is handed back to the
1155
+ * canvas and the page inside never scrolls.
1156
+ *
1157
+ * Left button only, and the press is not consumed — selecting the element under
1158
+ * the pointer is the whole point of framing the real site.
1159
+ */
1160
+ if (event.button === 0) send({ type: 'u90:focus' })
1161
+
1162
+ if (event.button !== 1 || panning !== null) return
1163
+ event.preventDefault()
1164
+ panning = event.pointerId
1165
+ try {
1166
+ document.documentElement.setPointerCapture(event.pointerId)
1167
+ } catch {
1168
+ // Capture is a nicety; the window listeners still see the moves.
1169
+ }
1170
+ send({ screenX: event.screenX, screenY: event.screenY, type: 'u90:grab' })
1171
+ }
1172
+
1173
+ const onPointerMove = (event: PointerEvent) => {
1174
+ if (event.pointerId !== panning) return
1175
+ send({ screenX: event.screenX, screenY: event.screenY, type: 'u90:drag' })
1176
+ }
1177
+
1178
+ const onPointerUp = (event: PointerEvent) => {
1179
+ if (event.pointerId !== panning) return
1180
+ panning = null
1181
+ try {
1182
+ document.documentElement.releasePointerCapture(event.pointerId)
1183
+ } catch {
1184
+ // Already released.
1185
+ }
1186
+ send({ type: 'u90:drop' })
1187
+ }
1188
+
1189
+ // Chrome fires this on middle-button release; without it the frame can still open a
1190
+ // link in a new tab as the pan ends.
1191
+ const onAuxClick = (event: MouseEvent) => {
1192
+ if (event.button === 1) event.preventDefault()
1193
+ }
1194
+
1195
+ /*
1196
+ * The canvas's own shortcuts. Clicking into this frame moves keyboard focus here, so
1197
+ * the dashboard stops receiving key events — its tool shortcuts and space-to-pan
1198
+ * would work only until the client touched their own page.
1199
+ *
1200
+ * `KeyN` — turn the frame's links on and off — is the clearest case for why this list
1201
+ * exists at all, and was missing from it: you decide you want to follow a link while
1202
+ * you are looking at the page, having just clicked something on it, which is precisely
1203
+ * when this frame owns the keyboard and the dashboard hears nothing.
1204
+ *
1205
+ * `KeyT` — send this page back to the top — is the same shape. `Home` already does it and
1206
+ * only from in here; T does it from either side. Safe as a plain letter for the reason
1207
+ * every key in this set is: the `isTyping` guard below silences all of them while the
1208
+ * client is typing, and in-frame editing is a `contenteditable`, which is exactly what
1209
+ * that guard reads.
1210
+ */
1211
+ const CANVAS_KEYS = new Set(['KeyF', 'KeyG', 'KeyH', 'KeyN', 'KeyT', 'KeyV', 'Space'])
1212
+ const SCROLL_KEYS = new Set([
1213
+ 'ArrowDown',
1214
+ 'ArrowLeft',
1215
+ 'ArrowRight',
1216
+ 'ArrowUp',
1217
+ 'End',
1218
+ 'Home',
1219
+ 'PageDown',
1220
+ 'PageUp',
1221
+ ])
1222
+
1223
+ const isTyping = (target: EventTarget | null): boolean => {
1224
+ const element = target as HTMLElement | null
1225
+ const tag = element?.tagName
1226
+ return (
1227
+ tag === 'INPUT' ||
1228
+ tag === 'TEXTAREA' ||
1229
+ tag === 'SELECT' ||
1230
+ Boolean(element?.isContentEditable)
1231
+ )
1232
+ }
1233
+
1234
+ const onKey = (event: KeyboardEvent) => {
1235
+ /*
1236
+ * Escape leaves the field. Ahead of the `isTyping` guard on purpose — that guard is
1237
+ * what stops the canvas shortcuts firing while the client types, and an editable
1238
+ * element is precisely the case it silences, so the way out has to be read first.
1239
+ */
1240
+ if (event.type === 'keydown' && event.key === 'Escape' && editing) {
1241
+ event.preventDefault()
1242
+ endEditing()
1243
+ return
1244
+ }
1245
+
1246
+ if (event.metaKey || event.ctrlKey || event.altKey || isTyping(event.target)) return
1247
+
1248
+ if (CANVAS_KEYS.has(event.code)) {
1249
+ // Space would otherwise scroll this page while the client is panning.
1250
+ if (event.code === 'Space') event.preventDefault()
1251
+ send({
1252
+ code: event.code as 'KeyF' | 'KeyH' | 'KeyN' | 'KeyT' | 'KeyV' | 'Space',
1253
+ down: event.type === 'keydown',
1254
+ type: 'u90:key',
1255
+ })
1256
+ return
1257
+ }
1258
+
1259
+ // The keys that scroll, blocked while this screen isn't the active one.
1260
+ if (event.type === 'keydown' && !isActive && SCROLL_KEYS.has(event.key)) {
1261
+ stopScrolling(event)
1262
+ }
1263
+ }
1264
+
1265
+ /*
1266
+ * Any click on an annotated element selects it. Capture phase, so a link or button
1267
+ * wrapping the text can't swallow it first — inside the editor, clicking is for
1268
+ * selecting, not navigating.
1269
+ *
1270
+ * With navigation off, a click on *any* link or button is swallowed too, even
1271
+ * where nothing is annotated. That covers the two cases the marker alone misses:
1272
+ * a button whose marker is on its inner label, so the padding around the words
1273
+ * was a live link; and a card whose entire surface is a stretched anchor, which
1274
+ * carried no marker at all and could only ever navigate. If such a click lands
1275
+ * inside something annotated, that gets selected; if not, the click simply does
1276
+ * nothing, which is the honest outcome for a link with no editable content.
1277
+ */
1278
+ const onClick = (event: MouseEvent) => {
1279
+ const from = event.target as HTMLElement | null
1280
+ if (navigationRef.current) {
1281
+ const link = from?.closest?.('a[href]') as HTMLAnchorElement | null
1282
+ const href =
1283
+ link && !link.hasAttribute('download') && (!link.target || link.target === '_self')
1284
+ ? languageFrameNavigation(link.href, window.location.href, language)
1285
+ : null
1286
+ if (href && !event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey) {
1287
+ endEditing()
1288
+ event.preventDefault()
1289
+ event.stopPropagation()
1290
+ const target = new URL(href)
1291
+ send({
1292
+ type: 'u90:navigate',
1293
+ path: target.pathname + target.search + target.hash,
1294
+ targetLocale: link?.hreflang || languageLocale,
1295
+ })
1296
+ return
1297
+ }
1298
+ }
1299
+
1300
+ const target = from?.closest?.(`[${FIELD_ATTR}]`)
1301
+
1302
+ /*
1303
+ * A drag that left words highlighted inside the field being edited.
1304
+ *
1305
+ * The marker is on the prose, and the box a client sees around it is wider — a
1306
+ * container's padding, a section's vertical band. Dragging past the end of a line, which
1307
+ * is simply how you select to the end of a sentence, releases the pointer in that gutter.
1308
+ * Without this the release reads as a click on nothing, the field is deselected, and the
1309
+ * formatting toolbar disappears in the middle of the gesture that was about to use it.
1310
+ */
1311
+ const isHighlighting = (): boolean => {
1312
+ if (!editing) return false
1313
+ const selection = document.getSelection()
1314
+ if (!selection || selection.isCollapsed || selection.rangeCount === 0) return false
1315
+ return editing.contains(selection.getRangeAt(0).commonAncestorContainer)
1316
+ }
1317
+
1318
+ if (!target) {
1319
+ // Words are highlighted in the field: this is the end of a selection, not a click off it.
1320
+ if (isHighlighting()) return
1321
+
1322
+ /*
1323
+ * Clicking off an element is the other half of clicking one.
1324
+ *
1325
+ * This used to return here and say nothing, which meant a selection could only ever be
1326
+ * replaced, never let go of: click a heading, click the paragraph gap beside it, and the
1327
+ * heading stayed outlined, stayed in the inspector, and stayed the thing ⌘L quoted.
1328
+ * Escape and V both cleared it, and neither is a gesture anybody arrives with — every
1329
+ * editor a client has used deselects when you click the background, so the absence read
1330
+ * as the canvas being stuck rather than as a shortcut they had not found.
1331
+ *
1332
+ * Before the branches below, deliberately: all three of them are "the press landed on
1333
+ * nothing editable", and the one that swallows a link is no less a deselect than the two
1334
+ * that let the click through. Sent even when nothing was selected — `setSelected` bails
1335
+ * on an unchanged value, and the dashboard's store compares before it publishes, so the
1336
+ * quiet case costs one postMessage and re-renders nothing.
1337
+ */
1338
+ setSelected(null)
1339
+ send({ field: null, type: 'u90:select' })
1340
+
1341
+ if (navigationRef.current) return
1342
+ // `[href]` rather than `a`, so a bare anchor used as a scroll target is left
1343
+ // alone — it navigates nowhere and swallowing it would be pointless.
1344
+ if (!from?.closest?.('a[href], button')) return
1345
+ event.preventDefault()
1346
+ event.stopPropagation()
1347
+ return
1348
+ }
1349
+
1350
+ event.preventDefault()
1351
+ event.stopPropagation()
1352
+ const selectedField = target.getAttribute(FIELD_ATTR)
1353
+ if (selectedField) {
1354
+ setSelected(selectedField)
1355
+ /*
1356
+ * The rendered words travel with the field, so the dashboard's ⌘L can quote what is
1357
+ * on the page rather than only naming the field. Additive and optional — an older
1358
+ * dashboard ignores a key it does not read.
1359
+ */
1360
+ /*
1361
+ * Remembered so the echo of this very click cannot move the page.
1362
+ *
1363
+ * The dashboard stores the selection and sends `u90:select` straight back, and the
1364
+ * handler for it reveals the field. For anything shorter than the screen
1365
+ * `isSettledInView` absorbs that — but a field taller than the viewport is never
1366
+ * "in view" by that test, so a legal document scrolled to its own middle every time
1367
+ * the client clicked the top of it.
1368
+ */
1369
+ selfSelected.current = selectedField
1370
+ send({
1371
+ field: selectedField,
1372
+ text: quotable(target as HTMLElement),
1373
+ type: 'u90:select',
1374
+ })
1375
+ }
1376
+
1377
+ /*
1378
+ * One click into words is a caret in those words.
1379
+ *
1380
+ * This used to need a double-click, with a single click only selecting the field and
1381
+ * lighting up the panel on the right. Which meant the obvious gesture — click the
1382
+ * heading, type — did nothing visible, and the way to edit text in place was
1383
+ * undiscoverable unless somebody told you. Every editor a client has used puts a caret
1384
+ * in text on one click, and the frame already looks like the page rather than like a
1385
+ * form, so it is the gesture they arrive with.
1386
+ *
1387
+ * Only text leaves. A marked image, or a container holding several marked children, has
1388
+ * nowhere to put a caret — those still select and are edited from the panel.
1389
+ *
1390
+ * **Not while navigation is on.** That mode exists so the client can walk their own
1391
+ * site, following links to reach the page they want, and the toolbar names it "click
1392
+ * one to open that page". A caret appearing in every heading passed on the way is
1393
+ * noise, and on an annotated link it would swallow the very click meant to follow it.
1394
+ * Double-click still edits in that mode, because a double-click is unambiguous.
1395
+ */
1396
+ if (!navigationRef.current && isEditableLeaf(target as HTMLElement)) {
1397
+ // Resolved before the element is editable, applied after — see `caretTargetIn`, where
1398
+ // the measurements behind that order are written down.
1399
+ const caret = caretTargetIn(target as HTMLElement, event.clientX, event.clientY)
1400
+ beginEditing(target as HTMLElement)
1401
+ applyCaret(caret)
1402
+ }
1403
+ }
1404
+
1405
+ /*
1406
+ * Double-click to edit the words where they are.
1407
+ *
1408
+ * Capture phase, like `onClick` and for the same reason: a link or button wrapping the
1409
+ * text would otherwise get the event first. The double-click has already selected the
1410
+ * word under the pointer, and making the element editable keeps that selection — so
1411
+ * typing replaces the word you aimed at, which is what a double-click means everywhere
1412
+ * else.
1413
+ *
1414
+ * Nothing happens on anything that isn't a text leaf: a marked image, or a container
1415
+ * holding several marked children. Those stay a single click and the panel on the right.
1416
+ */
1417
+ const onDoubleClick = (event: MouseEvent) => {
1418
+ const from = event.target as HTMLElement | null
1419
+ const target = from?.closest?.(`[${FIELD_ATTR}]`) as HTMLElement | null
1420
+ if (!target || !target.getAttribute(FIELD_ATTR) || !isEditableLeaf(target)) return
1421
+
1422
+ /*
1423
+ * Already editing this element — which is now the common case, since the first click of
1424
+ * the double started it. Hand the event to the browser untouched: selecting the word
1425
+ * under the pointer is its default action inside a contenteditable, and that is exactly
1426
+ * what a double-click should do. Suppressing it here, as the branch below must, would
1427
+ * turn a double-click into an ordinary caret placement and lose the one thing the
1428
+ * gesture is for.
1429
+ */
1430
+ if (editing === target) return
1431
+
1432
+ /*
1433
+ * Not editing yet, so this is the path that still matters: navigation is on, where a
1434
+ * single click deliberately does not start editing. A double-click is unambiguous enough
1435
+ * to mean "edit this" even while the client is browsing, so it is honoured — and the
1436
+ * default has to be suppressed, or the link under the words navigates away from the
1437
+ * caret that was just placed.
1438
+ */
1439
+ event.preventDefault()
1440
+ event.stopPropagation()
1441
+ const caret = caretTargetIn(target, event.clientX, event.clientY)
1442
+ beginEditing(target)
1443
+ applyCaret(caret)
1444
+ }
1445
+
1446
+ /*
1447
+ * Debounced, so the dashboard's autosave sees a burst of typing as one edit rather than
1448
+ * a save per character — the same shape as the panel's own control, which is a React
1449
+ * input driving the identical change path.
1450
+ */
1451
+ const EDIT_DEBOUNCE_MS = 150
1452
+
1453
+ const onInput = () => {
1454
+ const element = editing
1455
+ if (!element) return
1456
+ const field = element.getAttribute(FIELD_ATTR)
1457
+ if (!field) return
1458
+
1459
+ unsent = element.hasAttribute(RICH_ATTR)
1460
+ ? { element, field, rich: true }
1461
+ : { field, rich: false, value: readEditedText(element) }
1462
+ window.clearTimeout(editTimer)
1463
+ editTimer = window.setTimeout(flushEdit, EDIT_DEBOUNCE_MS)
1464
+ }
1465
+
1466
+ /*
1467
+ * ── Formatting from a toolbar that lives in another window ────────────────
1468
+ *
1469
+ * The rich text toolbar sits on the frame's border, drawn by the dashboard. The words it
1470
+ * formats are in here. Nothing in the dashboard can touch this selection — `execCommand`
1471
+ * runs in the document that owns the selection, and that document is this one — so the
1472
+ * press arrives as `u90:format` and is applied here.
1473
+ *
1474
+ * **`execCommand`, deprecated, and still correct.** This package ships no dependencies, so
1475
+ * Lexical is not available on this side of the bridge and must not become available. For a
1476
+ * `contentEditable` it is the only formatting engine there is. It also emits exactly the
1477
+ * tags `lexicalDom.ts` already inverts — `<b>`, `<i>`, `<h2>`, `<ul><li>`, `<a href>` —
1478
+ * which is why the serialiser accepts the browser's spellings alongside the reader's.
1479
+ */
1480
+ let lastRange: null | Range = null
1481
+
1482
+ /*
1483
+ * The selection, remembered while it still exists.
1484
+ *
1485
+ * Clicking a button in the parent window blurs this frame, and a blurred document reports a
1486
+ * collapsed selection — so by the time the message arrives there is nothing to format, and
1487
+ * the button reads as broken while the field is plainly focused. Recording the range as it
1488
+ * moves is what makes the toolbar work at all.
1489
+ *
1490
+ * Only ranges inside the element being edited are kept. A selection elsewhere on the page
1491
+ * is not this field's, and restoring it would apply the client's formatting to whatever
1492
+ * they happened to have highlighted last.
1493
+ */
1494
+ const rememberRange = () => {
1495
+ if (!editing || !editing.hasAttribute(RICH_ATTR)) return
1496
+ const selection = document.getSelection()
1497
+ if (!selection || selection.rangeCount === 0) return
1498
+ const range = selection.getRangeAt(0)
1499
+ if (!editing.contains(range.commonAncestorContainer)) return
1500
+ lastRange = range.cloneRange()
1501
+ reportCaret(range)
1502
+ }
1503
+
1504
+ /*
1505
+ * ── Where the toolbar should stand ────────────────────────────────────────
1506
+ *
1507
+ * The toolbar is drawn by the dashboard and has no way to see this selection, so the frame
1508
+ * reports where it is. Sent in this document's own viewport coordinates: the dashboard
1509
+ * knows where the frame sits and at what zoom, and that is the only pair of facts needed to
1510
+ * put a floating control beside the caret.
1511
+ *
1512
+ * Collapsed or not — a caret with no selection still wants the toolbar near it, because the
1513
+ * next thing a client does is choose a heading for the line they are on.
1514
+ */
1515
+ let caretFrame = 0
1516
+ const reportCaret = (range: Range): void => {
1517
+ window.cancelAnimationFrame(caretFrame)
1518
+ caretFrame = window.requestAnimationFrame(() => {
1519
+ const rect = range.getBoundingClientRect()
1520
+ /*
1521
+ * A collapsed range at the start of an empty block measures zero on every axis, and a
1522
+ * zero rect would park the toolbar in the frame's top-left corner. The element being
1523
+ * edited is the honest fallback — the toolbar lands on the field rather than nowhere.
1524
+ */
1525
+ const box =
1526
+ rect.width || rect.height || rect.top || rect.left
1527
+ ? rect
1528
+ : editing?.getBoundingClientRect()
1529
+ if (!box) return
1530
+
1531
+ /*
1532
+ * What kind of block the caret is in, so the toolbar can show it ticked.
1533
+ *
1534
+ * Read here rather than inferred out there: only this document can see the element the
1535
+ * selection sits in, and a toolbar that cannot say what the current style *is* leaves
1536
+ * the client guessing whether their last press did anything.
1537
+ *
1538
+ * A list item reports its list's kind, because that is the choice the client made.
1539
+ */
1540
+ const from = range.commonAncestorContainer
1541
+ const start = (from.nodeType === 1 ? from : from.parentElement) as HTMLElement | null
1542
+ const node = start?.closest('h1,h2,h3,li,p')
1543
+ const tag = node?.tagName.toLowerCase()
1544
+ const block =
1545
+ tag === 'li'
1546
+ ? node?.closest('ol')
1547
+ ? 'number'
1548
+ : 'bullet'
1549
+ : tag === 'h1' || tag === 'h2' || tag === 'h3'
1550
+ ? tag
1551
+ : 'paragraph'
1552
+
1553
+ send({
1554
+ block,
1555
+ height: box.height,
1556
+ left: box.left,
1557
+ top: box.top,
1558
+ type: 'u90:caret',
1559
+ width: box.width,
1560
+ })
1561
+ })
1562
+ }
1563
+
1564
+ /** No caret, no toolbar. Sent on release so the dashboard can put it away. */
1565
+ const clearCaret = (): void => {
1566
+ window.cancelAnimationFrame(caretFrame)
1567
+ send({ height: 0, left: 0, top: 0, type: 'u90:caret', width: 0 })
1568
+ }
1569
+
1570
+ /** What each button asks the browser for. */
1571
+ const COMMANDS: Record<string, [string, string | undefined]> = {
1572
+ bold: ['bold', undefined],
1573
+ bullet: ['insertUnorderedList', undefined],
1574
+ h1: ['formatBlock', '<h1>'],
1575
+ h2: ['formatBlock', '<h2>'],
1576
+ h3: ['formatBlock', '<h3>'],
1577
+ italic: ['italic', undefined],
1578
+ number: ['insertOrderedList', undefined],
1579
+ paragraph: ['formatBlock', '<p>'],
1580
+ strike: ['strikeThrough', undefined],
1581
+ underline: ['underline', undefined],
1582
+ }
1583
+
1584
+ const applyFormat = (action: unknown, url?: string): void => {
1585
+ const element = editing
1586
+ if (!element || !element.hasAttribute(RICH_ATTR)) return
1587
+ if (typeof action !== 'string') return
1588
+
1589
+ /*
1590
+ * Focus first, then restore the range. The order matters: focusing collapses the
1591
+ * selection to wherever the browser thinks the caret is, so restoring first and focusing
1592
+ * second would throw the remembered range away.
1593
+ */
1594
+ element.focus({ preventScroll: true })
1595
+ const selection = document.getSelection()
1596
+ if (lastRange && selection) {
1597
+ selection.removeAllRanges()
1598
+ selection.addRange(lastRange)
1599
+ }
1600
+
1601
+ if (action === 'single') {
1602
+ if (!url || !/^[^{}\s]+$/.test(url) || url === 'link') return
1603
+ const chip = document.createElement('span')
1604
+ chip.setAttribute('data-single-key', url)
1605
+ chip.contentEditable = 'false'
1606
+ chip.textContent = `{${url}}`
1607
+ document.execCommand('insertHTML', false, chip.outerHTML)
1608
+ } else if (action === 'link') {
1609
+ // An empty url is the unlink button — the same control, in its other state.
1610
+ if (url) document.execCommand('createLink', false, url)
1611
+ else document.execCommand('unlink')
1612
+ } else {
1613
+ const command = COMMANDS[action]
1614
+ if (!command) return
1615
+ document.execCommand(command[0], false, command[1])
1616
+ }
1617
+
1618
+ /*
1619
+ * `execCommand` fires `input` in every browser that still implements it, but the flush is
1620
+ * scheduled explicitly rather than relying on that: a formatting press the client can see
1621
+ * and the dashboard never receives is the worst of the failure modes available here.
1622
+ */
1623
+ rememberRange()
1624
+ onInput()
1625
+ }
1626
+
1627
+ // Tabbing or clicking into another focusable thing. A press on plain page copy is
1628
+ // caught by `onPointerDown` instead — see the note there.
1629
+ const onFocusOut = (event: FocusEvent) => {
1630
+ if (event.target !== editing) return
1631
+
1632
+ /*
1633
+ * Leaving the document is not leaving the field.
1634
+ *
1635
+ * Clicking the formatting toolbar, switching applications, clicking the browser's own
1636
+ * chrome — all fire `focusout` here exactly as tabbing to the next field does. Ending
1637
+ * the edit on any of them is what made the toolbar inert: by the time a press on
1638
+ * "Heading 1" arrived, `editing` was null and the command had nothing to act on.
1639
+ *
1640
+ * `relatedTarget` is what separates them, and `document.hasFocus()` — which this used to
1641
+ * ask — is not. `focusout` fires *while* focus is leaving, and at that moment this
1642
+ * document can still report having it, so the guard passed and the edit ended anyway.
1643
+ * That is why leaving the window looked fixed and clicking the toolbar did not.
1644
+ *
1645
+ * Focus moving somewhere outside this document reports no `relatedTarget` at all, which
1646
+ * is precisely the case to hold on to: the client is still in the field, they are just
1647
+ * reaching for a control that lives in another window.
1648
+ */
1649
+ const leaving = editing
1650
+ const next = event.relatedTarget as Node | null
1651
+ if (!next || !leaving) return
1652
+ if (leaving.contains(next)) return
1653
+
1654
+ endEditing()
1655
+ }
1656
+
1657
+ /*
1658
+ * Coming back to the window: say where the caret is again, so the toolbar returns to it.
1659
+ * The selection survived the trip — only the dashboard's knowledge of it did not.
1660
+ */
1661
+ const onWindowFocus = () => {
1662
+ if (editing) rememberRange()
1663
+ }
1664
+
1665
+ /*
1666
+ * ── What the client has highlighted ───────────────────────────────────────
1667
+ *
1668
+ * So the dashboard's ⌘L can attach a *run of words* to the agent, not just the field they
1669
+ * sit in. Asking for "the second sentence" to change is the common case and an
1670
+ * element-level selection cannot say it.
1671
+ *
1672
+ * **On pointer-up and key-up, not on `selectionchange`.** A single click into words puts a
1673
+ * caret in them, so from that moment the selection belongs to the client's typing — and
1674
+ * `selectionchange` fires on every keystroke and every caret nudge. These two fire once
1675
+ * per gesture: a drag that ends, or a shift-arrow that lands. Typing collapses the
1676
+ * selection, which reports `null` once and then repeats itself into the `lastReported`
1677
+ * guard below rather than posting per character.
1678
+ *
1679
+ * Reported *while* editing rather than suppressed, deliberately: a run selected inside a
1680
+ * contentEditable element is exactly the case this exists for — the client has clicked
1681
+ * into their own heading and dragged across the half of it they want rewritten.
1682
+ */
1683
+ let lastReported: null | string = null
1684
+
1685
+ const reportHighlight = () => {
1686
+ const selection = window.getSelection()
1687
+ const words = selection && !selection.isCollapsed ? collapseSpace(selection.toString()) : ''
1688
+ const text = words || null
1689
+
1690
+ // Only when it changes. See the note above about keystrokes.
1691
+ if (text === lastReported) return
1692
+ lastReported = text
1693
+
1694
+ /*
1695
+ * Which annotated element the run started in, so the dashboard can name the field as
1696
+ * well as quote the words. `anchorNode` is usually a text node, hence the climb to an
1697
+ * element before `closest`. Null is a real answer: page copy that carries no marker is
1698
+ * still worth quoting, it just cannot be attributed to a field.
1699
+ */
1700
+ const anchor = selection?.anchorNode ?? null
1701
+ const from =
1702
+ anchor instanceof HTMLElement
1703
+ ? anchor
1704
+ : ((anchor?.parentElement ?? null) as HTMLElement | null)
1705
+ const host = text ? from?.closest?.(`[${FIELD_ATTR}]`) : null
1706
+
1707
+ send({
1708
+ field: host?.getAttribute(FIELD_ATTR) ?? null,
1709
+ text,
1710
+ type: 'u90:highlight',
1711
+ })
1712
+ }
1713
+
1714
+ window.addEventListener('pointerdown', onPointerDown)
1715
+ window.addEventListener('pointermove', onPointerMove)
1716
+ window.addEventListener('pointerup', onPointerUp)
1717
+ window.addEventListener('pointercancel', onPointerUp)
1718
+ window.addEventListener('auxclick', onAuxClick)
1719
+ window.addEventListener('keydown', onKey, { capture: true })
1720
+ window.addEventListener('keyup', onKey, { capture: true })
1721
+ document.addEventListener('click', onClick, true)
1722
+ document.addEventListener('dblclick', onDoubleClick, true)
1723
+ document.addEventListener('input', onInput)
1724
+ document.addEventListener('selectionchange', rememberRange)
1725
+ window.addEventListener('focus', onWindowFocus)
1726
+ document.addEventListener('focusout', onFocusOut)
1727
+ // After the gesture that made the selection, never during it — see `reportHighlight`.
1728
+ window.addEventListener('pointerup', reportHighlight)
1729
+ window.addEventListener('keyup', reportHighlight)
1730
+
1731
+ return () => {
1732
+ languageHints.dispose()
1733
+ window.removeEventListener('message', onMessage)
1734
+ window.removeEventListener('wheel', onWheel, { capture: true })
1735
+ window.removeEventListener('scroll', reportScroll)
1736
+ if (scrollPending) cancelAnimationFrame(scrollPending)
1737
+ window.removeEventListener('pointerdown', onPointerDown)
1738
+ window.removeEventListener('pointermove', onPointerMove)
1739
+ window.removeEventListener('pointerup', onPointerUp)
1740
+ window.removeEventListener('pointercancel', onPointerUp)
1741
+ window.removeEventListener('auxclick', onAuxClick)
1742
+ window.removeEventListener('keydown', onKey, { capture: true })
1743
+ window.removeEventListener('keyup', onKey, { capture: true })
1744
+ document.removeEventListener('click', onClick, true)
1745
+ document.removeEventListener('dblclick', onDoubleClick, true)
1746
+ document.removeEventListener('input', onInput)
1747
+ document.removeEventListener('selectionchange', rememberRange)
1748
+ window.removeEventListener('focus', onWindowFocus)
1749
+ document.removeEventListener('focusout', onFocusOut)
1750
+ window.removeEventListener('pointerup', reportHighlight)
1751
+ window.removeEventListener('keyup', reportHighlight)
1752
+ // Send what was typed before tearing down, and leave no element contentEditable
1753
+ // behind — this page may carry on being a page.
1754
+ endEditing()
1755
+ window.removeEventListener('popstate', reportPath)
1756
+ // Put history back the way we found it, so a navigation after the editor
1757
+ // closes doesn't call into a listener that is no longer wanted.
1758
+ window.history.pushState = pushState
1759
+ window.history.replaceState = replaceState
1760
+ heights.disconnect()
1761
+ cancelAnimationFrame(raf)
1762
+ // Hand the stylesheets back unpinned — this page may carry on being a page.
1763
+ releaseViewport()
1764
+ }
1765
+ // `blocks` and `collections` are module-level constants in every real site;
1766
+ // re-subscribing on them would tear the listeners down on each render for no benefit.
1767
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1768
+ }, [embedded, platformOrigin, send])
1769
+
1770
+ return { content, embedded, entries, navigation, selected }
1771
+ }
1772
+
1773
+ /**
1774
+ * Editing affordances, rendered only inside the dashboard frame.
1775
+ *
1776
+ * A stylesheet rather than per-element classes, so the page components stay free of
1777
+ * editor concerns — they only have to carry the field attribute.
1778
+ */
1779
+ export const EditingStyles = ({
1780
+ navigation = false,
1781
+ selected,
1782
+ }: {
1783
+ /**
1784
+ * Whether links in this frame navigate. Optional and defaulting to off, so a site
1785
+ * on an older kit that renders this without the prop still gets the editing
1786
+ * affordances — the safe reading, since an embedded frame is being edited.
1787
+ */
1788
+ navigation?: boolean
1789
+ selected: null | string
1790
+ }) => {
1791
+ // The values are generated paths (digits, letters, dots), so escaping quotes is
1792
+ // sufficient and avoids depending on CSS.escape.
1793
+ const escaped = selected?.replace(/"/g, '\\"')
1794
+ const sectionKey =
1795
+ selected && !selected.startsWith('@') ? /^(\d+)(?:\.|$)/.exec(selected)?.[1] : undefined
1796
+
1797
+ return (
1798
+ <style
1799
+ data-u90-bridge=""
1800
+ dangerouslySetInnerHTML={{
1801
+ __html: `
1802
+ [${FIELD_ATTR}] { cursor: pointer; }
1803
+ [${FIELD_ATTR}]:hover {
1804
+ outline: 1px dashed #FF5D2E;
1805
+ outline-offset: 4px;
1806
+ }
1807
+
1808
+ /*
1809
+ * A picture's affordance is drawn INSIDE it.
1810
+ *
1811
+ * Every rule above offsets its outline outwards, which is right for words and wrong
1812
+ * for a photograph: an image sits in a rounded, overflow-hidden frame and fills it
1813
+ * exactly, so an outline painted outside the image lands on the clip edge and is
1814
+ * never drawn at all. Selecting one of pbir's service photos looked like selecting
1815
+ * nothing. A negative offset paints within the element's own box, where no ancestor's
1816
+ * overflow can reach it.
1817
+ *
1818
+ * No backticks anywhere in this comment, like the one further down: it lives inside a
1819
+ * template literal, so one would end the stylesheet mid-sentence.
1820
+ *
1821
+ * The corners do not follow the frame's radius, because the radius is on the wrapper
1822
+ * and this outline is on the image. A square inside a rounded frame is a visible
1823
+ * selection box; a clipped one is not.
1824
+ */
1825
+ ${MEDIA_SELECTOR} { cursor: pointer; }
1826
+ ${MEDIA_SELECTOR}:hover {
1827
+ outline: 2px dashed #FF5D2E;
1828
+ outline-offset: -2px;
1829
+ }
1830
+ [${SECTION_ATTR}]:hover {
1831
+ outline: 2px dashed #FF5D2E;
1832
+ outline-offset: 8px;
1833
+ }
1834
+ ${
1835
+ sectionKey
1836
+ ? `[${SECTION_ATTR}="${sectionKey}"] {
1837
+ outline: 3px solid #FF5D2E !important;
1838
+ outline-offset: 8px;
1839
+ }`
1840
+ : ''
1841
+ }
1842
+ ${
1843
+ navigation
1844
+ ? /*
1845
+ * Navigation on: links are links again, so say so with the cursor and
1846
+ * draw nothing. Anything annotated keeps its own affordance above.
1847
+ */
1848
+ `a[href]:not([${FIELD_ATTR}]), button:not([${FIELD_ATTR}]) { cursor: pointer; }`
1849
+ : /*
1850
+ * Navigation off: outline the whole control rather than the words
1851
+ * inside it. A button's marker sits on its label, so without this the
1852
+ * hover outline hugs the text and the padding around it reads as dead
1853
+ * space — when in fact the entire control is what gets selected.
1854
+ *
1855
+ * `:not(:has(...))` keeps the outline on the outermost thing: a link
1856
+ * containing an annotated span is drawn once, around the link, rather
1857
+ * than twice in concentric rectangles.
1858
+ */
1859
+ `a[href], button { cursor: pointer; }
1860
+ a[href]:not(:has([${FIELD_ATTR}])):hover,
1861
+ button:not(:has([${FIELD_ATTR}])):hover {
1862
+ outline: 1px dashed #FF5D2E;
1863
+ outline-offset: 3px;
1864
+ }
1865
+ a[href]:has([${FIELD_ATTR}]):hover {
1866
+ outline: 1px dashed #FF5D2E;
1867
+ outline-offset: 3px;
1868
+ }
1869
+
1870
+ /*
1871
+ * An I-beam over anything one click will put a caret in.
1872
+ *
1873
+ * This selector is isEditableLeaf written in CSS - marked, and EITHER
1874
+ * carrying the rich text marker OR (containing no other marked element and
1875
+ * containing none of the non-text tags) - so the two cannot disagree about
1876
+ * what is typable. A rich field is accepted by that first branch precisely
1877
+ * because a real document contains the very things the second branch rules
1878
+ * out, an <a> and a <ul> among them. It sits inside the navigation-off
1879
+ * branch, and after the link and button rule above, because a marked button
1880
+ * label is both a control and a text leaf: with navigation off it is text
1881
+ * and takes a caret, with navigation on it is a link.
1882
+ *
1883
+ * The one thing CSS cannot check is that the element has any text at all. An
1884
+ * empty marked element is a field the client has not filled in, so it offers
1885
+ * an I-beam and the click does nothing - the least interesting way for the
1886
+ * two to fall out of step, and the alternative is a class every site
1887
+ * component would have to carry.
1888
+ */
1889
+ ${EDITABLE_LEAF_SELECTOR} {
1890
+ cursor: text;
1891
+ }`
1892
+ }
1893
+ ${
1894
+ escaped
1895
+ ? `[${FIELD_ATTR}="${escaped}"] {
1896
+ outline: 2px solid #FF5D2E !important;
1897
+ outline-offset: 5px;
1898
+ }
1899
+ /*
1900
+ * The selected picture, drawn inside itself for the reason its hover rule is.
1901
+ * More specific than the rule above - an attribute plus a type - so it takes
1902
+ * the offset without an !important of its own.
1903
+ */
1904
+ [${FIELD_ATTR}="${escaped}"]:is(${MEDIA}) {
1905
+ outline: 3px solid #FF5D2E !important;
1906
+ outline-offset: -3px;
1907
+ }`
1908
+ : ''
1909
+ }
1910
+ /*
1911
+ * The field the caret is in. Solid like a selection, because it is one, but with
1912
+ * a text cursor and no focus ring — the browser's own would sit inside the
1913
+ * outline and read as two borders around one word.
1914
+ *
1915
+ * Keyed off the attribute the bridge sets rather than off the selected prop, so
1916
+ * it appears the instant the element becomes editable rather than a round trip
1917
+ * later, and cannot disagree with what is actually contentEditable.
1918
+ */
1919
+ [${FIELD_ATTR}][data-u90-editing] {
1920
+ cursor: text;
1921
+ outline: 2px solid #FF5D2E !important;
1922
+ outline-offset: 5px;
1923
+ }
1924
+ [${FIELD_ATTR}][data-u90-editing]:focus,
1925
+ [${FIELD_ATTR}][data-u90-editing]:focus-visible {
1926
+ outline: 2px solid #FF5D2E !important;
1927
+ }
1928
+
1929
+ /*
1930
+ * A page with no sections, said out loud.
1931
+ *
1932
+ * A freshly created page renders its header and footer around an empty main
1933
+ * element, which in a Canvas frame is indistinguishable from a broken render --
1934
+ * the editor sees the chrome and a void and reasonably assumes the page failed.
1935
+ * This names the state instead.
1936
+ *
1937
+ * CSS rather than a component, and that is the point. Every generated site
1938
+ * already renders EditingStyles when embedded and already emits a main element,
1939
+ * so a stylesheet reaches every site that exists today on a kit bump alone: no
1940
+ * regenerated HomeFrame, no new prop, no call site to add. Same reasoning as the
1941
+ * navigation prop defaulting to off above -- the kit has to do something useful
1942
+ * for sites written before it knew how to ask.
1943
+ *
1944
+ * Only inside Canvas, because this whole component renders only when embedded.
1945
+ * A visitor on the live site sees an empty page, not our scaffolding.
1946
+ *
1947
+ * The :not(:has(*)) test rather than :empty, which a single newline of whitespace
1948
+ * between tags defeats -- and whether there is one is a formatting accident of
1949
+ * whatever generated the page.
1950
+ *
1951
+ * The copy does not offer to add anything. Sections and layout are deliberately
1952
+ * not a client's to change: see the note in the dashboard's AgentPanel, which
1953
+ * exists because promising exactly that was the first thing a client tried and
1954
+ * the first thing they were refused. So this reports the state and stops.
1955
+ *
1956
+ * No backticks anywhere in here. This comment lives inside a template literal,
1957
+ * and one would end the stylesheet mid-sentence.
1958
+ */
1959
+ main:not(:has(*)) {
1960
+ min-height: 50vh;
1961
+ display: grid;
1962
+ place-items: center;
1963
+ padding: 48px 24px;
1964
+ box-sizing: border-box;
1965
+ }
1966
+ main:not(:has(*))::after {
1967
+ /*
1968
+ * Says where the control is rather than pretending to be one. The page is the
1969
+ * client's site in an iframe; it has no access to the dashboard's panel, and a
1970
+ * button here that could not add anything would be worse than a sentence.
1971
+ */
1972
+ content: 'This page has no sections yet. Add one from the Page panel.';
1973
+ display: block;
1974
+ max-width: 32ch;
1975
+ padding: 28px 32px;
1976
+ border: 1px dashed #FF5D2E;
1977
+ /* Not the site's own type ramp: this is our chrome speaking, not the page. */
1978
+ font: 500 14px/1.5 ui-sans-serif, system-ui, sans-serif;
1979
+ letter-spacing: 0.01em;
1980
+ text-align: center;
1981
+ color: #FF5D2E;
1982
+ }
1983
+ `,
1984
+ }}
1985
+ />
1986
+ )
1987
+ }