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