@brass-build/client 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (258) hide show
  1. package/AGENTS.md +255 -0
  2. package/CHANGELOG.md +15 -0
  3. package/LICENSE +21 -0
  4. package/README.md +836 -0
  5. package/dist/api-fetch.d.ts +16 -0
  6. package/dist/api-fetch.d.ts.map +1 -0
  7. package/dist/api-fetch.js +97 -0
  8. package/dist/api-fetch.js.map +1 -0
  9. package/dist/api-headers.d.ts +3 -0
  10. package/dist/api-headers.d.ts.map +1 -0
  11. package/dist/api-headers.js +25 -0
  12. package/dist/api-headers.js.map +1 -0
  13. package/dist/auth-flow.d.ts +23 -0
  14. package/dist/auth-flow.d.ts.map +1 -0
  15. package/dist/auth-flow.js +76 -0
  16. package/dist/auth-flow.js.map +1 -0
  17. package/dist/client.d.ts +106 -0
  18. package/dist/client.d.ts.map +1 -0
  19. package/dist/client.js +1278 -0
  20. package/dist/client.js.map +1 -0
  21. package/dist/doc-url-sync.d.ts +19 -0
  22. package/dist/doc-url-sync.d.ts.map +1 -0
  23. package/dist/doc-url-sync.js +181 -0
  24. package/dist/doc-url-sync.js.map +1 -0
  25. package/dist/document-branches.d.ts +32 -0
  26. package/dist/document-branches.d.ts.map +1 -0
  27. package/dist/document-branches.js +89 -0
  28. package/dist/document-branches.js.map +1 -0
  29. package/dist/document-checkpoints.d.ts +24 -0
  30. package/dist/document-checkpoints.d.ts.map +1 -0
  31. package/dist/document-checkpoints.js +115 -0
  32. package/dist/document-checkpoints.js.map +1 -0
  33. package/dist/document-commands.d.ts +93 -0
  34. package/dist/document-commands.d.ts.map +1 -0
  35. package/dist/document-commands.js +423 -0
  36. package/dist/document-commands.js.map +1 -0
  37. package/dist/document-signals.d.ts +53 -0
  38. package/dist/document-signals.d.ts.map +1 -0
  39. package/dist/document-signals.js +114 -0
  40. package/dist/document-signals.js.map +1 -0
  41. package/dist/document-streams.d.ts +22 -0
  42. package/dist/document-streams.d.ts.map +1 -0
  43. package/dist/document-streams.js +98 -0
  44. package/dist/document-streams.js.map +1 -0
  45. package/dist/document-thumbnail-capture.d.ts +27 -0
  46. package/dist/document-thumbnail-capture.d.ts.map +1 -0
  47. package/dist/document-thumbnail-capture.js +160 -0
  48. package/dist/document-thumbnail-capture.js.map +1 -0
  49. package/dist/documents-namespace.d.ts +134 -0
  50. package/dist/documents-namespace.d.ts.map +1 -0
  51. package/dist/documents-namespace.js +190 -0
  52. package/dist/documents-namespace.js.map +1 -0
  53. package/dist/documents.d.ts +146 -0
  54. package/dist/documents.d.ts.map +1 -0
  55. package/dist/documents.js +2045 -0
  56. package/dist/documents.js.map +1 -0
  57. package/dist/emitter.d.ts +15 -0
  58. package/dist/emitter.d.ts.map +1 -0
  59. package/dist/emitter.js +90 -0
  60. package/dist/emitter.js.map +1 -0
  61. package/dist/errors.d.ts +18 -0
  62. package/dist/errors.d.ts.map +1 -0
  63. package/dist/errors.js +22 -0
  64. package/dist/errors.js.map +1 -0
  65. package/dist/export-guest.d.ts +9 -0
  66. package/dist/export-guest.d.ts.map +1 -0
  67. package/dist/export-guest.js +225 -0
  68. package/dist/export-guest.js.map +1 -0
  69. package/dist/fetch-retry.d.ts +16 -0
  70. package/dist/fetch-retry.d.ts.map +1 -0
  71. package/dist/fetch-retry.js +90 -0
  72. package/dist/fetch-retry.js.map +1 -0
  73. package/dist/guest-link.d.ts +3 -0
  74. package/dist/guest-link.d.ts.map +1 -0
  75. package/dist/guest-link.js +13 -0
  76. package/dist/guest-link.js.map +1 -0
  77. package/dist/handles.d.ts +57 -0
  78. package/dist/handles.d.ts.map +1 -0
  79. package/dist/handles.js +1375 -0
  80. package/dist/handles.js.map +1 -0
  81. package/dist/hosted-runtime.d.ts +33 -0
  82. package/dist/hosted-runtime.d.ts.map +1 -0
  83. package/dist/hosted-runtime.js +99 -0
  84. package/dist/hosted-runtime.js.map +1 -0
  85. package/dist/hosted-ui.d.ts +84 -0
  86. package/dist/hosted-ui.d.ts.map +1 -0
  87. package/dist/hosted-ui.js +673 -0
  88. package/dist/hosted-ui.js.map +1 -0
  89. package/dist/importer.d.ts +22 -0
  90. package/dist/importer.d.ts.map +1 -0
  91. package/dist/importer.js +54 -0
  92. package/dist/importer.js.map +1 -0
  93. package/dist/index.d.ts +54 -0
  94. package/dist/index.d.ts.map +1 -0
  95. package/dist/index.js +132 -0
  96. package/dist/index.js.map +1 -0
  97. package/dist/init-keys.d.ts +3 -0
  98. package/dist/init-keys.d.ts.map +1 -0
  99. package/dist/init-keys.js +13 -0
  100. package/dist/init-keys.js.map +1 -0
  101. package/dist/internal.d.ts +6 -0
  102. package/dist/internal.d.ts.map +1 -0
  103. package/dist/internal.js +17 -0
  104. package/dist/internal.js.map +1 -0
  105. package/dist/jwt.d.ts +3 -0
  106. package/dist/jwt.d.ts.map +1 -0
  107. package/dist/jwt.js +22 -0
  108. package/dist/jwt.js.map +1 -0
  109. package/dist/launch-context.d.ts +4 -0
  110. package/dist/launch-context.d.ts.map +1 -0
  111. package/dist/launch-context.js +44 -0
  112. package/dist/launch-context.js.map +1 -0
  113. package/dist/login-hint.d.ts +4 -0
  114. package/dist/login-hint.d.ts.map +1 -0
  115. package/dist/login-hint.js +63 -0
  116. package/dist/login-hint.js.map +1 -0
  117. package/dist/mutate-batching.d.ts +14 -0
  118. package/dist/mutate-batching.d.ts.map +1 -0
  119. package/dist/mutate-batching.js +65 -0
  120. package/dist/mutate-batching.js.map +1 -0
  121. package/dist/open-document.d.ts +127 -0
  122. package/dist/open-document.d.ts.map +1 -0
  123. package/dist/open-document.js +2121 -0
  124. package/dist/open-document.js.map +1 -0
  125. package/dist/page-title.d.ts +3 -0
  126. package/dist/page-title.d.ts.map +1 -0
  127. package/dist/page-title.js +32 -0
  128. package/dist/page-title.js.map +1 -0
  129. package/dist/perf.d.ts +9 -0
  130. package/dist/perf.d.ts.map +1 -0
  131. package/dist/perf.js +39 -0
  132. package/dist/perf.js.map +1 -0
  133. package/dist/presence.d.ts +62 -0
  134. package/dist/presence.d.ts.map +1 -0
  135. package/dist/presence.js +331 -0
  136. package/dist/presence.js.map +1 -0
  137. package/dist/record-paging.d.ts +99 -0
  138. package/dist/record-paging.d.ts.map +1 -0
  139. package/dist/record-paging.js +220 -0
  140. package/dist/record-paging.js.map +1 -0
  141. package/dist/schema-conflict-dialog.d.ts +10 -0
  142. package/dist/schema-conflict-dialog.d.ts.map +1 -0
  143. package/dist/schema-conflict-dialog.js +164 -0
  144. package/dist/schema-conflict-dialog.js.map +1 -0
  145. package/dist/selection-persistence.d.ts +5 -0
  146. package/dist/selection-persistence.d.ts.map +1 -0
  147. package/dist/selection-persistence.js +90 -0
  148. package/dist/selection-persistence.js.map +1 -0
  149. package/dist/serializer.d.ts +3 -0
  150. package/dist/serializer.d.ts.map +1 -0
  151. package/dist/serializer.js +2 -0
  152. package/dist/serializer.js.map +1 -0
  153. package/dist/session-persistence.d.ts +4 -0
  154. package/dist/session-persistence.d.ts.map +1 -0
  155. package/dist/session-persistence.js +68 -0
  156. package/dist/session-persistence.js.map +1 -0
  157. package/dist/session-toolbar.d.ts +62 -0
  158. package/dist/session-toolbar.d.ts.map +1 -0
  159. package/dist/session-toolbar.js +1205 -0
  160. package/dist/session-toolbar.js.map +1 -0
  161. package/dist/session.d.ts +69 -0
  162. package/dist/session.d.ts.map +1 -0
  163. package/dist/session.js +926 -0
  164. package/dist/session.js.map +1 -0
  165. package/dist/tail-ops.d.ts +28 -0
  166. package/dist/tail-ops.d.ts.map +1 -0
  167. package/dist/tail-ops.js +46 -0
  168. package/dist/tail-ops.js.map +1 -0
  169. package/dist/topic-lifecycle.d.ts +41 -0
  170. package/dist/topic-lifecycle.d.ts.map +1 -0
  171. package/dist/topic-lifecycle.js +169 -0
  172. package/dist/topic-lifecycle.js.map +1 -0
  173. package/dist/types.d.ts +22 -0
  174. package/dist/types.d.ts.map +1 -0
  175. package/dist/types.js +2 -0
  176. package/dist/types.js.map +1 -0
  177. package/dist/undo.d.ts +18 -0
  178. package/dist/undo.d.ts.map +1 -0
  179. package/dist/undo.js +110 -0
  180. package/dist/undo.js.map +1 -0
  181. package/dist/version-bar.d.ts +22 -0
  182. package/dist/version-bar.d.ts.map +1 -0
  183. package/dist/version-bar.js +169 -0
  184. package/dist/version-bar.js.map +1 -0
  185. package/dist/version.d.ts +3 -0
  186. package/dist/version.d.ts.map +1 -0
  187. package/dist/version.js +8 -0
  188. package/dist/version.js.map +1 -0
  189. package/dist/vite.d.ts +15 -0
  190. package/dist/vite.d.ts.map +1 -0
  191. package/dist/vite.js +36 -0
  192. package/dist/vite.js.map +1 -0
  193. package/dist/ws-client.d.ts +53 -0
  194. package/dist/ws-client.d.ts.map +1 -0
  195. package/dist/ws-client.js +604 -0
  196. package/dist/ws-client.js.map +1 -0
  197. package/dist/ws-frame-limit.d.ts +21 -0
  198. package/dist/ws-frame-limit.d.ts.map +1 -0
  199. package/dist/ws-frame-limit.js +40 -0
  200. package/dist/ws-frame-limit.js.map +1 -0
  201. package/docs/app-manifest.md +65 -0
  202. package/docs/custom-chrome.md +147 -0
  203. package/docs/hosted-ui-advanced.md +77 -0
  204. package/docs/imported-documents.md +261 -0
  205. package/docs/presence.md +129 -0
  206. package/docs/schemas.md +135 -0
  207. package/docs/streams.md +248 -0
  208. package/package.json +60 -0
  209. package/scripts/prepare.mjs +50 -0
  210. package/src/api-fetch.ts +127 -0
  211. package/src/api-headers.ts +28 -0
  212. package/src/auth-flow.ts +125 -0
  213. package/src/client.ts +1457 -0
  214. package/src/doc-url-sync.ts +183 -0
  215. package/src/document-branches.ts +124 -0
  216. package/src/document-checkpoints.ts +135 -0
  217. package/src/document-commands.ts +651 -0
  218. package/src/document-signals.ts +234 -0
  219. package/src/document-streams.ts +132 -0
  220. package/src/document-thumbnail-capture.ts +191 -0
  221. package/src/documents-namespace.ts +516 -0
  222. package/src/documents.ts +2426 -0
  223. package/src/emitter.ts +129 -0
  224. package/src/errors.ts +45 -0
  225. package/src/export-guest.ts +282 -0
  226. package/src/fetch-retry.ts +119 -0
  227. package/src/guest-link.ts +14 -0
  228. package/src/handles.ts +1731 -0
  229. package/src/hosted-runtime.ts +109 -0
  230. package/src/hosted-ui.ts +1020 -0
  231. package/src/importer.ts +89 -0
  232. package/src/index.ts +267 -0
  233. package/src/init-keys.ts +15 -0
  234. package/src/internal.ts +23 -0
  235. package/src/jwt.ts +18 -0
  236. package/src/launch-context.ts +43 -0
  237. package/src/login-hint.ts +60 -0
  238. package/src/mutate-batching.ts +63 -0
  239. package/src/open-document.ts +2869 -0
  240. package/src/page-title.ts +33 -0
  241. package/src/perf.ts +46 -0
  242. package/src/presence.ts +471 -0
  243. package/src/record-paging.ts +270 -0
  244. package/src/schema-conflict-dialog.ts +176 -0
  245. package/src/selection-persistence.ts +98 -0
  246. package/src/serializer.ts +15 -0
  247. package/src/session-persistence.ts +69 -0
  248. package/src/session-toolbar.ts +1457 -0
  249. package/src/session.ts +1262 -0
  250. package/src/tail-ops.ts +63 -0
  251. package/src/topic-lifecycle.ts +217 -0
  252. package/src/types.ts +75 -0
  253. package/src/undo.ts +125 -0
  254. package/src/version-bar.ts +221 -0
  255. package/src/version.ts +8 -0
  256. package/src/vite.ts +49 -0
  257. package/src/ws-client.ts +678 -0
  258. package/src/ws-frame-limit.ts +52 -0
