@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,1205 @@
1
+ // The SDK-rendered document chrome, as composable pieces. The SDK owns the
2
+ // DOM, look, and behaviour; the app provides a container and (optionally) its
3
+ // own tool buttons.
4
+ //
5
+ // Three granularities from one implementation:
6
+ // - `createSessionToolbar` mounts the whole bar.
7
+ // - `createSessionHeader` mounts the empty shell and hands back its three
8
+ // slots (`start` / `tools` / `end`) for an app that fills them itself.
9
+ // - the per-piece mounts each render one cluster into the app's element.
10
+ //
11
+ // The header layout has ONE definition (`buildBarShell`), shared by the
12
+ // whole-bar and slot-only mounts, so a composable app gets the same
13
+ // avatar-stays-top-right wrapping the full bar has.
14
+ //
15
+ // These widgets only render chrome and route to the hosted dialogs; they
16
+ // never mint a grant themselves.
17
+ import { createVersionBarFrame, } from './version-bar.js';
18
+ const STYLE_ID = 'brass-toolbar-styles';
19
+ // Scoped, theme-aware styles, injected once per document. Every piece root
20
+ // carries `brass-ui` (theme tokens + base font, so a standalone piece styles
21
+ // itself); `.brass-bar` only adds the flex header layout. Light/dark follows
22
+ // the OS.
23
+ const CSS = `
24
+ .brass-ui { --brass-surface:#161b22; --brass-surface-2:#1f2730;
25
+ --brass-fg:#e6edf3; --brass-fg-muted:#9da7b3; --brass-border:#2a313b;
26
+ --brass-accent:#3b82f6; --brass-accent-fg:#fff; --brass-danger:#e5534b;
27
+ color:var(--brass-fg);
28
+ font:14px system-ui,-apple-system,Segoe UI,Roboto,sans-serif; box-sizing:border-box; }
29
+ @media (prefers-color-scheme: light) { .brass-ui {
30
+ --brass-surface:#fff; --brass-surface-2:#f1f3f5; --brass-fg:#1b1f24;
31
+ --brass-fg-muted:#5c636a; --brass-border:#d8dee4; --brass-danger:#cf222e; } }
32
+ .brass-ui *, .brass-bar * { box-sizing:border-box; }
33
+ /* Host apps commonly set a global button min-height for touch targets, which
34
+ overrides the avatar's height regardless of specificity and stretches the
35
+ 32px circle into an oval. Zero the inherited sizing constraints so each
36
+ widget's explicit dimensions hold. */
37
+ .brass-ui button, .brass-bar button { min-width:0; min-height:0; }
38
+ /* width:100% is load-bearing: mounted as a flex item the bar would otherwise
39
+ shrink to content width (flex stretches only the cross axis). */
40
+ .brass-bar { width:100%; display:flex; flex-wrap:wrap; align-items:center; gap:10px;
41
+ padding:8px 12px; background:var(--brass-surface);
42
+ border-bottom:1px solid var(--brass-border); }
43
+ /* flex-wrap breaks lines on each item's basis/content size, not its
44
+ shrunk-to-min size, so a wide left cluster would push the avatar onto its
45
+ own line even though both fit after shrinking. flex-basis:0 on the left
46
+ removes it from that line-fill calc, so the avatar stays on row 1 top-right
47
+ and the left ellipsizes instead; the toolbar commands wrap via
48
+ flex-basis:100%. */
49
+ .brass-bar .b-left { display:flex; align-items:center; gap:10px; order:1;
50
+ min-width:0; flex:1 1 0; overflow:hidden; }
51
+ .brass-bar .b-right { display:flex; align-items:center; gap:8px; order:2;
52
+ flex:0 0 auto; }
53
+ .brass-bar .b-tools { display:flex; align-items:center; gap:6px; flex-wrap:wrap;
54
+ order:3; flex-basis:100%; }
55
+ /* margin-left:auto pins the avatar right independent of the tools cluster:
56
+ with no doc open the empty tools cluster is hidden (updateToolsVisibility),
57
+ so nothing grows to push the avatar over and it would bunch up by the title.
58
+ It doesn't fight the centered tools: grow resolves before auto margins, so a
59
+ visible flex:1 tools cluster consumes the slack and the margin collapses. */
60
+ @media (min-width:760px) { .brass-bar .b-left { flex:0 1 auto; }
61
+ .brass-bar .b-tools { order:2; flex:1; flex-basis:auto;
62
+ justify-content:center; } .brass-bar .b-right { order:3; margin-left:auto; } }
63
+ .b-menu { display:inline-flex; }
64
+ /* A bare hamburger glyph, no button chrome (background / border / radius), so it
65
+ reads as an icon sitting beside the document title. The 36px box is only an
66
+ invisible tap target; the glyph is sized up so it holds its weight next to the
67
+ 14px title. Hover tints the glyph since there's no border to highlight. */
68
+ .b-burger { cursor:pointer; width:36px; height:36px;
69
+ display:inline-flex; align-items:center; justify-content:center; border-radius:0;
70
+ border:none; background:none;
71
+ color:var(--brass-fg); font-size:24px; line-height:1; padding:0; font-family:inherit; }
72
+ .b-burger:hover { color:var(--brass-accent); }
73
+ /* Top-layer dropdown for the file and account menus: rendered via the Popover
74
+ API with fixed JS coords so an ancestor's overflow can't clip it (the file
75
+ menu lives in the bar's overflow:hidden left cluster). */
76
+ .b-pop { position:fixed; inset:auto; margin:0; padding:6px;
77
+ list-style:none; background:var(--brass-surface); border:1px solid var(--brass-border);
78
+ border-radius:8px; display:none; flex-direction:column; gap:2px; min-width:200px;
79
+ z-index:2147483646; box-shadow:0 8px 24px rgba(0,0,0,.35); color:var(--brass-fg); }
80
+ .b-pop:popover-open { display:flex; }
81
+ .b-pop button, .b-pop a { display:block; width:100%; text-align:left;
82
+ background:transparent; border:none; color:var(--brass-fg); padding:8px 10px;
83
+ border-radius:6px; cursor:pointer; font:inherit; text-decoration:none; }
84
+ .b-pop button:hover:not(:disabled), .b-pop a:hover { background:var(--brass-surface-2); }
85
+ .b-pop button:disabled { opacity:.5; cursor:default; }
86
+ /* The rule between command families. A host app's global hr styling would
87
+ otherwise land a thick default rule in a 200px menu, so every side is
88
+ declared here. */
89
+ .b-pop .b-sep { height:0; border:none; border-top:1px solid var(--brass-border);
90
+ margin:4px 2px; }
91
+ /* [hidden] must beat the block rule above (the account menu swaps its body
92
+ between signed-in actions and the guest's "Sign in"). */
93
+ .b-pop button[hidden], .b-pop a[hidden], .b-pop .b-email[hidden] { display:none; }
94
+ .b-identity { display:flex; flex-direction:column; align-items:flex-start;
95
+ gap:1px; min-width:0; }
96
+ /* Cancels the horizontal padding the title and location carry for their focus
97
+ rings, so the identity block's text still lines up where it would with none.
98
+ Safe inside the bar, where the burger and its 10px gap sit to the left. */
99
+ .brass-bar .b-identity { margin-left:-3px; }
100
+ .b-titlerow { display:flex; align-items:center; gap:6px;
101
+ max-width:44vw; min-width:0; overflow:hidden; }
102
+ /* The title and location are chrome-stripped text buttons (no background /
103
+ border). A host app's global button border-radius still bleeds in, and the
104
+ title's overflow:hidden (for ellipsis) makes that radius clip the glyph
105
+ corners into a rounded nibble; zero it so the text renders whole. */
106
+ .b-title { font-weight:600; font-size:14px; line-height:1.2; color:var(--brass-fg);
107
+ flex:0 1 auto; min-width:3em; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
108
+ background:none; border:none; border-radius:0; padding:1px 3px; cursor:pointer; font-family:inherit; }
109
+ .b-title:hover { text-decoration:underline; text-underline-offset:2px; }
110
+ .b-branch { flex:0 0 auto; max-width:12em; font-size:11px; font-weight:600;
111
+ line-height:1.4; color:var(--brass-accent); background:var(--brass-surface-2);
112
+ border:1px solid var(--brass-accent); border-radius:6px; padding:0 6px;
113
+ margin:0; cursor:pointer; font-family:inherit; overflow:hidden;
114
+ text-overflow:ellipsis; white-space:nowrap; }
115
+ .b-branch:hover { background:var(--brass-accent); color:var(--brass-accent-fg); }
116
+ .b-loc { font-size:11px; line-height:1.2; color:var(--brass-fg-muted);
117
+ background:none; border:none; border-radius:0; padding:1px 3px; min-height:0; cursor:pointer; white-space:nowrap;
118
+ font-family:inherit; }
119
+ .b-loc:hover { color:var(--brass-fg); }
120
+ .b-loc.filed::before { content:'📁 '; }
121
+ .b-loc.loose { text-decoration:underline dashed; text-underline-offset:2px; }
122
+ .b-loc.loose::before { content:'+ '; }
123
+ .b-undo-redo { display:inline-flex; align-items:center; gap:6px; }
124
+ .b-tool { background:var(--brass-surface-2); color:var(--brass-fg);
125
+ border:1px solid var(--brass-border); border-radius:8px; padding:8px 12px; cursor:pointer; font:inherit; }
126
+ .b-tool:disabled { opacity:.5; cursor:default; }
127
+ .b-roster { display:flex; gap:4px; }
128
+ .b-pip { flex:0 0 auto; width:24px; height:24px; border-radius:50%; display:inline-flex;
129
+ align-items:center; justify-content:center; color:#fff; font-size:11px; font-weight:600;
130
+ overflow:hidden; }
131
+ /* A collaborator's avatar fills the pip; object-fit:cover keeps it
132
+ undistorted. Absent or broken, the pip falls back to its coloured initial. */
133
+ .b-pip-img { width:100%; height:100%; object-fit:cover; display:block; }
134
+ /* flex:0 0 auto so a tight header row never shrinks the avatar's width below
135
+ its fixed height; pairing that with the host-button reset above keeps it a
136
+ true circle (equal width + height) regardless of host CSS or available
137
+ width. */
138
+ .b-avatar { flex:0 0 auto; cursor:pointer; width:32px; height:32px; border-radius:50%; border:none;
139
+ display:inline-flex; align-items:center; justify-content:center; background:var(--brass-accent);
140
+ color:var(--brass-accent-fg); font-size:13px; font-weight:600; padding:0; font-family:inherit;
141
+ overflow:hidden; }
142
+ /* The uploaded avatar image fills the circle; object-fit:cover keeps it
143
+ undistorted. It sits over the monogram, which stays in the DOM as the
144
+ fallback shown when no image is set or the image fails to load. */
145
+ .b-avatar-img { width:100%; height:100%; object-fit:cover; display:block; }
146
+ .b-avatar-img[hidden], .b-avatar-initial[hidden] { display:none; }
147
+ .b-email { color:var(--brass-fg-muted); font-size:12px; padding:2px 4px; word-break:break-all; }
148
+ /* Drive sync: a dim link icon at rest, accent (or danger) with a dot when
149
+ there is something to do. Clicking opens a top-layer Popover (never clipped
150
+ by the header overflow, light-dismissed on outside tap). */
151
+ .b-drive { display:inline-flex; }
152
+ .b-drive[hidden] { display:none; }
153
+ .b-drive-icon { position:relative; cursor:pointer; width:30px; height:30px;
154
+ display:inline-flex; align-items:center; justify-content:center; border-radius:8px;
155
+ background:transparent; border:none; padding:0; color:var(--brass-fg-muted); }
156
+ .b-drive-icon:hover { background:var(--brass-surface-2); color:var(--brass-fg); }
157
+ .b-drive.has-changes .b-drive-icon { color:var(--brass-accent); }
158
+ .b-drive.is-broken .b-drive-icon { color:var(--brass-danger); }
159
+ .b-drive-dot { display:none; position:absolute; top:5px; right:5px; width:7px; height:7px;
160
+ border-radius:50%; background:var(--brass-accent); border:1.5px solid var(--brass-surface); }
161
+ .b-drive.has-changes .b-drive-dot, .b-drive.is-broken .b-drive-dot { display:block; }
162
+ .b-drive.is-broken .b-drive-dot { background:var(--brass-danger); }
163
+ /* Top-layer popover, fixed JS coords clamped to the viewport (inset:auto
164
+ undoes the UA centering). Default display:none keeps it hidden on the
165
+ fallback path; the two show rules stay SEPARATE so an engine without
166
+ :popover-open still honors .b-drive-open. */
167
+ .b-drive-pop { position:fixed; margin:0; inset:auto; padding:6px;
168
+ background:var(--brass-surface); border:1px solid var(--brass-border); border-radius:8px;
169
+ display:none; flex-direction:column; gap:2px; z-index:2147483646;
170
+ min-width:220px; max-width:min(320px, calc(100vw - 16px));
171
+ box-shadow:0 8px 24px rgba(0,0,0,.35); color:var(--brass-fg); }
172
+ .b-drive-pop:popover-open { display:flex; }
173
+ .b-drive-pop.b-drive-open { display:flex; }
174
+ .b-drive-pop button { width:100%; text-align:left; background:transparent; border:none;
175
+ color:var(--brass-fg); padding:8px 10px; border-radius:6px; cursor:pointer;
176
+ font:inherit; font-size:13px; }
177
+ .b-drive-pop button:hover { background:var(--brass-surface-2); }
178
+ .b-drive-head { display:flex; align-items:baseline; gap:8px; padding:6px 10px 2px; }
179
+ .b-drive-source { font-size:12px; color:var(--brass-accent); text-decoration:none; white-space:nowrap; }
180
+ .b-drive-source:hover { text-decoration:underline; }
181
+ .b-drive-detail { margin:0; padding:0 10px 6px; font-size:12px; color:var(--brass-fg-muted); }
182
+ /* Historical-version preview bar: a full-width strip the toolbar shows below
183
+ the bar whenever the active doc is previewing an earlier version. The strip
184
+ itself is the platform-served version-bar iframe (the SDK owns only this
185
+ container + the load placeholder); it is collapsed (display:none) unless
186
+ previewing. The placeholder reserves the strip's height during the iframe
187
+ load so there is no white flash or layout jump. Neutral surface tones
188
+ (theme-aware via the toolbar tokens) so the load moment reads as chrome,
189
+ not an alert. */
190
+ .b-version { width:100%; display:none; }
191
+ .b-version.b-version-on { display:block; }
192
+ .b-version-loading { display:flex; align-items:center; width:100%;
193
+ min-height:44px; padding:0 14px; font-size:13px;
194
+ background:var(--brass-surface-2); color:var(--brass-fg-muted);
195
+ border-bottom:1px solid var(--brass-border); }
196
+ .b-version iframe { display:block; }
197
+ /* Keyboard focus. The bar's controls draw the ring INSIDE their own border box
198
+ (negative outline-offset), because the left cluster and the title row clip
199
+ their overflow so a long name ellipsizes: a ring outside the box falls in
200
+ that clipped strip, leaving the burger without its top and bottom edges, the
201
+ location button without its right edge, and the title, which fills the row,
202
+ without any. The chrome-stripped text buttons carry the padding the inset
203
+ ring needs to clear their glyphs. */
204
+ .b-burger:focus-visible, .b-title:focus-visible, .b-branch:focus-visible,
205
+ .b-loc:focus-visible, .b-tool:focus-visible, .b-avatar:focus-visible,
206
+ .b-drive-icon:focus-visible {
207
+ outline:2px solid var(--brass-accent); outline-offset:-2px; }
208
+ /* The avatar is filled with the accent and the branch chip is bordered with it
209
+ and fills with it on hover, so an accent ring reads there as part of the
210
+ control. Both take the foreground tone, which holds against the accent fill
211
+ and against their own surface. */
212
+ .b-avatar:focus-visible, .b-branch:focus-visible { outline-color:var(--brass-fg); }
213
+ /* A top-layer popover is clipped by nothing, so its rows keep the ring outside
214
+ the box, clear of the label. */
215
+ .b-pop button:focus-visible, .b-pop a:focus-visible,
216
+ .b-drive-pop button:focus-visible, .b-drive-source:focus-visible {
217
+ outline:2px solid var(--brass-accent); outline-offset:2px; }
218
+ `;
219
+ function injectStyles(doc) {
220
+ if (doc.getElementById(STYLE_ID))
221
+ return;
222
+ const style = doc.createElement('style');
223
+ style.id = STYLE_ID;
224
+ style.textContent = CSS;
225
+ doc.head.appendChild(style);
226
+ }
227
+ function el(tag, className) {
228
+ const node = document.createElement(tag);
229
+ if (className)
230
+ node.className = className;
231
+ return node;
232
+ }
233
+ function initialOf(user) {
234
+ const label = user.display_name ?? user.email ?? user.sub;
235
+ return label.trim().charAt(0).toUpperCase() || '?';
236
+ }
237
+ function renderCommandButton(cmd, asTool) {
238
+ const b = el('button', asTool ? 'b-tool' : undefined);
239
+ b.type = 'button';
240
+ b.textContent = cmd.label;
241
+ b.disabled = !cmd.enabled || cmd.running;
242
+ return b;
243
+ }
244
+ // Place a top-layer (position:fixed) popover under its anchor, clamped to the
245
+ // viewport so a wide one near an edge never runs off-screen. `align` picks the
246
+ // edge it lines up with the anchor: `start` (left) or `end` (right).
247
+ function positionPopover(anchor, pop, align) {
248
+ const r = anchor.getBoundingClientRect();
249
+ const margin = 8;
250
+ const pw = pop.offsetWidth || 200;
251
+ const ph = pop.offsetHeight || 0;
252
+ let left = align === 'end' ? r.right - pw : r.left;
253
+ left = Math.min(left, window.innerWidth - pw - margin);
254
+ left = Math.max(margin, left);
255
+ let top = r.bottom + 4;
256
+ // Flip above the anchor when there is no room below but room above.
257
+ if (top + ph > window.innerHeight - margin && r.top - ph - 4 >= margin) {
258
+ top = r.top - ph - 4;
259
+ }
260
+ top = Math.max(margin, Math.min(top, window.innerHeight - ph - margin));
261
+ pop.style.left = `${left}px`;
262
+ pop.style.top = `${top}px`;
263
+ }
264
+ // Unique ids per mounted menu popover (one element per id per document).
265
+ let menuPopSeq = 0;
266
+ function buildPopoverMenu(spec) {
267
+ const root = el('span', spec.rootClass);
268
+ const trigger = el('button', spec.triggerClass);
269
+ trigger.type = 'button';
270
+ const menuSemantics = spec.menuSemantics ?? true;
271
+ if (menuSemantics)
272
+ trigger.setAttribute('aria-haspopup', 'menu');
273
+ trigger.setAttribute('aria-label', spec.label);
274
+ const pop = el(spec.popTag ?? 'menu', spec.popClass ?? 'brass-ui b-pop');
275
+ pop.id = `${spec.idPrefix ?? 'brass-menu-pop'}-${++menuPopSeq}`;
276
+ pop.setAttribute('popover', 'auto');
277
+ if (menuSemantics)
278
+ pop.setAttribute('role', 'menu');
279
+ root.append(trigger, pop);
280
+ const popApi = pop;
281
+ const fallbackClass = spec.fallbackOpenClass;
282
+ const nativePopover = fallbackClass === undefined || typeof popApi.showPopover === 'function';
283
+ const reposition = () => positionPopover(trigger, pop, spec.align);
284
+ const inFallbackClass = () => fallbackClass !== undefined && pop.classList.contains(fallbackClass);
285
+ const isOpen = () => {
286
+ if (!nativePopover)
287
+ return inFallbackClass();
288
+ try {
289
+ return pop.matches(':popover-open');
290
+ }
291
+ catch {
292
+ return inFallbackClass();
293
+ }
294
+ };
295
+ function onOutside(e) {
296
+ if (!root.contains(e.target))
297
+ close();
298
+ }
299
+ function open() {
300
+ if (isOpen())
301
+ return;
302
+ if (nativePopover)
303
+ popApi.showPopover?.();
304
+ else if (fallbackClass !== undefined) {
305
+ pop.classList.add(fallbackClass);
306
+ document.addEventListener('click', onOutside, true);
307
+ }
308
+ reposition();
309
+ window.addEventListener('resize', reposition);
310
+ window.addEventListener('scroll', reposition, true);
311
+ }
312
+ // Guarded: hidePopover() throws InvalidStateError on a popover that is
313
+ // not showing (incl. a disconnected element on destroy).
314
+ function close() {
315
+ if (!isOpen())
316
+ return;
317
+ if (nativePopover)
318
+ popApi.hidePopover?.();
319
+ else if (fallbackClass !== undefined) {
320
+ pop.classList.remove(fallbackClass);
321
+ document.removeEventListener('click', onOutside, true);
322
+ }
323
+ window.removeEventListener('resize', reposition);
324
+ window.removeEventListener('scroll', reposition, true);
325
+ }
326
+ // The trigger is wired as the popover's invoker. The UA exempts an
327
+ // invoker from light-dismiss, so a click on the trigger of an OPEN
328
+ // popover closes it; a manual onclick toggle instead races light-dismiss
329
+ // (pointerdown hides the popover, then click sees it closed and reopens
330
+ // it), leaving a menu that can't be closed from its own trigger. The
331
+ // manual toggle exists only on the no-native-Popover fallback path.
332
+ if (nativePopover)
333
+ trigger.setAttribute('popovertarget', pop.id);
334
+ else
335
+ trigger.onclick = () => (isOpen() ? close() : open());
336
+ // The UA drives native open/close (invoker click, outside tap, Escape)
337
+ // without routing through open()/close(), so position on every open and
338
+ // detach the reposition listeners on close. (No-op on the fallback path,
339
+ // which has no native toggle event.)
340
+ const onToggle = (event) => {
341
+ const state = event.newState;
342
+ if (state === 'open') {
343
+ reposition();
344
+ window.addEventListener('resize', reposition);
345
+ window.addEventListener('scroll', reposition, true);
346
+ }
347
+ else if (state === 'closed') {
348
+ window.removeEventListener('resize', reposition);
349
+ window.removeEventListener('scroll', reposition, true);
350
+ }
351
+ };
352
+ pop.addEventListener('toggle', onToggle);
353
+ return {
354
+ root,
355
+ trigger,
356
+ pop,
357
+ close,
358
+ destroy() {
359
+ close();
360
+ pop.removeEventListener('toggle', onToggle);
361
+ document.removeEventListener('click', onOutside, true);
362
+ window.removeEventListener('resize', reposition);
363
+ window.removeEventListener('scroll', reposition, true);
364
+ },
365
+ };
366
+ }
367
+ // --- Pieces ----------------------------------------------------------------
368
+ function buildFileMenu(deps) {
369
+ injectStyles(document);
370
+ const menu = buildPopoverMenu({
371
+ rootClass: 'brass-ui b-menu',
372
+ triggerClass: 'b-burger',
373
+ label: 'Menu',
374
+ align: 'start',
375
+ });
376
+ menu.trigger.textContent = '☰';
377
+ const render = () => {
378
+ const rows = [];
379
+ let group = null;
380
+ for (const c of deps.session.commands) {
381
+ if (c.surface !== 'menu')
382
+ continue;
383
+ // A rule wherever the family changes: the entries that put a
384
+ // different document on screen, then the ones about this document.
385
+ if (group !== null && c.group !== group) {
386
+ const sep = el('hr', 'b-sep');
387
+ sep.setAttribute('role', 'separator');
388
+ rows.push(sep);
389
+ }
390
+ group = c.group;
391
+ const b = renderCommandButton(c, false);
392
+ b.onclick = () => {
393
+ menu.close();
394
+ void c.run();
395
+ };
396
+ rows.push(b);
397
+ }
398
+ menu.pop.replaceChildren(...rows);
399
+ };
400
+ render();
401
+ const unsub = deps.session.subscribe(render);
402
+ return {
403
+ root: menu.root,
404
+ destroy() {
405
+ unsub();
406
+ menu.destroy();
407
+ },
408
+ };
409
+ }
410
+ function buildDocumentTitle(deps) {
411
+ injectStyles(document);
412
+ const root = el('div', 'brass-ui b-identity');
413
+ const titleRow = el('div', 'b-titlerow');
414
+ const title = el('button', 'b-title');
415
+ title.type = 'button';
416
+ // A chip qualifying the title with the non-default branch. Hidden on the
417
+ // default branch, and dropped (not truncated) when the title row can't fit
418
+ // it, so the document name keeps priority. Opens the branches dialog.
419
+ const branch = el('button', 'b-branch');
420
+ branch.type = 'button';
421
+ branch.hidden = true;
422
+ branch.onclick = () => void deps.session.run('branches');
423
+ titleRow.append(title, branch);
424
+ const loc = el('button', 'b-loc');
425
+ loc.type = 'button';
426
+ loc.hidden = true;
427
+ root.append(titleRow, loc);
428
+ let lastDocId = null;
429
+ // The non-default branch label (null on the default), read by `fitBranch`
430
+ // so a resize re-evaluates the drop without a full re-render.
431
+ let branchLabel = null;
432
+ // Show the branch chip, then drop it if the title row can't fit it. Reading
433
+ // `scrollWidth` forces a sync layout, so the overflow test reflects the
434
+ // just-applied text.
435
+ function fitBranch() {
436
+ if (!branchLabel) {
437
+ branch.hidden = true;
438
+ return;
439
+ }
440
+ branch.hidden = false;
441
+ if (titleRow.scrollWidth > titleRow.clientWidth)
442
+ branch.hidden = true;
443
+ }
444
+ // The active doc's branch name when it is NOT the default, else null. Reads
445
+ // the doc's internal branch state, so it settles once the branch list loads
446
+ // (a `'branches'` subscribe tick re-renders the chip).
447
+ function activeBranchLabel() {
448
+ return deps.session.document?._currentBranchName() ?? null;
449
+ }
450
+ async function refreshLocation() {
451
+ const doc = deps.session.document;
452
+ const docId = doc?.id ?? null;
453
+ if (!docId) {
454
+ loc.hidden = true;
455
+ return;
456
+ }
457
+ try {
458
+ const record = await deps.getDocument(docId);
459
+ if (deps.session.document?.id !== docId)
460
+ return; // superseded
461
+ // The server hands back an opaque location label (rendered verbatim);
462
+ // the toolbar never composes it from project / org ids, so the
463
+ // hierarchy above the document can evolve without a client change. A
464
+ // null label is the loose (unfiled) state.
465
+ if (record.location_label !== null) {
466
+ loc.textContent = record.location_label;
467
+ loc.className = 'b-loc filed';
468
+ loc.title = 'Move or rename this document';
469
+ }
470
+ else {
471
+ loc.textContent = 'Add to project';
472
+ loc.className = 'b-loc loose';
473
+ loc.title = 'File this document in a project';
474
+ }
475
+ loc.hidden = false;
476
+ }
477
+ catch {
478
+ if (deps.session.document?.id === docId)
479
+ loc.hidden = true;
480
+ }
481
+ }
482
+ function runManage() {
483
+ void (async () => {
484
+ await deps.session.run('manage');
485
+ void refreshLocation();
486
+ })();
487
+ }
488
+ title.onclick = runManage;
489
+ loc.onclick = runManage;
490
+ const render = () => {
491
+ const session = deps.session;
492
+ const doc = session.document;
493
+ // Three states, not two. A bound doc whose name has not resolved yet
494
+ // (a fresh create the server just numbered, a create/open still settling)
495
+ // and an open in flight (`status` 'loading', `document` still null) both
496
+ // leave the name UNKNOWN: render nothing rather than assert "No document",
497
+ // the same way the session reports 'unknown' auth as a neutral state
498
+ // instead of flashing 'signed-out'. Only the genuine empty state (no doc,
499
+ // not loading) reads "No document".
500
+ const name = doc
501
+ ? doc.name
502
+ : session.status === 'loading'
503
+ ? null
504
+ : 'No document';
505
+ if (name === null) {
506
+ title.hidden = true;
507
+ }
508
+ else {
509
+ title.hidden = false;
510
+ title.textContent = name;
511
+ title.disabled = !doc;
512
+ }
513
+ if (!doc)
514
+ loc.hidden = true;
515
+ branchLabel = activeBranchLabel();
516
+ if (branchLabel) {
517
+ branch.textContent = branchLabel;
518
+ branch.title = `On branch "${branchLabel}"`;
519
+ }
520
+ fitBranch();
521
+ // Re-resolve location only when the active document changes.
522
+ const docId = doc?.id ?? null;
523
+ if (docId !== lastDocId) {
524
+ lastDocId = docId;
525
+ void refreshLocation();
526
+ }
527
+ };
528
+ render();
529
+ const unsub = deps.session.subscribe(render);
530
+ // Re-evaluate the branch-chip drop on width changes (resize, tools reflow).
531
+ // Guarded for hosts without ResizeObserver.
532
+ const ro = typeof ResizeObserver !== 'undefined'
533
+ ? new ResizeObserver(() => fitBranch())
534
+ : null;
535
+ ro?.observe(titleRow);
536
+ return {
537
+ root,
538
+ destroy() {
539
+ unsub();
540
+ ro?.disconnect();
541
+ },
542
+ };
543
+ }
544
+ function buildUndoRedo(deps) {
545
+ injectStyles(document);
546
+ const root = el('div', 'brass-ui b-undo-redo');
547
+ const render = () => {
548
+ root.replaceChildren(...deps.session.commands
549
+ .filter((c) => c.surface === 'toolbar')
550
+ .map((c) => {
551
+ const b = renderCommandButton(c, true);
552
+ b.onclick = () => void c.run();
553
+ return b;
554
+ }));
555
+ };
556
+ render();
557
+ const unsub = deps.session.subscribe(render);
558
+ return {
559
+ root,
560
+ destroy() {
561
+ unsub();
562
+ },
563
+ };
564
+ }
565
+ function buildPresenceRoster(deps) {
566
+ injectStyles(document);
567
+ const root = el('div', 'brass-ui b-roster');
568
+ let lastDocId = null;
569
+ let presenceOff = null;
570
+ const renderRoster = (participants) => {
571
+ root.replaceChildren(...participants.map((p) => {
572
+ const pip = el('span', 'b-pip');
573
+ pip.style.background = p.color;
574
+ const label = p.displayName ?? p.sub ?? '?';
575
+ pip.title = label;
576
+ const initial = label.slice(0, 1).toUpperCase() || '?';
577
+ const url = p.avatarUrl?.trim() ?? '';
578
+ if (url) {
579
+ // The collaborator's avatar fills the pip; the coloured initial
580
+ // stays the fallback, restored if the image fails to load (a
581
+ // broken / expired URL shows the initial, not a broken-image
582
+ // glyph). The pip keeps its per-sub colour as the image backdrop.
583
+ const img = el('img', 'b-pip-img');
584
+ img.alt = '';
585
+ img.addEventListener('error', () => {
586
+ pip.textContent = initial;
587
+ });
588
+ img.src = url;
589
+ pip.appendChild(img);
590
+ }
591
+ else {
592
+ pip.textContent = initial;
593
+ }
594
+ return pip;
595
+ }));
596
+ };
597
+ function bindPresence() {
598
+ presenceOff?.();
599
+ presenceOff = null;
600
+ root.replaceChildren();
601
+ const presence = deps.session.presence;
602
+ if (!presence)
603
+ return;
604
+ renderRoster(presence.roster ?? []);
605
+ const off = presence.on('change', renderRoster);
606
+ presenceOff =
607
+ typeof off === 'function' ? off : () => presence.off('change', renderRoster);
608
+ }
609
+ const render = () => {
610
+ const docId = deps.session.document?.id ?? null;
611
+ if (docId !== lastDocId) {
612
+ lastDocId = docId;
613
+ bindPresence();
614
+ }
615
+ };
616
+ render();
617
+ const unsub = deps.session.subscribe(render);
618
+ return {
619
+ root,
620
+ destroy() {
621
+ unsub();
622
+ presenceOff?.();
623
+ },
624
+ };
625
+ }
626
+ // A monochrome chain-link glyph (inherits `color` via `currentColor`). Built
627
+ // in the SVG namespace because `createElement('svg')` yields an HTML element
628
+ // that won't render.
629
+ function chainLinkIcon() {
630
+ const ns = 'http://www.w3.org/2000/svg';
631
+ const svg = document.createElementNS(ns, 'svg');
632
+ svg.setAttribute('width', '16');
633
+ svg.setAttribute('height', '16');
634
+ svg.setAttribute('viewBox', '0 0 24 24');
635
+ svg.setAttribute('fill', 'none');
636
+ svg.setAttribute('stroke', 'currentColor');
637
+ svg.setAttribute('stroke-width', '2');
638
+ svg.setAttribute('stroke-linecap', 'round');
639
+ svg.setAttribute('stroke-linejoin', 'round');
640
+ svg.setAttribute('aria-hidden', 'true');
641
+ for (const d of [
642
+ 'M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71',
643
+ 'M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.72-1.71',
644
+ ]) {
645
+ const path = document.createElementNS(ns, 'path');
646
+ path.setAttribute('d', d);
647
+ svg.append(path);
648
+ }
649
+ return svg;
650
+ }
651
+ // How long the popover holds its "Publishing..." affordance after a push
652
+ // before giving up on the synced confirmation. Long enough to cover a normal
653
+ // drive webhook round-trip.
654
+ const PUBLISH_HOLD_MS = 30000;
655
+ // The drive-sync indicator: a link icon (dim at rest, accent / danger with a
656
+ // dot when there is something to do) whose popover describes the state
657
+ // (server-authored copy, verbatim) and offers publish / resolve. The receiver
658
+ // half of `doc.pushToDrive` / `doc.resolveDrive`. Hidden only when the doc is
659
+ // not drive-linked, so a non-drive app mounts it harmlessly. A plain panel,
660
+ // not a menu, with the `.b-drive-open` class fallback for engines without
661
+ // the native Popover API.
662
+ function buildDriveSync(deps) {
663
+ injectStyles(document);
664
+ const menu = buildPopoverMenu({
665
+ rootClass: 'brass-ui b-drive',
666
+ triggerClass: 'b-drive-icon',
667
+ label: 'Drive sync',
668
+ align: 'start',
669
+ popTag: 'div',
670
+ popClass: 'brass-ui b-drive-pop',
671
+ idPrefix: 'brass-drive-pop',
672
+ menuSemantics: false,
673
+ fallbackOpenClass: 'b-drive-open',
674
+ });
675
+ const { root, pop } = menu;
676
+ root.hidden = true;
677
+ const button = menu.trigger;
678
+ button.append(chainLinkIcon());
679
+ const dot = el('span', 'b-drive-dot');
680
+ dot.setAttribute('aria-hidden', 'true');
681
+ button.append(dot);
682
+ let lastDocId = null;
683
+ let statusOff = null;
684
+ // A publish runs on a server worker and reaches `synced` only after the
685
+ // worker (and, on a file-roundtrip, the drive's importer) confirms. In that
686
+ // gap the drive state still reads `ahead`, which would flash "unpublished
687
+ // changes" / re-offer Publish. The server computes "a push is in flight"
688
+ // once and exposes it as `driveStatus.publishing` (with the progress copy on
689
+ // `detail`), so hold a steady "Publishing..." while it is true, regardless
690
+ // of which surface started the push. `publishing` is only a short local hold
691
+ // covering the gap between a click here and the first `publishing` frame
692
+ // arriving; the two combine as `isPublishing` in `render`.
693
+ let publishing = false;
694
+ let publishError = false;
695
+ let publishTimer = null;
696
+ let currentStatus = null;
697
+ const clearPublishing = () => {
698
+ publishing = false;
699
+ if (publishTimer !== null) {
700
+ clearTimeout(publishTimer);
701
+ publishTimer = null;
702
+ }
703
+ };
704
+ const render = (status) => {
705
+ currentStatus = status;
706
+ // Hidden only when the doc is not drive-linked.
707
+ if (status === null) {
708
+ root.hidden = true;
709
+ menu.close();
710
+ clearPublishing();
711
+ root.classList.remove('has-changes', 'is-broken');
712
+ pop.replaceChildren();
713
+ return;
714
+ }
715
+ // The publish settles once the doc actually reaches synced; drop the
716
+ // local hold (the server frame already has `publishing: false`).
717
+ if (status.state === 'synced') {
718
+ clearPublishing();
719
+ publishError = false;
720
+ }
721
+ root.hidden = false;
722
+ // Either a local click hold or the server's own `publishing` flag means a
723
+ // push is in flight; both suppress the `ahead` affordance.
724
+ const isPublishing = publishing || status.publishing;
725
+ const driveName = status.driveName;
726
+ const broken = status.broken;
727
+ // The icon brightens (and the dot shows) only when there is something
728
+ // to act on; a synced / unhydrated doc stays dim.
729
+ const hasChanges = !broken &&
730
+ (status.state === 'ahead' ||
731
+ status.state === 'behind' ||
732
+ status.state === 'diverged');
733
+ root.classList.toggle('is-broken', broken);
734
+ root.classList.toggle('has-changes', hasChanges);
735
+ button.title =
736
+ status.detail ??
737
+ (status.state === 'synced'
738
+ ? `In sync with ${driveName}`
739
+ : `Linked to ${driveName}`);
740
+ // Popover: a "view source" link (the drive name is the link label, so no
741
+ // separate header), a one-line state description (server-authored where
742
+ // there is one), then the commands. Rebuilt on every status change, so an
743
+ // open popover updates live.
744
+ const rows = [];
745
+ if (status.itemUrl) {
746
+ const head = el('div', 'b-drive-head');
747
+ const link = el('a', 'b-drive-source');
748
+ link.href = status.itemUrl;
749
+ link.target = '_blank';
750
+ link.rel = 'noopener noreferrer';
751
+ link.textContent = `View in ${driveName}`;
752
+ head.append(link);
753
+ rows.push(head);
754
+ }
755
+ // While the server reports the push in flight, `detail` carries its
756
+ // phase-by-phase copy (exporting, uploading, waiting on the source's
757
+ // importer); render it verbatim so the popover tracks the worker. The
758
+ // fixed line covers only the local click-hold gap before the first
759
+ // `publishing` frame arrives, when `detail` still holds resting copy.
760
+ const description = status.publishing
761
+ ? (status.detail ?? 'Publishing changes...')
762
+ : publishing
763
+ ? 'Publishing changes...'
764
+ : (status.detail ??
765
+ (status.state === 'synced'
766
+ ? `In sync with ${driveName}.`
767
+ : (status.label ?? '')));
768
+ if (description) {
769
+ const p = el('p', 'b-drive-detail');
770
+ p.textContent = description;
771
+ rows.push(p);
772
+ }
773
+ if (isPublishing) {
774
+ // Held while the publish is in flight (a local click hold, or the doc's
775
+ // pending work-status), until it reaches synced (see `isPublishing`).
776
+ const pub = el('button');
777
+ pub.type = 'button';
778
+ pub.textContent = 'Publishing...';
779
+ pub.disabled = true;
780
+ rows.push(pub);
781
+ }
782
+ else if (status.canPush) {
783
+ // `canPush` already encodes two-way + not broken + local edits, so this
784
+ // never offers a no-op publish.
785
+ const pub = el('button');
786
+ pub.type = 'button';
787
+ pub.textContent = publishError ? 'Publish failed, retry' : 'Publish changes';
788
+ pub.onclick = () => {
789
+ const doc = deps.session.document;
790
+ if (!doc)
791
+ return;
792
+ publishError = false;
793
+ publishing = true;
794
+ // Safety net: if the publish neither errors nor reaches synced (the
795
+ // webhook never confirms), release the held state so the popover
796
+ // recovers instead of hanging.
797
+ publishTimer = setTimeout(() => {
798
+ clearPublishing();
799
+ render(currentStatus);
800
+ }, PUBLISH_HOLD_MS);
801
+ render(currentStatus);
802
+ void doc.pushToDrive().catch(() => {
803
+ clearPublishing();
804
+ publishError = true;
805
+ render(currentStatus);
806
+ });
807
+ };
808
+ rows.push(pub);
809
+ }
810
+ // Resolve a divergence via the hosted take-source / keep-both surface. Not
811
+ // on a broken link (the connection is gone, so re-import can't run).
812
+ if (!isPublishing &&
813
+ !broken &&
814
+ (status.state === 'behind' || status.state === 'diverged')) {
815
+ const res = el('button');
816
+ res.type = 'button';
817
+ res.textContent = 'Resolve sync...';
818
+ res.onclick = () => {
819
+ // The hosted resolution surface is a modal, so close this popover.
820
+ menu.close();
821
+ void deps.session.document?.resolveDrive().catch(() => { });
822
+ };
823
+ rows.push(res);
824
+ }
825
+ pop.replaceChildren(...rows);
826
+ };
827
+ function bind() {
828
+ statusOff?.();
829
+ statusOff = null;
830
+ const doc = deps.session.document;
831
+ if (!doc) {
832
+ render(null);
833
+ return;
834
+ }
835
+ render(doc.driveStatus);
836
+ // `driveStatus.publishing` carries the "push in flight" signal, so a push
837
+ // started from any surface (the dashboard card, the app) flips it and the
838
+ // indicator holds "Publishing..." without a local click.
839
+ statusOff = doc.onDriveStatus(render);
840
+ }
841
+ const onSession = () => {
842
+ const docId = deps.session.document?.id ?? null;
843
+ if (docId !== lastDocId) {
844
+ lastDocId = docId;
845
+ bind();
846
+ }
847
+ };
848
+ onSession();
849
+ const unsub = deps.session.subscribe(onSession);
850
+ return {
851
+ root,
852
+ destroy() {
853
+ unsub();
854
+ statusOff?.();
855
+ clearPublishing();
856
+ menu.destroy();
857
+ },
858
+ };
859
+ }
860
+ function buildAccountMenu(deps) {
861
+ injectStyles(document);
862
+ const menu = buildPopoverMenu({
863
+ rootClass: 'brass-ui b-menu b-account',
864
+ triggerClass: 'b-avatar',
865
+ label: 'Account',
866
+ align: 'end',
867
+ });
868
+ const emailEl = el('div', 'b-email');
869
+ // Opens the dashboard in a new tab, so the user keeps the app open in the
870
+ // current one. `/home` is the authenticated landing (resolves the active
871
+ // org); the bare origin is the sign-in page.
872
+ const dashboardLink = el('a');
873
+ dashboardLink.textContent = 'Open dashboard';
874
+ dashboardLink.href = `${deps.dashboardBaseUrl}/home`;
875
+ dashboardLink.target = '_blank';
876
+ dashboardLink.rel = 'noopener noreferrer';
877
+ dashboardLink.onclick = () => menu.close();
878
+ const signOutBtn = el('button');
879
+ signOutBtn.textContent = 'Sign out';
880
+ signOutBtn.onclick = () => {
881
+ menu.close();
882
+ deps.signOut();
883
+ };
884
+ // Anonymous case: a guest opened the document through a public link with no
885
+ // Brass session. The signed-in actions (email / dashboard / sign out) are
886
+ // meaningless, so the menu offers a single "Sign in" that takes the top
887
+ // window through the app's normal sign-in and returns to this URL.
888
+ const signInBtn = el('button');
889
+ signInBtn.textContent = 'Sign in';
890
+ signInBtn.onclick = () => {
891
+ menu.close();
892
+ deps.signIn();
893
+ };
894
+ menu.pop.append(emailEl, dashboardLink, signOutBtn, signInBtn);
895
+ // The avatar trigger holds both the uploaded image and the monogram. The
896
+ // monogram is the fallback: shown when the user has no avatar image, and
897
+ // restored if the image fails to load (a broken / unreachable URL renders
898
+ // the initial, never a broken-image glyph).
899
+ const avatarImg = el('img', 'b-avatar-img');
900
+ avatarImg.alt = '';
901
+ const initialEl = el('span', 'b-avatar-initial');
902
+ menu.trigger.append(avatarImg, initialEl);
903
+ const showMonogram = () => {
904
+ avatarImg.hidden = true;
905
+ initialEl.hidden = false;
906
+ };
907
+ avatarImg.addEventListener('error', () => {
908
+ avatarImg.removeAttribute('src');
909
+ showMonogram();
910
+ });
911
+ const renderUser = (user) => {
912
+ initialEl.textContent = user ? initialOf(user) : '?';
913
+ emailEl.textContent = user ? (user.email ?? user.display_name ?? user.sub) : '';
914
+ // Swap the menu body between the signed-in actions and the guest's lone
915
+ // "Sign in": a null user is an anonymous link holder.
916
+ const signedIn = user !== null;
917
+ emailEl.hidden = !signedIn;
918
+ dashboardLink.hidden = !signedIn;
919
+ signOutBtn.hidden = !signedIn;
920
+ signInBtn.hidden = signedIn;
921
+ const url = user?.avatar_url?.trim() ?? '';
922
+ if (url) {
923
+ if (avatarImg.getAttribute('src') !== url)
924
+ avatarImg.src = url;
925
+ avatarImg.hidden = false;
926
+ initialEl.hidden = true;
927
+ }
928
+ else {
929
+ avatarImg.removeAttribute('src');
930
+ showMonogram();
931
+ }
932
+ };
933
+ renderUser(deps.getUser());
934
+ const unsub = deps.onUserChange(renderUser);
935
+ return {
936
+ root: menu.root,
937
+ destroy() {
938
+ unsub();
939
+ menu.destroy();
940
+ },
941
+ };
942
+ }
943
+ // The historical-version preview bar. When the active doc is previewing an
944
+ // earlier version (a hosted `history`-initiated checkout: `doc.readOnly` is
945
+ // true and `doc.version` is set), the SDK mounts the platform-served
946
+ // `/embed/version-bar` inline as a strip and routes its intents (Prev / Next
947
+ // checkout, return-to-latest, browse, branch-from) to the doc's internal
948
+ // methods. Delivered as a server embed so the platform owns and grows the
949
+ // bar's features with no SDK or app change; the SDK stays a thin bridge.
950
+ // Version navigation itself is hosted; this piece owns the whole preview
951
+ // surface. Apps still gate their own edit controls on `doc.readOnly`.
952
+ function buildVersionPreview(deps) {
953
+ injectStyles(document);
954
+ const root = el('div', 'brass-ui b-version');
955
+ root.setAttribute('role', 'group');
956
+ root.setAttribute('aria-label', 'Document version');
957
+ let frame = null;
958
+ // Once a preview starts (a checkout), the bar owns a browsing session that
959
+ // stays open, including after returning to the live head, until the user
960
+ // explicitly closes it. So the bar is never dismissed as a side effect of
961
+ // navigation (stepping to latest, returning to head). Reset when the user
962
+ // closes it, when the active document changes, or when no doc is open.
963
+ let browsing = false;
964
+ let lastDocId = null;
965
+ const session = deps.session;
966
+ const hideBar = () => {
967
+ root.classList.remove('b-version-on');
968
+ if (frame) {
969
+ frame.destroy();
970
+ frame = null;
971
+ }
972
+ };
973
+ const frameDeps = {
974
+ dashboardBaseUrl: deps.dashboardBaseUrl,
975
+ ...(deps.hostedUiTheme ? { theme: deps.hostedUiTheme } : {}),
976
+ ...(deps.getSessionToken ? { getSessionToken: deps.getSessionToken } : {}),
977
+ // Read the live active document at call time: a bridge intent lands
978
+ // async, so it must not close over a doc that may have been superseded.
979
+ checkoutVersion: (version) => {
980
+ void session.document?._checkoutVersion(version).catch(() => { });
981
+ },
982
+ returnToHead: () => {
983
+ // Return to the live head but keep the bar open (the browsing session
984
+ // persists); the bar re-renders in its "at latest" state with a Close
985
+ // button. Dismissal is the explicit `close` intent only.
986
+ try {
987
+ session.document?.returnToHead();
988
+ }
989
+ catch {
990
+ // frozen / no live head: leave the preview as-is
991
+ }
992
+ },
993
+ fork: (fromVersion, name) => {
994
+ void session.document?._forkFromVersion(fromVersion, name).catch(() => { });
995
+ },
996
+ browse: () => {
997
+ void session.run('history');
998
+ },
999
+ close: () => {
1000
+ // Explicit dismissal: end the browsing session and hide the bar
1001
+ // synchronously so the tap is instant. The document's own browsing
1002
+ // flag is cleared too, or the next render would latch the bar back on.
1003
+ browsing = false;
1004
+ session.document?._setVersionBrowsing(false);
1005
+ hideBar();
1006
+ },
1007
+ };
1008
+ const render = () => {
1009
+ const doc = session.document;
1010
+ const docId = doc?.id ?? null;
1011
+ // A new active document (or none) ends any prior browsing session.
1012
+ if (docId !== lastDocId) {
1013
+ lastDocId = docId;
1014
+ browsing = false;
1015
+ }
1016
+ // A live checkout (`doc.version !== null`) starts / keeps the browsing
1017
+ // session. Other read-only states (a view-only share link, a still-loading
1018
+ // doc) are not this bar's concern, and `doc.id` is null only on the offline
1019
+ // / test path the embed can't serve.
1020
+ if (doc != null &&
1021
+ docId !== null &&
1022
+ (doc.version !== null || doc.versionBrowsing)) {
1023
+ browsing = true;
1024
+ }
1025
+ // The bar stays open for the whole session, including at the live head
1026
+ // (`doc.version === null`, browsing still true), until `close`.
1027
+ if (doc != null && docId !== null && browsing) {
1028
+ root.classList.add('b-version-on');
1029
+ if (!frame)
1030
+ frame = createVersionBarFrame(root, frameDeps);
1031
+ frame.update({
1032
+ docId,
1033
+ branch: doc.branchId ?? undefined,
1034
+ version: doc.version,
1035
+ branchName: doc._currentBranchName() ?? undefined,
1036
+ });
1037
+ }
1038
+ else {
1039
+ hideBar();
1040
+ }
1041
+ };
1042
+ render();
1043
+ const unsub = session.subscribe(render);
1044
+ return {
1045
+ root,
1046
+ destroy() {
1047
+ unsub();
1048
+ frame?.destroy();
1049
+ frame = null;
1050
+ },
1051
+ };
1052
+ }
1053
+ // --- Standalone piece mounts -----------------------------------------------
1054
+ function mountPiece(container, piece) {
1055
+ container.replaceChildren(piece.root);
1056
+ return {
1057
+ destroy() {
1058
+ piece.destroy();
1059
+ container.replaceChildren();
1060
+ },
1061
+ };
1062
+ }
1063
+ // The hamburger file menu (the `session.commands` menu-surface), mounted
1064
+ // standalone into the app's own header.
1065
+ export function createFileMenu(container, deps) {
1066
+ return mountPiece(container, buildFileMenu(deps));
1067
+ }
1068
+ // The document title plus a location chip; both open the hosted location
1069
+ // editor (rename / move / file).
1070
+ export function createDocumentTitle(container, deps) {
1071
+ return mountPiece(container, buildDocumentTitle(deps));
1072
+ }
1073
+ // The toolbar-surface commands (undo / redo) as prominent buttons.
1074
+ export function createUndoRedo(container, deps) {
1075
+ return mountPiece(container, buildUndoRedo(deps));
1076
+ }
1077
+ // The presence roster (coloured initials of who's on the active doc).
1078
+ // Receiver half only; the app still publishes its own state.
1079
+ export function createPresenceRoster(container, deps) {
1080
+ return mountPiece(container, buildPresenceRoster(deps));
1081
+ }
1082
+ // The drive-sync indicator (tracking state + Publish / Resolve). Renders
1083
+ // nothing until a drive-linked doc drifts from its source, so a non-drive app
1084
+ // mounts it harmlessly.
1085
+ export function createDriveSync(container, deps) {
1086
+ return mountPiece(container, buildDriveSync(deps));
1087
+ }
1088
+ // The account avatar menu (email + sign out).
1089
+ export function createAccountMenu(container, deps) {
1090
+ return mountPiece(container, buildAccountMenu(deps));
1091
+ }
1092
+ // The historical-version preview banner (return-to-live during a hosted
1093
+ // `history` preview). Renders nothing until the active doc is previewing an
1094
+ // earlier version, so an app mounts it harmlessly. The full toolbar and the
1095
+ // bare header shell both include it, so this standalone mount is only for an
1096
+ // app that builds fully custom chrome and wants the correct preview affordance.
1097
+ export function createVersionPreview(container, deps) {
1098
+ return mountPiece(container, buildVersionPreview(deps));
1099
+ }
1100
+ function buildBarShell() {
1101
+ injectStyles(document);
1102
+ const bar = el('header', 'brass-bar brass-ui');
1103
+ const start = el('div', 'b-left');
1104
+ const tools = el('div', 'b-tools');
1105
+ const end = el('div', 'b-right');
1106
+ bar.append(start, tools, end);
1107
+ // Hide the tools strip when it holds no visible content: on mobile it wraps
1108
+ // to its own row, and an empty strip would still reserve that row plus the
1109
+ // row-gap, leaving asymmetric padding. "Empty" = every child is blank (no
1110
+ // text, no element children), so an empty save-indicator span or a
1111
+ // command-less undo/redo piece both count.
1112
+ function updateToolsVisibility() {
1113
+ const allBlank = Array.from(tools.children).every((c) => (c.textContent ?? '').trim() === '' && c.childElementCount === 0);
1114
+ tools.style.display = allBlank ? 'none' : '';
1115
+ }
1116
+ // The app (and the undo/redo subscription) mutates `tools` after mount, so
1117
+ // re-evaluate visibility on any change.
1118
+ const toolsObserver = new MutationObserver(() => updateToolsVisibility());
1119
+ toolsObserver.observe(tools, {
1120
+ childList: true,
1121
+ subtree: true,
1122
+ characterData: true,
1123
+ });
1124
+ updateToolsVisibility();
1125
+ return {
1126
+ bar,
1127
+ start,
1128
+ tools,
1129
+ end,
1130
+ destroy() {
1131
+ toolsObserver.disconnect();
1132
+ },
1133
+ };
1134
+ }
1135
+ // --- The composed bar ------------------------------------------------------
1136
+ export function createSessionToolbar(container, deps, options = {}) {
1137
+ const showPresence = options.presence !== false;
1138
+ const showAccount = options.account !== false;
1139
+ const showDrive = options.drive !== false;
1140
+ const shell = buildBarShell();
1141
+ // Left cluster: file menu + title/location identity + the drive-sync
1142
+ // indicator (by the title, where storage provenance belongs).
1143
+ const fileMenu = buildFileMenu(deps);
1144
+ const titlePiece = buildDocumentTitle(deps);
1145
+ shell.start.append(fileMenu.root, titlePiece.root);
1146
+ const pieces = [fileMenu, titlePiece];
1147
+ if (showDrive) {
1148
+ const drivePiece = buildDriveSync(deps);
1149
+ shell.start.append(drivePiece.root);
1150
+ pieces.push(drivePiece);
1151
+ }
1152
+ // Middle: SDK toolbar-surface commands (undo/redo) + the app's slot.
1153
+ const undoRedo = buildUndoRedo(deps);
1154
+ shell.tools.append(undoRedo.root);
1155
+ pieces.push(undoRedo);
1156
+ // Right cluster: presence roster + account menu.
1157
+ if (showPresence) {
1158
+ const rosterPiece = buildPresenceRoster(deps);
1159
+ shell.end.append(rosterPiece.root);
1160
+ pieces.push(rosterPiece);
1161
+ }
1162
+ if (showAccount) {
1163
+ const accountPiece = buildAccountMenu(deps);
1164
+ shell.end.append(accountPiece.root);
1165
+ pieces.push(accountPiece);
1166
+ }
1167
+ // The historical-version preview bar sits below the bar as a full-width
1168
+ // strip: mounting the toolbar is all an app does to get correct version-
1169
+ // preview UX, so it never hand-rolls one.
1170
+ const versionPiece = buildVersionPreview(deps);
1171
+ pieces.push(versionPiece);
1172
+ container.replaceChildren(shell.bar, versionPiece.root);
1173
+ return {
1174
+ tools: shell.tools,
1175
+ destroy() {
1176
+ for (const p of pieces)
1177
+ p.destroy();
1178
+ shell.destroy();
1179
+ container.replaceChildren();
1180
+ },
1181
+ };
1182
+ }
1183
+ // The bare header shell for an app that builds its own header but wants the
1184
+ // proven layout. Mount pieces (and your own content) into the returned
1185
+ // `start` / `tools` / `end` slots; the avatar in `end` stays top-right and
1186
+ // never wraps when `tools` does.
1187
+ export function createSessionHeader(container, deps) {
1188
+ const shell = buildBarShell();
1189
+ // A header shell built with session access includes the historical-version
1190
+ // preview bar below the bar, so a composable-chrome app gets the same
1191
+ // correct preview UX the full toolbar does without a separate mount.
1192
+ const versionPiece = deps ? buildVersionPreview(deps) : null;
1193
+ container.replaceChildren(shell.bar, ...(versionPiece ? [versionPiece.root] : []));
1194
+ return {
1195
+ start: shell.start,
1196
+ tools: shell.tools,
1197
+ end: shell.end,
1198
+ destroy() {
1199
+ versionPiece?.destroy();
1200
+ shell.destroy();
1201
+ container.replaceChildren();
1202
+ },
1203
+ };
1204
+ }
1205
+ //# sourceMappingURL=session-toolbar.js.map