@@ -0,0 +1,673 @@
1
+ // The hosted-UI launcher core.
2
+ //
3
+ // "Hosted UI" surfaces are Brass-owned pages (document picker, share dialog) a
4
+ // third-party app opens without building the UI. The app calls e.g.
5
+ // `Brass.selectDocument()`; this loads `${dashboardBaseUrl}/embed/<surface>`
6
+ // in a modal iframe, runs a postMessage handshake with the embed page's
7
+ // bridge, and resolves with whatever the user did.
8
+ //
9
+ // Iframe-first. When the iframe can't complete the handshake (CSP
10
+ // `frame-ancestors`, a partitioned third-party auth cookie, ...) the launcher
11
+ // falls back per surface:
12
+ // - `reject`: throw `BrassError` `code: 'iframe_unavailable'`. Used by
13
+ // value-returning launchers with nowhere else to send the user.
14
+ // - `redirect`: navigate the top window to the standalone
15
+ // `/embed/<surface>?...&return_to=<here>` page. Used by fire-and-forget
16
+ // launchers; the returned promise never settles (the page is gone).
17
+ // - `redirect-with-value`: the same navigation for a surface that still
18
+ // owes the caller a value, which comes home on one-shot URL params (the
19
+ // document picker).
20
+ //
21
+ // Bridge protocol (envelope `__brass_embed: 1`):
22
+ // iframe -> host: ready | resize{height} | result{payload} | cancel |
23
+ // error{code,message}
24
+ // host -> iframe: init{surface,params}
25
+ // The host validates `event.origin === dashboardOrigin` and
26
+ // `event.source === iframe.contentWindow` on every inbound message; the embed
27
+ // page learns the host origin from `init` and targets outbound at it (never
28
+ // `*`).
29
+ // Both sides ignore any `message` whose `data[ENVELOPE]` is not this exact
30
+ // value, so unrelated postMessage traffic never reaches the handshake.
31
+ import { BrassError } from './errors.js';
32
+ const ENVELOPE = '__brass_embed';
33
+ const PROTOCOL_VERSION = 1;
34
+ // No `ready` within this window means the iframe is unusable (CSP, origin
35
+ // unreachable, bridge threw). What the window is worth is set by what the
36
+ // fallback costs the user, not by which fallback runs.
37
+ //
38
+ // Only a plain `redirect` can afford the short window: the surface returns
39
+ // nothing, the standalone page finishes the same job, and the user keeps
40
+ // their place, so eating ten seconds of blank modal is the bigger cost.
41
+ //
42
+ // `reject` and `redirect-with-value` both end with the caller worse off (a
43
+ // thrown error, or the user navigated out of the host app with the value
44
+ // routed home through a URL round trip), so they wait long enough that only
45
+ // a genuinely unusable iframe times out. The embed page is a full
46
+ // application boot behind a cross-origin request, so a merely slow link
47
+ // clears the short window while the iframe is perfectly capable.
48
+ const READY_TIMEOUT_LONG_MS = 10_000;
49
+ const READY_TIMEOUT_SHORT_MS = 4_000;
50
+ // The embed page posts `resize` with its measured height; the host clamps to
51
+ // this range so a malformed or hostile height can't collapse the dialog or
52
+ // paint the whole viewport.
53
+ const INITIAL_IFRAME_HEIGHT = 480;
54
+ const MIN_IFRAME_HEIGHT = 160;
55
+ const MAX_IFRAME_HEIGHT = 760;
56
+ // Full-page fallback when the iframe can't be hosted: navigate the top
57
+ // window to the standalone embed page, threading the surface params plus a
58
+ // `return_to` so the standalone page can send the user home when done.
59
+ function redirectToStandalone(baseUrl, surface, params, theme) {
60
+ const url = new URL(`${baseUrl}/embed/${surface}`);
61
+ for (const [key, value] of Object.entries(params)) {
62
+ // `sessionToken` is an in-iframe-only credential hand-off (posted over the
63
+ // bridge to the pinned dashboard origin); never let it reach a URL. On the
64
+ // redirect fallback the standalone page is top-level and first-party, so
65
+ // its own cookie carries the session and no hand-off is needed.
66
+ if (key === 'sessionToken')
67
+ continue;
68
+ if (value !== undefined && value !== null) {
69
+ url.searchParams.set(key, String(value));
70
+ }
71
+ }
72
+ url.searchParams.set('return_to', window.location.href);
73
+ // Thread the theme only when it overrides the `'light'` default, keeping the
74
+ // URL stable for the no-config path (mirrors the iframe-src construction).
75
+ if (theme !== 'light') {
76
+ url.searchParams.set('theme', theme);
77
+ }
78
+ window.location.assign(url.toString());
79
+ }
80
+ // Resolved light/dark for the modal chrome. `'auto'` reads the host's
81
+ // `prefers-color-scheme` once on open; the embed page still receives the
82
+ // unresolved `'auto'` so its own rule keeps tracking OS preference.
83
+ function resolveChromeTheme(theme) {
84
+ if (theme === 'light' || theme === 'dark')
85
+ return theme;
86
+ const mql = window.matchMedia?.('(prefers-color-scheme: dark)');
87
+ return mql?.matches ? 'dark' : 'light';
88
+ }
89
+ function chromeTokensFor(resolved) {
90
+ if (resolved === 'dark') {
91
+ return {
92
+ backdrop: 'rgba(0, 0, 0, 0.65)',
93
+ cardBackground: '#11141b',
94
+ cardShadow: '0 20px 60px rgba(0, 0, 0, 0.55)',
95
+ closeButtonColor: '#e7eaf0',
96
+ spinnerTrack: 'rgba(231, 234, 240, 0.18)',
97
+ spinnerAccent: '#e7eaf0',
98
+ };
99
+ }
100
+ return {
101
+ backdrop: 'rgba(15, 23, 42, 0.55)',
102
+ cardBackground: '#fff',
103
+ cardShadow: '0 20px 60px rgba(15, 23, 42, 0.35)',
104
+ closeButtonColor: '#0f172a',
105
+ spinnerTrack: 'rgba(15, 23, 42, 0.12)',
106
+ spinnerAccent: '#0f172a',
107
+ };
108
+ }
109
+ const CHROME_STYLE_ID = 'brass-hosted-ui-chrome';
110
+ // The chrome dims the page with the dialog element's own themed background, so
111
+ // the UA's `::backdrop` would stack a second, unthemed dim beneath it. A
112
+ // pseudo-element cannot be styled inline, which is why the chrome injects a
113
+ // stylesheet at all; everything else it paints is an inline style.
114
+ const CHROME_CSS = `dialog[data-brass-hosted-ui]::backdrop { background: transparent; }`;
115
+ function injectChromeStyles(doc) {
116
+ if (doc.getElementById(CHROME_STYLE_ID))
117
+ return;
118
+ const style = doc.createElement('style');
119
+ style.id = CHROME_STYLE_ID;
120
+ style.textContent = CHROME_CSS;
121
+ doc.head.appendChild(style);
122
+ }
123
+ // The control the reader last acted on, which is who a dismissed surface hands
124
+ // focus back to. `document.activeElement` when the launcher is called looks
125
+ // like the answer and is often `<body>` by then: a host that disables its
126
+ // trigger for the life of the dialog does so before calling, and disabling a
127
+ // focused control blurs it. Tracking the activation instead survives that.
128
+ let lastActivated = null;
129
+ let trackingActivations = false;
130
+ const ACTIVATABLE = 'button, a[href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
131
+ function trackActivation(event) {
132
+ // Enter and Space are the keys that press a control; every other keystroke
133
+ // is someone typing, and typing is not what a dialog returns focus to.
134
+ if (event instanceof KeyboardEvent && event.key !== 'Enter' && event.key !== ' ') {
135
+ return;
136
+ }
137
+ const target = event.target;
138
+ if (!(target instanceof HTMLElement))
139
+ return;
140
+ lastActivated = target.closest(ACTIVATABLE) ?? target;
141
+ }
142
+ /**
143
+ * Start recording which control the reader last pressed, so a dismissed
144
+ * surface can hand the keyboard back to it. Idempotent.
145
+ *
146
+ * @internal
147
+ */
148
+ export function trackHostedUiActivations() {
149
+ if (trackingActivations || typeof document === 'undefined')
150
+ return;
151
+ trackingActivations = true;
152
+ document.addEventListener('pointerdown', trackActivation, true);
153
+ document.addEventListener('keydown', trackActivation, true);
154
+ }
155
+ // Who a dismissal should put the keyboard back on: whatever holds focus when
156
+ // the surface opens, or the last control the reader pressed when nothing does.
157
+ function resolveOpener() {
158
+ const active = document.activeElement;
159
+ if (active instanceof HTMLElement && active !== document.body)
160
+ return active;
161
+ return lastActivated;
162
+ }
163
+ // Opens `${baseUrl}/embed/<surface>` in a modal iframe and drives the bridge
164
+ // handshake. Resolves with the `result` payload or `null` on cancel; rejects
165
+ // on not-in-browser / embed error. On the ready timeout it rejects
166
+ // (`unavailable: 'reject'`) or redirects and never settles (the two
167
+ // `redirect` modes).
168
+ function openEmbed(baseUrl, dashboardOrigin, surface, params, unavailable, theme,
169
+ // Handles an `export-request`: serialise the surface's `docId` and post the
170
+ // bytes back. Only the document surfaces (with a Download button) pass it.
171
+ onExportRequest,
172
+ // Notified when the delete surface reports a local delete (or its
173
+ // retraction) for a doc id, so the session can classify the matching
174
+ // `doc_gone`. Only the document-settings / share surface emits it.
175
+ onLocalDelete,
176
+ // Notified when the user takes the refusal surface's sign-in action.
177
+ onSignInRequested) {
178
+ return new Promise((resolve, reject) => {
179
+ if (typeof window === 'undefined' || typeof document === 'undefined') {
180
+ reject(new BrassError('Hosted UI requires a browser environment.', {
181
+ code: 'not_in_browser',
182
+ }));
183
+ return;
184
+ }
185
+ const resolvedChrome = resolveChromeTheme(theme);
186
+ const tokens = chromeTokensFor(resolvedChrome);
187
+ injectChromeStyles(document);
188
+ // A `<dialog>` opened with `showModal()` (below), which is what makes the
189
+ // host page inert while the surface is up: the reader tabs around the
190
+ // dialog instead of through the app behind it, and the browser enforces
191
+ // that rather than a key handler here. The UA box styles for a dialog (a
192
+ // content-sized, centered, bordered, padded box with a max-width) are all
193
+ // wrong for a full-viewport backdrop, so each is zeroed.
194
+ const overlay = document.createElement('dialog');
195
+ overlay.setAttribute('data-brass-hosted-ui', surface);
196
+ overlay.setAttribute('data-brass-theme', theme);
197
+ Object.assign(overlay.style, {
198
+ position: 'fixed',
199
+ inset: '0',
200
+ margin: '0',
201
+ padding: '0',
202
+ border: '0',
203
+ width: 'auto',
204
+ height: 'auto',
205
+ maxWidth: 'none',
206
+ maxHeight: 'none',
207
+ // Above effectively everything; this is a foreground modal. The top
208
+ // layer already outranks any z-index, so this is what keeps the chrome
209
+ // in front on the fallback path where `showModal` is unavailable.
210
+ zIndex: '2147483647',
211
+ display: 'flex',
212
+ alignItems: 'center',
213
+ justifyContent: 'center',
214
+ background: tokens.backdrop,
215
+ });
216
+ const card = document.createElement('div');
217
+ // Expose the frame as a labelled modal dialog: the overlay behind it is
218
+ // the backdrop, and the embed page inside the iframe carries its own
219
+ // heading. The close button / Escape dismiss it (below); a backdrop
220
+ // click does not.
221
+ card.setAttribute('role', 'dialog');
222
+ card.setAttribute('aria-modal', 'true');
223
+ card.setAttribute('aria-label', 'Brass');
224
+ Object.assign(card.style, {
225
+ position: 'relative',
226
+ width: 'min(420px, calc(100vw - 32px))',
227
+ background: tokens.cardBackground,
228
+ borderRadius: '12px',
229
+ overflow: 'hidden',
230
+ boxShadow: tokens.cardShadow,
231
+ });
232
+ const iframe = document.createElement('iframe');
233
+ // Thread the theme on the iframe URL (not the bridge `init` payload) so
234
+ // the embed page pins its `color-scheme` before it paints, avoiding a
235
+ // wrong-theme flash while the handshake is in flight. Omit the `'light'`
236
+ // default to keep the URL stable for the no-config path.
237
+ if (theme === 'light') {
238
+ iframe.src = `${baseUrl}/embed/${surface}`;
239
+ }
240
+ else {
241
+ const iframeUrl = new URL(`${baseUrl}/embed/${surface}`);
242
+ iframeUrl.searchParams.set('theme', theme);
243
+ iframe.src = iframeUrl.toString();
244
+ }
245
+ iframe.title = 'Brass';
246
+ // The dialogs offer copy-to-clipboard actions (the share surface's
247
+ // document link); a cross-origin iframe only gets the Clipboard API
248
+ // when its embedder delegates it, so writeText throws NotAllowedError
249
+ // without this.
250
+ iframe.allow = 'clipboard-write';
251
+ // `colorScheme` + `background` on the iframe ELEMENT defend against a
252
+ // flash through the OS color while the embed HTML + JS loads: until the
253
+ // embed page's `applyEmbedTheme()` pins `color-scheme` on its own
254
+ // `<html>`, the browser paints the iframe's default background from the OS
255
+ // `prefers-color-scheme` (a black flash on a dark-OS phone opening a
256
+ // light modal). Pinning here paints the resolved color before any request
257
+ // leaves the wire. `'auto'` follows OS preference, matching the embed page
258
+ // once loaded.
259
+ Object.assign(iframe.style, {
260
+ display: 'block',
261
+ border: '0',
262
+ width: '100%',
263
+ height: `${INITIAL_IFRAME_HEIGHT}px`,
264
+ colorScheme: theme === 'auto' ? 'light dark' : resolvedChrome,
265
+ background: tokens.cardBackground,
266
+ });
267
+ const closeButton = document.createElement('button');
268
+ closeButton.type = 'button';
269
+ closeButton.setAttribute('aria-label', 'Close');
270
+ closeButton.textContent = '×';
271
+ Object.assign(closeButton.style, {
272
+ position: 'absolute',
273
+ top: '6px',
274
+ right: '8px',
275
+ width: '28px',
276
+ height: '28px',
277
+ border: '0',
278
+ borderRadius: '6px',
279
+ background: 'transparent',
280
+ color: tokens.closeButtonColor,
281
+ fontSize: '20px',
282
+ lineHeight: '1',
283
+ cursor: 'pointer',
284
+ });
285
+ card.appendChild(closeButton);
286
+ card.appendChild(iframe);
287
+ // Loading spinner over the iframe until the `ready` handshake. Without it
288
+ // the user sees a blank panel for ~300-1000ms while the iframe boots,
289
+ // reading as "the click did nothing". It can't sit BEHIND the iframe
290
+ // because the iframe paints its own background synchronously (the
291
+ // OS-color-flash defense above), so it goes on top and hides on `ready`.
292
+ const loader = document.createElement('div');
293
+ Object.assign(loader.style, {
294
+ position: 'absolute',
295
+ inset: '0',
296
+ display: 'flex',
297
+ alignItems: 'center',
298
+ justifyContent: 'center',
299
+ background: tokens.cardBackground,
300
+ zIndex: '1',
301
+ // Brief fade so the spinner-to-content transition isn't abrupt.
302
+ transition: 'opacity 120ms ease-out',
303
+ });
304
+ const spinner = document.createElement('div');
305
+ Object.assign(spinner.style, {
306
+ width: '32px',
307
+ height: '32px',
308
+ borderRadius: '50%',
309
+ border: `3px solid ${tokens.spinnerTrack}`,
310
+ borderTopColor: tokens.spinnerAccent,
311
+ boxSizing: 'border-box',
312
+ });
313
+ // Web Animations API rather than a `<style>` @keyframes, so no CSS is
314
+ // injected into the host page (which would need scoping to avoid leaking).
315
+ if (typeof spinner.animate === 'function') {
316
+ spinner.animate([{ transform: 'rotate(0deg)' }, { transform: 'rotate(360deg)' }], { duration: 800, iterations: Infinity, easing: 'linear' });
317
+ }
318
+ loader.appendChild(spinner);
319
+ card.appendChild(loader);
320
+ overlay.appendChild(card);
321
+ let settled = false;
322
+ let readyTimer;
323
+ // Where to put the reader back. Dismissal detaches the dialog rather than
324
+ // closing it in place, so this is the launcher's job; a reader left on
325
+ // `<body>` resumes tabbing from the top of the page instead of from the
326
+ // control that opened the surface.
327
+ const opener = resolveOpener();
328
+ function restoreFocus() {
329
+ if (!opener?.isConnected)
330
+ return;
331
+ opener.focus();
332
+ if (document.activeElement === opener)
333
+ return;
334
+ // A host that disabled its trigger for the life of the dialog re-enables
335
+ // it when the launcher settles, which happens after this runs, so the
336
+ // press above landed on a disabled control and did nothing. Try again
337
+ // past the caller's own reaction, and only while nothing has taken focus
338
+ // since, so a host that moves focus itself on the result keeps it.
339
+ setTimeout(() => {
340
+ const active = document.activeElement;
341
+ if (active !== null && active !== document.body)
342
+ return;
343
+ if (opener.isConnected)
344
+ opener.focus();
345
+ }, 0);
346
+ }
347
+ function teardown() {
348
+ window.removeEventListener('message', onMessage);
349
+ window.removeEventListener('keydown', onKeydown, true);
350
+ overlay.removeEventListener('cancel', onCancel);
351
+ if (readyTimer !== undefined)
352
+ clearTimeout(readyTimer);
353
+ if (overlay.open)
354
+ overlay.close();
355
+ overlay.remove();
356
+ restoreFocus();
357
+ }
358
+ function settleResolve(value) {
359
+ if (settled)
360
+ return;
361
+ settled = true;
362
+ teardown();
363
+ resolve(value);
364
+ }
365
+ function settleReject(error) {
366
+ if (settled)
367
+ return;
368
+ settled = true;
369
+ teardown();
370
+ reject(error);
371
+ }
372
+ function onMessage(event) {
373
+ // Origin + source pinning: only the embed page we opened, in the
374
+ // dashboard origin, can drive this handshake.
375
+ if (event.origin !== dashboardOrigin)
376
+ return;
377
+ if (event.source !== iframe.contentWindow)
378
+ return;
379
+ const data = event.data;
380
+ if (!data || data[ENVELOPE] !== PROTOCOL_VERSION)
381
+ return;
382
+ switch (data['type']) {
383
+ case 'ready': {
384
+ if (readyTimer !== undefined)
385
+ clearTimeout(readyTimer);
386
+ iframe.contentWindow?.postMessage({ [ENVELOPE]: PROTOCOL_VERSION, type: 'init', surface, params }, dashboardOrigin);
387
+ // Embed page mounted; fade the loader out (delayed removal so the
388
+ // last 120ms is a fade, not a snap).
389
+ loader.style.opacity = '0';
390
+ window.setTimeout(() => {
391
+ loader.style.display = 'none';
392
+ }, 150);
393
+ // Hand the keyboard to the embed now that there is something inside
394
+ // to receive it: opening the dialog leaves focus on the close
395
+ // button, which is the right place to wait while the frame boots and
396
+ // the wrong place to stay once it has. Only when the reader has not
397
+ // moved in the meantime, since taking focus back off a control they
398
+ // tabbed to during the load is worse than leaving it where it is.
399
+ if (document.activeElement === closeButton)
400
+ iframe.focus();
401
+ break;
402
+ }
403
+ case 'resize': {
404
+ const height = data['height'];
405
+ if (typeof height === 'number' && Number.isFinite(height)) {
406
+ const clamped = Math.min(MAX_IFRAME_HEIGHT, Math.max(MIN_IFRAME_HEIGHT, Math.round(height)));
407
+ iframe.style.height = `${clamped}px`;
408
+ }
409
+ break;
410
+ }
411
+ case 'export-request': {
412
+ // The Download button asks this host to serialise its open doc. The
413
+ // docId is the surface's own param, not the child's to choose, so
414
+ // the app never serialises a doc the page wasn't opened for. The
415
+ // dialog stays open; do not settle the promise.
416
+ const requestId = data['requestId'];
417
+ const docId = typeof params['docId'] === 'string' ? params['docId'] : '';
418
+ const reply = (outcome) => {
419
+ iframe.contentWindow?.postMessage({ [ENVELOPE]: PROTOCOL_VERSION, type: 'export-result', requestId, ...outcome }, dashboardOrigin);
420
+ };
421
+ if (!onExportRequest) {
422
+ reply({ ok: false, code: 'no_serializer', message: 'Export is not supported.' });
423
+ break;
424
+ }
425
+ void onExportRequest(docId).then(reply, (err) => {
426
+ reply({
427
+ ok: false,
428
+ code: 'serializer_failed',
429
+ message: err instanceof Error ? err.message : 'Serializer failed.',
430
+ });
431
+ });
432
+ break;
433
+ }
434
+ case 'request-sign-in': {
435
+ // The user clicked the refusal surface's sign-in action. Settle the
436
+ // launcher first (this dialog is over; the host is about to
437
+ // navigate) so the caller's promise does not dangle on a page that
438
+ // is going away, then run the host's sign-in.
439
+ settleResolve(null);
440
+ onSignInRequested?.();
441
+ break;
442
+ }
443
+ case 'document-deleted':
444
+ case 'document-delete-failed': {
445
+ // The delete surface reports that the local user is deleting a doc
446
+ // (or that the attempt failed). Fire-and-forget: the dialog settles
447
+ // separately through its own `result` / `cancel`, so do not settle
448
+ // the promise here.
449
+ const docId = data['docId'];
450
+ if (onLocalDelete && typeof docId === 'string' && docId) {
451
+ onLocalDelete(docId, data['type'] === 'document-deleted');
452
+ }
453
+ break;
454
+ }
455
+ case 'result': {
456
+ settleResolve({ payload: data['payload'] });
457
+ break;
458
+ }
459
+ case 'cancel': {
460
+ settleResolve(null);
461
+ break;
462
+ }
463
+ case 'error': {
464
+ const message = typeof data['message'] === 'string' && data['message']
465
+ ? data['message']
466
+ : 'The hosted UI could not be opened.';
467
+ settleReject(new BrassError(message, { code: 'embed_error' }));
468
+ break;
469
+ }
470
+ default:
471
+ break;
472
+ }
473
+ }
474
+ // The close button dismisses. A backdrop click does NOT: these surfaces
475
+ // hold in-progress work (connecting a drive, sharing, picking a
476
+ // destination), so a stray click outside the card must not throw it away.
477
+ // Dismissal is the explicit close button or Escape only.
478
+ closeButton.addEventListener('click', () => settleResolve(null));
479
+ // Escape dismisses too, matching the close button. This host-side
480
+ // listener fires for an Escape landing on the modal chrome (the close
481
+ // button) or the host page behind it; an Escape pressed while focus is
482
+ // inside the cross-origin iframe never reaches the host, so the embed
483
+ // page relays it as a `cancel` over the bridge. Capture phase so the
484
+ // foreground modal wins over any Escape handler the host app registered.
485
+ function onKeydown(event) {
486
+ if (event.key === 'Escape' && !settled) {
487
+ event.stopPropagation();
488
+ settleResolve(null);
489
+ }
490
+ }
491
+ window.addEventListener('keydown', onKeydown, true);
492
+ // An open dialog answers Escape itself, and closing it that way would
493
+ // leave the launcher's promise unsettled. Route it into the one dismissal
494
+ // path instead; `onKeydown` above normally gets there first, so this is
495
+ // the backstop for an Escape it does not see.
496
+ function onCancel(event) {
497
+ event.preventDefault();
498
+ settleResolve(null);
499
+ }
500
+ overlay.addEventListener('cancel', onCancel);
501
+ window.addEventListener('message', onMessage);
502
+ const readyTimeoutMs = unavailable === 'redirect'
503
+ ? READY_TIMEOUT_SHORT_MS
504
+ : READY_TIMEOUT_LONG_MS;
505
+ readyTimer = setTimeout(() => {
506
+ if (unavailable !== 'reject') {
507
+ // Graceful fallback: tear down the never-loaded modal and navigate to
508
+ // the standalone page. The promise never settles (this page is about
509
+ // to be replaced).
510
+ if (settled)
511
+ return;
512
+ settled = true;
513
+ teardown();
514
+ redirectToStandalone(baseUrl, surface, params, theme);
515
+ return;
516
+ }
517
+ settleReject(new BrassError('The Brass hosted UI did not load. This browser or app context ' +
518
+ 'may block third-party iframes.', { code: 'iframe_unavailable' }));
519
+ }, readyTimeoutMs);
520
+ document.body.appendChild(overlay);
521
+ // The call that makes the dialog modal: without it the chrome paints
522
+ // identically and Tab keeps walking the host app behind it. Guarded so a
523
+ // browser without the top layer still gets the chrome, non-modal, rather
524
+ // than no dialog at all.
525
+ if (typeof overlay.showModal === 'function')
526
+ overlay.showModal();
527
+ });
528
+ }
529
+ export function createHostedUiLauncher(opts) {
530
+ const baseUrl = opts.dashboardBaseUrl.replace(/\/$/, '');
531
+ // Parsed once for the postMessage origin checks; a malformed
532
+ // `dashboardBaseUrl` throws here, at construction, not mid-handshake.
533
+ const dashboardOrigin = new URL(baseUrl).origin;
534
+ // Watch for activations from here on, so a surface opened later knows which
535
+ // control the reader pressed to get there.
536
+ trackHostedUiActivations();
537
+ // Default `'light'`: most hosts are light-only, and an `'auto'` default
538
+ // would put a dark embed inside a light host on a dark-OS user.
539
+ const theme = opts.theme ?? 'light';
540
+ // Merge the SDK's fallback session pointer into a surface's bridge params
541
+ // when this browser blocked the cross-site cookie (so the embed page can
542
+ // refresh the dashboard's tokens without its own framed cookie). Read at
543
+ // open time, not launcher-construction time, since the sid is populated by
544
+ // the first fallback refresh, which may land after the launcher is built.
545
+ // Every embed surface authenticates the same way, so this is cross-cutting
546
+ // rather than per-surface like `appId`.
547
+ const withSession = (params) => {
548
+ const sid = opts.getSessionToken?.();
549
+ const actingSub = opts.getActingSub?.();
550
+ return {
551
+ ...params,
552
+ ...(sid ? { sessionToken: sid } : {}),
553
+ ...(actingSub ? { actingSub } : {}),
554
+ };
555
+ };
556
+ // Open a hosted surface and unwrap its bridge outcome: `null` is a user
557
+ // dismissal, otherwise the payload. `exportDoc` threads the export
558
+ // serializer for the document surfaces whose page offers a Download
559
+ // action.
560
+ const launch = async (surface, params, unavailable, exportDoc) => {
561
+ const outcome = await openEmbed(baseUrl, dashboardOrigin, surface, withSession(params), unavailable, theme, exportDoc ? opts.serializeForExport : undefined,
562
+ // Only the document-settings / share surface deletes; harmless to pass
563
+ // for all, since the message arrives only from that surface.
564
+ opts.onLocalDelete, opts.onSignInRequested);
565
+ if (!outcome)
566
+ return null;
567
+ return outcome.payload;
568
+ };
569
+ // Every Brass app shares one sign-in client, so a request's JWT
570
+ // does not say which app made it. Surfaces that must name the caller
571
+ // (its scopes on the install screen, its documents in the picker, the app
572
+ // a share link should open in) read this off the bridge instead.
573
+ const callerAppParam = () => opts.callingAppId !== undefined ? { appId: opts.callingAppId } : {};
574
+ return {
575
+ async selectDocument() {
576
+ // Thread the calling app id over the bridge (every Brass app shares one
577
+ // sign-in client, so the origin can't otherwise tell which
578
+ // app opened the picker). The embed page uses it for a "only documents
579
+ // this app can open" filter.
580
+ const params = {};
581
+ if (opts.callingAppId !== undefined)
582
+ params['appId'] = opts.callingAppId;
583
+ // Where the iframe can't be hosted (cookie-partitioning browsers), the
584
+ // picker falls back to a full-page redirect: the standalone page returns
585
+ // the pick on these one-shot params, which the SDK consumes on boot to
586
+ // open the document, first-party and top-level, the context the iframe
587
+ // fails in. A returned value can't survive that navigation, so the raw
588
+ // promise resolves only on the in-iframe path; the pick otherwise
589
+ // arrives through the session.
590
+ //
591
+ // That fallback ejects the user from the host app, so the picker takes
592
+ // the long handshake window (`redirect-with-value`): degrading early on
593
+ // a slow-but-working iframe costs the user their place for nothing.
594
+ const pickReturn = opts.pickReturn;
595
+ if (pickReturn) {
596
+ params['docParam'] = pickReturn.docParam;
597
+ params['branchParam'] = pickReturn.branchParam;
598
+ }
599
+ return launch('select-document', params, pickReturn ? 'redirect-with-value' : 'reject');
600
+ },
601
+ async selectCreateDestination(options) {
602
+ // The `select-document` surface opened on its New panel via
603
+ // `intent: 'create'`: the embed page resolves a destination and sends
604
+ // it back instead of creating the doc, so the create stays on the SDK's
605
+ // credential path. `name` pre-fills the dialog.
606
+ const params = { intent: 'create' };
607
+ if (opts.callingAppId !== undefined)
608
+ params['appId'] = opts.callingAppId;
609
+ if (options?.name !== undefined)
610
+ params['name'] = options.name;
611
+ return launch('select-document', params, 'reject');
612
+ },
613
+ async acquireOrganization() {
614
+ const params = {};
615
+ if (opts.callingAppId !== undefined)
616
+ params['appId'] = opts.callingAppId;
617
+ return launch('acquire-organization', params, 'reject');
618
+ },
619
+ async shareDocument(docId) {
620
+ // The share-focused dialog, on the same surface `documentSettings`
621
+ // reaches with `focus: 'share'`. It discards the outcome, so it passes
622
+ // no `canOpenCopy`: that flag says the caller will route a duplicate's
623
+ // `newDocId` into `openSiblingDoc`, and this one cannot.
624
+ await launch('share-document', { ...callerAppParam(), docId, focus: 'share' }, 'redirect', true);
625
+ },
626
+ async documentSettings(docId, options) {
627
+ // The embed page reads `focus` from `init.params`. Defaults to
628
+ // `'share'` so passing only docId matches the `shareDocument(docId)`
629
+ // surface.
630
+ const focus = options?.focus ?? 'share';
631
+ return launch(
632
+ // The surface names one dialog with two entry points, and it is what
633
+ // the overlay carries as `data-brass-hosted-ui`, so it follows what
634
+ // is on screen rather than which method was called. A host that
635
+ // selects the share dialog matches it whether the app opened it or
636
+ // the toolbar's Share did.
637
+ focus === 'share' ? 'share-document' : 'document-settings',
638
+ // `canOpenCopy` tells the surface this caller routes a duplicate's
639
+ // `newDocId` into `openSiblingDoc`. The hosted page is served
640
+ // fresh against pinned SDKs, so it offers the Open only to a host
641
+ // that says it will honour it.
642
+ { ...callerAppParam(), docId, focus, canOpenCopy: true }, 'redirect', true);
643
+ },
644
+ async documentHistory(docId, options) {
645
+ const params = { docId };
646
+ if (options?.branch !== undefined)
647
+ params['branch'] = options.branch;
648
+ return launch('document-history', params, 'reject');
649
+ },
650
+ async selectBranch(docId, options) {
651
+ const params = { docId };
652
+ if (options?.branch !== undefined)
653
+ params['branch'] = options.branch;
654
+ return launch('select-branch', params, 'reject');
655
+ },
656
+ async installApp(organizationId) {
657
+ // Thread the caller's `appId` over the bridge so the embed page renders
658
+ // the right app's name + scopes (every Brass app shares one sign-in
659
+ // user-pool client, so the JWT can't disambiguate). On confirm the
660
+ // embed page POSTs the install server-side and forwards the response.
661
+ const params = {};
662
+ if (opts.callingAppId !== undefined)
663
+ params['appId'] = opts.callingAppId;
664
+ if (organizationId !== undefined)
665
+ params['organizationId'] = organizationId;
666
+ return launch('install-app', params, 'reject');
667
+ },
668
+ async driveResolve(docId) {
669
+ return launch('drive-resolve', { docId }, 'reject');
670
+ },
671
+ };
672
+ }
673
+ //# sourceMappingURL=hosted-ui.js.map