@brass-build/client 0.4.1 → 0.5.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 (323) hide show
  1. package/AGENTS.md +124 -45
  2. package/CHANGELOG.md +80 -4
  3. package/README.md +666 -179
  4. package/dist/api-fetch.d.ts +8 -0
  5. package/dist/api-fetch.d.ts.map +1 -1
  6. package/dist/api-fetch.js +35 -8
  7. package/dist/api-fetch.js.map +1 -1
  8. package/dist/batched-get.d.ts +11 -0
  9. package/dist/batched-get.d.ts.map +1 -0
  10. package/dist/batched-get.js +92 -0
  11. package/dist/batched-get.js.map +1 -0
  12. package/dist/call-service.d.ts +4 -0
  13. package/dist/call-service.d.ts.map +1 -1
  14. package/dist/call-service.js +66 -3
  15. package/dist/call-service.js.map +1 -1
  16. package/dist/client.d.ts +48 -2
  17. package/dist/client.d.ts.map +1 -1
  18. package/dist/client.js +918 -115
  19. package/dist/client.js.map +1 -1
  20. package/dist/document-checkpoints.d.ts +25 -2
  21. package/dist/document-checkpoints.d.ts.map +1 -1
  22. package/dist/document-checkpoints.js +258 -61
  23. package/dist/document-checkpoints.js.map +1 -1
  24. package/dist/document-commands.d.ts.map +1 -1
  25. package/dist/document-commands.js +5 -3
  26. package/dist/document-commands.js.map +1 -1
  27. package/dist/document-contents.d.ts +10 -0
  28. package/dist/document-contents.d.ts.map +1 -1
  29. package/dist/document-contents.js +23 -1
  30. package/dist/document-contents.js.map +1 -1
  31. package/dist/document-set-subscription.d.ts +56 -0
  32. package/dist/document-set-subscription.d.ts.map +1 -0
  33. package/dist/document-set-subscription.js +240 -0
  34. package/dist/document-set-subscription.js.map +1 -0
  35. package/dist/document-signals.d.ts +6 -0
  36. package/dist/document-signals.d.ts.map +1 -1
  37. package/dist/document-signals.js +31 -1
  38. package/dist/document-signals.js.map +1 -1
  39. package/dist/document-streams.d.ts +36 -2
  40. package/dist/document-streams.d.ts.map +1 -1
  41. package/dist/document-streams.js +69 -18
  42. package/dist/document-streams.js.map +1 -1
  43. package/dist/document-thumbnail-capture.d.ts +12 -2
  44. package/dist/document-thumbnail-capture.d.ts.map +1 -1
  45. package/dist/document-thumbnail-capture.js +103 -27
  46. package/dist/document-thumbnail-capture.js.map +1 -1
  47. package/dist/documents-namespace.d.ts +92 -10
  48. package/dist/documents-namespace.d.ts.map +1 -1
  49. package/dist/documents-namespace.js +285 -39
  50. package/dist/documents-namespace.js.map +1 -1
  51. package/dist/documents.d.ts +173 -8
  52. package/dist/documents.d.ts.map +1 -1
  53. package/dist/documents.js +1778 -310
  54. package/dist/documents.js.map +1 -1
  55. package/dist/errors.d.ts +3 -3
  56. package/dist/errors.d.ts.map +1 -1
  57. package/dist/errors.js.map +1 -1
  58. package/dist/fetch-retry.d.ts +1 -0
  59. package/dist/fetch-retry.d.ts.map +1 -1
  60. package/dist/fetch-retry.js +2 -0
  61. package/dist/fetch-retry.js.map +1 -1
  62. package/dist/file-choice.d.ts +2 -0
  63. package/dist/file-choice.d.ts.map +1 -0
  64. package/dist/file-choice.js +27 -0
  65. package/dist/file-choice.js.map +1 -0
  66. package/dist/guest-link.d.ts +18 -1
  67. package/dist/guest-link.d.ts.map +1 -1
  68. package/dist/guest-link.js +329 -1
  69. package/dist/guest-link.js.map +1 -1
  70. package/dist/handle-types.d.ts +67 -5
  71. package/dist/handle-types.d.ts.map +1 -1
  72. package/dist/handles.d.ts +15 -19
  73. package/dist/handles.d.ts.map +1 -1
  74. package/dist/handles.js +231 -76
  75. package/dist/handles.js.map +1 -1
  76. package/dist/hosted-ui.d.ts +61 -0
  77. package/dist/hosted-ui.d.ts.map +1 -1
  78. package/dist/hosted-ui.js +94 -42
  79. package/dist/hosted-ui.js.map +1 -1
  80. package/dist/import-contract.d.ts +74 -0
  81. package/dist/import-contract.d.ts.map +1 -0
  82. package/dist/import-contract.js +163 -0
  83. package/dist/import-contract.js.map +1 -0
  84. package/dist/import-guest.d.ts +9 -0
  85. package/dist/import-guest.d.ts.map +1 -0
  86. package/dist/import-guest.js +155 -0
  87. package/dist/import-guest.js.map +1 -0
  88. package/dist/import-report.d.ts +17 -0
  89. package/dist/import-report.d.ts.map +1 -0
  90. package/dist/import-report.js +7 -0
  91. package/dist/import-report.js.map +1 -0
  92. package/dist/importer-wait.d.ts +2 -0
  93. package/dist/importer-wait.d.ts.map +1 -0
  94. package/dist/importer-wait.js +5 -0
  95. package/dist/importer-wait.js.map +1 -0
  96. package/dist/importer.d.ts +18 -4
  97. package/dist/importer.d.ts.map +1 -1
  98. package/dist/importer.js +322 -105
  99. package/dist/importer.js.map +1 -1
  100. package/dist/in-development.d.ts +2 -0
  101. package/dist/in-development.d.ts.map +1 -0
  102. package/dist/in-development.js +10 -0
  103. package/dist/in-development.js.map +1 -0
  104. package/dist/index.d.ts +16 -8
  105. package/dist/index.d.ts.map +1 -1
  106. package/dist/index.js +24 -8
  107. package/dist/index.js.map +1 -1
  108. package/dist/init-keys.d.ts +1 -1
  109. package/dist/init-keys.d.ts.map +1 -1
  110. package/dist/init-keys.js +6 -1
  111. package/dist/init-keys.js.map +1 -1
  112. package/dist/input-documents.d.ts +67 -0
  113. package/dist/input-documents.d.ts.map +1 -0
  114. package/dist/input-documents.js +693 -0
  115. package/dist/input-documents.js.map +1 -0
  116. package/dist/inputs-status.d.ts +80 -0
  117. package/dist/inputs-status.d.ts.map +1 -0
  118. package/dist/inputs-status.js +460 -0
  119. package/dist/inputs-status.js.map +1 -0
  120. package/dist/inputs.d.ts +2 -0
  121. package/dist/inputs.d.ts.map +1 -0
  122. package/dist/inputs.js +793 -0
  123. package/dist/inputs.js.map +1 -0
  124. package/dist/internal.d.ts +6 -1
  125. package/dist/internal.d.ts.map +1 -1
  126. package/dist/internal.js +4 -1
  127. package/dist/internal.js.map +1 -1
  128. package/dist/join-content.d.ts +33 -0
  129. package/dist/join-content.d.ts.map +1 -0
  130. package/dist/join-content.js +114 -0
  131. package/dist/join-content.js.map +1 -0
  132. package/dist/join-stamp.d.ts +3 -0
  133. package/dist/join-stamp.d.ts.map +1 -0
  134. package/dist/join-stamp.js +12 -0
  135. package/dist/join-stamp.js.map +1 -0
  136. package/dist/manifest-drift.d.ts +9 -0
  137. package/dist/manifest-drift.d.ts.map +1 -0
  138. package/dist/manifest-drift.js +46 -0
  139. package/dist/manifest-drift.js.map +1 -0
  140. package/dist/open-document.d.ts +109 -2
  141. package/dist/open-document.d.ts.map +1 -1
  142. package/dist/open-document.js +1822 -811
  143. package/dist/open-document.js.map +1 -1
  144. package/dist/page-restore.d.ts +12 -0
  145. package/dist/page-restore.d.ts.map +1 -0
  146. package/dist/page-restore.js +23 -0
  147. package/dist/page-restore.js.map +1 -0
  148. package/dist/placement.d.ts +13 -0
  149. package/dist/placement.d.ts.map +1 -0
  150. package/dist/placement.js +34 -0
  151. package/dist/placement.js.map +1 -0
  152. package/dist/presence.d.ts +1 -0
  153. package/dist/presence.d.ts.map +1 -1
  154. package/dist/presence.js +16 -4
  155. package/dist/presence.js.map +1 -1
  156. package/dist/read-only-document.d.ts +87 -0
  157. package/dist/read-only-document.d.ts.map +1 -0
  158. package/dist/read-only-document.js +4 -0
  159. package/dist/read-only-document.js.map +1 -0
  160. package/dist/record-paging.d.ts +63 -6
  161. package/dist/record-paging.d.ts.map +1 -1
  162. package/dist/record-paging.js +122 -29
  163. package/dist/record-paging.js.map +1 -1
  164. package/dist/references.d.ts +40 -0
  165. package/dist/references.d.ts.map +1 -0
  166. package/dist/references.js +135 -0
  167. package/dist/references.js.map +1 -0
  168. package/dist/request-lane.d.ts +5 -0
  169. package/dist/request-lane.d.ts.map +1 -0
  170. package/dist/request-lane.js +48 -0
  171. package/dist/request-lane.js.map +1 -0
  172. package/dist/sealed-arrow.d.ts +170 -0
  173. package/dist/sealed-arrow.d.ts.map +1 -0
  174. package/dist/sealed-arrow.js +769 -0
  175. package/dist/sealed-arrow.js.map +1 -0
  176. package/dist/sealed-cache.d.ts +21 -0
  177. package/dist/sealed-cache.d.ts.map +1 -0
  178. package/dist/sealed-cache.js +247 -0
  179. package/dist/sealed-cache.js.map +1 -0
  180. package/dist/sealed-encode.d.ts +87 -0
  181. package/dist/sealed-encode.d.ts.map +1 -0
  182. package/dist/sealed-encode.js +876 -0
  183. package/dist/sealed-encode.js.map +1 -0
  184. package/dist/sealed-handles.d.ts +45 -0
  185. package/dist/sealed-handles.d.ts.map +1 -0
  186. package/dist/sealed-handles.js +408 -0
  187. package/dist/sealed-handles.js.map +1 -0
  188. package/dist/sealed-store.d.ts +242 -0
  189. package/dist/sealed-store.d.ts.map +1 -0
  190. package/dist/sealed-store.js +1155 -0
  191. package/dist/sealed-store.js.map +1 -0
  192. package/dist/serializer.d.ts +6 -15
  193. package/dist/serializer.d.ts.map +1 -1
  194. package/dist/session-toolbar.d.ts +20 -5
  195. package/dist/session-toolbar.d.ts.map +1 -1
  196. package/dist/session-toolbar.js +419 -67
  197. package/dist/session-toolbar.js.map +1 -1
  198. package/dist/session.d.ts +38 -1
  199. package/dist/session.d.ts.map +1 -1
  200. package/dist/session.js +562 -79
  201. package/dist/session.js.map +1 -1
  202. package/dist/sha256.d.ts +3 -0
  203. package/dist/sha256.d.ts.map +1 -0
  204. package/dist/sha256.js +74 -0
  205. package/dist/sha256.js.map +1 -0
  206. package/dist/stable-handles.d.ts +19 -0
  207. package/dist/stable-handles.d.ts.map +1 -0
  208. package/dist/stable-handles.js +193 -0
  209. package/dist/stable-handles.js.map +1 -0
  210. package/dist/staged-seal.d.ts +2 -0
  211. package/dist/staged-seal.d.ts.map +1 -0
  212. package/dist/staged-seal.js +76 -0
  213. package/dist/staged-seal.js.map +1 -0
  214. package/dist/subscriber-delivery.d.ts +8 -0
  215. package/dist/subscriber-delivery.d.ts.map +1 -0
  216. package/dist/subscriber-delivery.js +31 -0
  217. package/dist/subscriber-delivery.js.map +1 -0
  218. package/dist/tail-op-import.d.ts +10 -0
  219. package/dist/tail-op-import.d.ts.map +1 -0
  220. package/dist/tail-op-import.js +33 -0
  221. package/dist/tail-op-import.js.map +1 -0
  222. package/dist/topic-lifecycle.d.ts +6 -0
  223. package/dist/topic-lifecycle.d.ts.map +1 -1
  224. package/dist/topic-lifecycle.js +32 -2
  225. package/dist/topic-lifecycle.js.map +1 -1
  226. package/dist/trial-replace-dialog.d.ts +10 -0
  227. package/dist/trial-replace-dialog.d.ts.map +1 -0
  228. package/dist/trial-replace-dialog.js +153 -0
  229. package/dist/trial-replace-dialog.js.map +1 -0
  230. package/dist/types.d.ts +8 -1
  231. package/dist/types.d.ts.map +1 -1
  232. package/dist/undo.d.ts +60 -5
  233. package/dist/undo.d.ts.map +1 -1
  234. package/dist/undo.js +344 -75
  235. package/dist/undo.js.map +1 -1
  236. package/dist/version-bar.d.ts +1 -0
  237. package/dist/version-bar.d.ts.map +1 -1
  238. package/dist/version-bar.js +2 -1
  239. package/dist/version-bar.js.map +1 -1
  240. package/dist/version.d.ts +2 -2
  241. package/dist/version.js +1 -1
  242. package/dist/ws-client.d.ts +21 -0
  243. package/dist/ws-client.d.ts.map +1 -1
  244. package/dist/ws-client.js +192 -8
  245. package/dist/ws-client.js.map +1 -1
  246. package/dist/ws-frame-limit.d.ts +1 -0
  247. package/dist/ws-frame-limit.d.ts.map +1 -1
  248. package/dist/ws-frame-limit.js.map +1 -1
  249. package/docs/app-manifest.md +121 -17
  250. package/docs/custom-chrome.md +24 -16
  251. package/docs/hosted-ui-advanced.md +38 -6
  252. package/docs/imported-documents.md +104 -51
  253. package/docs/presence.md +16 -4
  254. package/docs/schemas.md +8 -5
  255. package/docs/services.md +6 -6
  256. package/docs/streams.md +139 -150
  257. package/docs/text-fields.md +26 -19
  258. package/package.json +5 -2
  259. package/src/api-fetch.ts +53 -3
  260. package/src/auth-flow.ts +1 -1
  261. package/src/batched-get.ts +117 -0
  262. package/src/call-service.ts +78 -3
  263. package/src/client.ts +1022 -112
  264. package/src/document-checkpoints.ts +307 -65
  265. package/src/document-commands.ts +5 -5
  266. package/src/document-contents.ts +27 -1
  267. package/src/document-set-subscription.ts +274 -0
  268. package/src/document-signals.ts +47 -5
  269. package/src/document-streams.ts +85 -16
  270. package/src/document-thumbnail-capture.ts +118 -32
  271. package/src/documents-namespace.ts +539 -84
  272. package/src/documents.ts +2017 -348
  273. package/src/errors.ts +17 -6
  274. package/src/fetch-retry.ts +5 -0
  275. package/src/file-choice.ts +26 -0
  276. package/src/guest-link.ts +362 -1
  277. package/src/handle-types.ts +73 -47
  278. package/src/handles.ts +253 -83
  279. package/src/hosted-ui.ts +211 -50
  280. package/src/import-contract.ts +227 -0
  281. package/src/import-guest.ts +193 -0
  282. package/src/import-report.ts +24 -0
  283. package/src/importer-wait.ts +4 -0
  284. package/src/importer.ts +386 -131
  285. package/src/in-development.ts +10 -0
  286. package/src/index.ts +76 -16
  287. package/src/init-keys.ts +6 -1
  288. package/src/input-documents.ts +819 -0
  289. package/src/inputs-status.ts +598 -0
  290. package/src/inputs.ts +1090 -0
  291. package/src/internal.ts +7 -0
  292. package/src/join-content.ts +135 -0
  293. package/src/join-stamp.ts +12 -0
  294. package/src/manifest-drift.ts +54 -0
  295. package/src/open-document.ts +2346 -930
  296. package/src/page-restore.ts +38 -0
  297. package/src/placement.ts +54 -0
  298. package/src/presence.ts +21 -5
  299. package/src/read-only-document.ts +133 -0
  300. package/src/record-paging.ts +147 -25
  301. package/src/references.ts +207 -0
  302. package/src/request-lane.ts +59 -0
  303. package/src/sealed-arrow.ts +843 -0
  304. package/src/sealed-cache.ts +267 -0
  305. package/src/sealed-encode.ts +1015 -0
  306. package/src/sealed-handles.ts +486 -0
  307. package/src/sealed-store.ts +1330 -0
  308. package/src/serializer.ts +8 -20
  309. package/src/session-toolbar.ts +463 -72
  310. package/src/session.ts +740 -99
  311. package/src/sha256.ts +65 -0
  312. package/src/stable-handles.ts +235 -0
  313. package/src/staged-seal.ts +90 -0
  314. package/src/subscriber-delivery.ts +37 -0
  315. package/src/tail-op-import.ts +30 -0
  316. package/src/topic-lifecycle.ts +38 -2
  317. package/src/trial-replace-dialog.ts +163 -0
  318. package/src/types.ts +6 -6
  319. package/src/undo.ts +386 -73
  320. package/src/version-bar.ts +5 -1
  321. package/src/version.ts +1 -1
  322. package/src/ws-client.ts +208 -16
  323. package/src/ws-frame-limit.ts +1 -0
@@ -41,6 +41,8 @@ export interface DocumentTitleDeps {
41
41
  // Resolve the active doc's server-authored location label (rendered
42
42
  // verbatim by the chip; null when the doc is loose).
43
43
  getDocument: (docId: string) => Promise<{ location_label: string | null }>;
44
+ // Whether the page opened its document through a `?brass_link=` link.
45
+ onLink?: () => boolean;
44
46
  }
45
47
 
46
48
  export interface UndoRedoDeps {
@@ -49,6 +51,8 @@ export interface UndoRedoDeps {
49
51
 
50
52
  export interface PresenceRosterDeps {
51
53
  session: DocumentSession;
54
+ // The signed-in person, whose other tabs and devices are not collaborators.
55
+ getUser?: () => BrassUser | null;
52
56
  }
53
57
 
54
58
  export interface DriveSyncDeps {
@@ -58,21 +62,47 @@ export interface DriveSyncDeps {
58
62
  export interface VersionPreviewDeps {
59
63
  session: DocumentSession;
60
64
  // Resolved dashboard/embed origin (no trailing slash): the version bar
61
- // iframe loads `${dashboardBaseUrl}/embed/version-bar`.
62
- dashboardBaseUrl: string;
65
+ // iframe loads `${dashboardBaseUrl}/embed/version-bar`. Called at use
66
+ // rather than passed as a value, for the reason on `AccountMenuDeps`.
67
+ dashboardBaseUrl: () => string;
68
+ // The app the bar serves, named in the embed's init like every other hosted
69
+ // surface. Read at use for the same reason as `dashboardBaseUrl`.
70
+ appId?: () => string | undefined;
63
71
  // Pins the embed's light/dark theme (matches the other hosted surfaces).
64
- hostedUiTheme?: 'light' | 'dark' | 'auto';
72
+ hostedUiTheme?: () => 'light' | 'dark' | 'auto';
73
+ }
74
+
75
+ // The strip under the bar on a document created without an account, which
76
+ // says the work will be lost and offers the sign-in that keeps it.
77
+ export interface NotSignedInDeps {
78
+ session: DocumentSession;
79
+ signIn: () => void;
80
+ // The signed-in person, when there is one. A person signed in on someone
81
+ // else's temporary work is told when it goes and offered nothing; in the
82
+ // browser that made it, `keep` claims it in place.
83
+ getUser?: () => BrassUser | null;
84
+ onUserChange?: (listener: (user: BrassUser | null) => void) => () => void;
85
+ canKeep?: () => boolean;
86
+ keep?: () => void;
65
87
  }
66
88
 
67
89
  export interface AccountMenuDeps {
68
90
  getUser: () => BrassUser | null;
91
+ // The guest's "Sign in" names what a sign-in does when the open document
92
+ // was created without an account (it keeps the document), so the menu
93
+ // reads the active document's expiry off the session when one is given.
94
+ session?: DocumentSession;
69
95
  onUserChange: (listener: (user: BrassUser | null) => void) => () => void;
70
96
  signIn: () => void;
71
97
  signOut: () => void;
72
98
  // Base URL of the Brass dashboard, for the account menu's "Open Brass
73
99
  // dashboard" link. The client's resolved dashboard origin (picks the
74
100
  // environment), with no trailing slash.
75
- dashboardBaseUrl: string;
101
+ //
102
+ // Called at use rather than passed as a value: `Brass.init()` resolves the
103
+ // origin, and a bar may be mounted before it settles, so a value read at
104
+ // mount is the default rather than the one that call resolved.
105
+ dashboardBaseUrl: () => string;
76
106
  // The organization this app is currently acting in, or null while none has
77
107
  // been resolved or the one resolved has no name yet.
78
108
  getActingOrganization?: () => { id: string; name: string } | null;
@@ -91,6 +121,7 @@ export type SessionToolbarDeps = FileMenuDeps &
91
121
  UndoRedoDeps &
92
122
  PresenceRosterDeps &
93
123
  AccountMenuDeps &
124
+ NotSignedInDeps &
94
125
  VersionPreviewDeps;
95
126
 
96
127
  export interface SessionToolbarOptions {
@@ -144,12 +175,14 @@ const STYLE_ID = 'brass-toolbar-styles';
144
175
  const CSS = `
145
176
  .brass-ui { --brass-surface:#161b22; --brass-surface-2:#1f2730;
146
177
  --brass-fg:#e6edf3; --brass-fg-muted:#9da7b3; --brass-border:#2a313b;
147
- --brass-accent:#3b82f6; --brass-accent-fg:#fff; --brass-danger:#e5534b;
178
+ --brass-accent:#3b82f6; --brass-accent-fg:#fff; --brass-accent-soft:#1c2b45;
179
+ --brass-danger:#e5534b;
148
180
  color:var(--brass-fg);
149
181
  font:14px system-ui,-apple-system,Segoe UI,Roboto,sans-serif; box-sizing:border-box; }
150
182
  @media (prefers-color-scheme: light) { .brass-ui {
151
183
  --brass-surface:#fff; --brass-surface-2:#f1f3f5; --brass-fg:#1b1f24;
152
- --brass-fg-muted:#5c636a; --brass-border:#d8dee4; --brass-danger:#cf222e; } }
184
+ --brass-fg-muted:#5c636a; --brass-border:#d8dee4; --brass-accent-soft:#e6effd;
185
+ --brass-danger:#cf222e; } }
153
186
  .brass-ui *, .brass-bar * { box-sizing:border-box; }
154
187
  /* Host apps commonly set a global button min-height for touch targets, which
155
188
  overrides the avatar's height regardless of specificity and stretches the
@@ -182,6 +215,7 @@ const CSS = `
182
215
  .brass-bar .b-tools { order:2; flex:1; flex-basis:auto;
183
216
  justify-content:center; } .brass-bar .b-right { order:3; margin-left:auto; } }
184
217
  .b-menu { display:inline-flex; }
218
+ .b-menu[hidden] { display:none; }
185
219
  /* A bare hamburger glyph, no button chrome (background / border / radius), so it
186
220
  reads as an icon sitting beside the document title. The 36px box is only an
187
221
  invisible tap target; the glyph is sized up so it holds its weight next to the
@@ -209,6 +243,10 @@ const CSS = `
209
243
  declared here. */
210
244
  .b-pop .b-sep { height:0; border:none; border-top:1px solid var(--brass-border);
211
245
  margin:4px 2px; }
246
+ /* A line of fact in a menu (when a document minted without an account
247
+ expires): the same inset as an entry, in the muted tone, and no hover. */
248
+ .b-pop .b-note { padding:8px 10px; color:var(--brass-fg-muted); font-size:12px;
249
+ line-height:1.3; }
212
250
  /* [hidden] must beat the block rule above (the account menu swaps its body
213
251
  between signed-in actions and the guest's "Sign in"). */
214
252
  .b-pop button[hidden], .b-pop a[hidden], .b-pop .b-email[hidden],
@@ -234,7 +272,7 @@ const CSS = `
234
272
  border:1px solid var(--brass-accent); border-radius:6px; padding:0 6px;
235
273
  margin:0; cursor:pointer; font-family:inherit; overflow:hidden;
236
274
  text-overflow:ellipsis; white-space:nowrap; }
237
- .b-branch:hover { background:var(--brass-accent); color:var(--brass-accent-fg); }
275
+ .b-branch:hover:not(:disabled) { background:var(--brass-accent); color:var(--brass-accent-fg); }
238
276
  .b-loc { font-size:11px; line-height:1.2; color:var(--brass-fg-muted);
239
277
  background:none; border:none; border-radius:0; padding:1px 3px; min-height:0; cursor:pointer; white-space:nowrap;
240
278
  font-family:inherit; }
@@ -242,7 +280,24 @@ const CSS = `
242
280
  .b-loc.filed::before { content:'📁 '; }
243
281
  .b-loc.loose { text-decoration:underline dashed; text-underline-offset:2px; }
244
282
  .b-loc.loose::before { content:'+ '; }
245
- .b-undo-redo { display:inline-flex; align-items:center; gap:6px; }
283
+ .b-undo-redo { display:inline-flex; align-items:center; gap:2px; }
284
+ .b-undo-compact { display:none; }
285
+ .b-tool.b-tool-icon { padding:6px; display:inline-flex; align-items:center; justify-content:center;
286
+ background:none; border-color:transparent; }
287
+ .b-tool.b-tool-icon:hover:not(:disabled) { background:var(--brass-surface-2); }
288
+ /* An icon button's name on hover or keyboard focus. A top-layer popover, so
289
+ no ancestor's overflow clips it. */
290
+ .b-tip { position:fixed; inset:auto; margin:0; padding:4px 8px; border:none;
291
+ border-radius:6px; background:var(--brass-fg); color:var(--brass-surface);
292
+ font-size:12px; line-height:1.3; white-space:nowrap; pointer-events:none;
293
+ display:none; z-index:2147483646; }
294
+ .b-tip:popover-open, .b-tip.b-tip-open { display:block; }
295
+ /* Narrow: the pair sits in the top row beside the avatar, so the tools row
296
+ holds only the app's items and collapses when it has none. */
297
+ @media (max-width:759.98px) {
298
+ .brass-bar .b-undo-compact { display:inline-flex; }
299
+ .brass-bar .b-undo-full { display:none; }
300
+ .brass-bar:not(.b-app-tools) .b-tools { display:none !important; } }
246
301
  .b-tool { background:var(--brass-surface-2); color:var(--brass-fg);
247
302
  border:1px solid var(--brass-border); border-radius:8px; padding:8px 12px; cursor:pointer; font:inherit; }
248
303
  .b-tool:disabled { opacity:.5; cursor:default; }
@@ -264,6 +319,13 @@ const CSS = `
264
319
  /* The uploaded avatar image fills the circle; object-fit:cover keeps it
265
320
  undistorted. It sits over the monogram, which stays in the DOM as the
266
321
  fallback shown when no image is set or the image fails to load. */
322
+ /* The accent-filled controls (this avatar, the guest strip's sign-in, the
323
+ branch chip) restate their fill and text colour on hover, at a
324
+ specificity above a host's own button:hover:not(:disabled) whatever order the
325
+ two stylesheets load in. A host hover rule otherwise repaints the fill with
326
+ its own (often near-white) background under this white text. */
327
+ button.b-avatar:hover:not(:disabled) { background:var(--brass-accent);
328
+ color:var(--brass-accent-fg); }
267
329
  .b-avatar-img { width:100%; height:100%; object-fit:cover; display:block; }
268
330
  .b-avatar-img[hidden], .b-avatar-initial[hidden] { display:none; }
269
331
  /* The account menu opens on the identity it acts for: the avatar again at
@@ -331,6 +393,23 @@ const CSS = `
331
393
  reports a problem, so it carries the danger tone rather than the muted one
332
394
  every other line uses. */
333
395
  .b-drive-unreadable { margin:0; padding:0 10px 6px; font-size:12px; color:var(--brass-danger, #b3261e); }
396
+ /* The strip under the bar while the open document is one the platform will
397
+ remove (created without an account): it says so and carries the sign-in
398
+ that keeps the work. Tinted with the accent so it reads as a notice about
399
+ the document rather than as more chrome; the text takes the slack and the
400
+ button holds the trailing edge until a narrow width wraps it under. */
401
+ .b-guest { width:100%; display:none; flex-wrap:wrap; align-items:center;
402
+ justify-content:space-between; gap:6px 12px; padding:8px 14px; font-size:13px;
403
+ line-height:1.4; background:var(--brass-accent-soft); color:var(--brass-fg);
404
+ border-bottom:1px solid var(--brass-border); }
405
+ .b-guest.b-guest-on { display:flex; }
406
+ .b-guest-msg { flex:1 1 auto; min-width:0; }
407
+ .b-guest button { flex:0 0 auto; background:var(--brass-accent);
408
+ color:var(--brass-accent-fg); border:none; border-radius:6px; cursor:pointer;
409
+ font:inherit; font-size:13px; font-weight:600; padding:6px 12px; }
410
+ .b-guest button:hover:not(:disabled) { background:var(--brass-accent);
411
+ color:var(--brass-accent-fg); filter:brightness(1.1); }
412
+ .b-guest button:focus-visible { outline:2px solid var(--brass-fg); outline-offset:2px; }
334
413
  /* Historical-version preview bar: a full-width strip the toolbar shows below
335
414
  the bar whenever the active doc is previewing an earlier version. The strip
336
415
  itself is the platform-served version-bar iframe (the SDK owns only this
@@ -403,6 +482,13 @@ function el<K extends keyof HTMLElementTagNameMap>(
403
482
  return node;
404
483
  }
405
484
 
485
+ // A failed publish shows the server's own sentence, which is what the route
486
+ // answers a refusal with.
487
+ function publishFailureText(err: unknown): string {
488
+ const message = err instanceof Error ? err.message.trim() : '';
489
+ return message === '' || message === 'Request failed' ? 'Publishing failed.' : message;
490
+ }
491
+
406
492
  function initialOf(user: BrassUser): string {
407
493
  const label = user.display_name ?? user.email ?? user.sub;
408
494
  return label.trim().charAt(0).toUpperCase() || '?';
@@ -453,6 +539,28 @@ function renderCommandButton(cmd: Command, asTool: boolean): HTMLButtonElement {
453
539
  return b;
454
540
  }
455
541
 
542
+ const COMMAND_ICONS: Record<string, string> = {
543
+ undo: '<path d="M9 14 4 9l5-5"/><path d="M4 9h10.5a5.5 5.5 0 0 1 0 11H11"/>',
544
+ redo: '<path d="m15 14 5-5-5-5"/><path d="M20 9H9.5a5.5 5.5 0 0 0 0 11H13"/>',
545
+ };
546
+
547
+ // A toolbar command as an icon button, named by its label for assistive
548
+ // technology and on hover. A command with no icon keeps its text.
549
+ function commandGlyph(icon: string): string {
550
+ return `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${icon}</svg>`;
551
+ }
552
+
553
+ function renderCommandIcon(cmd: Command): HTMLButtonElement {
554
+ const icon = COMMAND_ICONS[cmd.id];
555
+ if (icon === undefined) return renderCommandButton(cmd, true);
556
+ const b = el('button', 'b-tool b-tool-icon');
557
+ b.type = 'button';
558
+ b.setAttribute('aria-label', cmd.label);
559
+ b.innerHTML = commandGlyph(icon);
560
+ b.disabled = !cmd.enabled || cmd.running;
561
+ return b;
562
+ }
563
+
456
564
  // Place a top-layer (position:fixed) popover under its anchor, clamped to the
457
565
  // viewport so a wide one near an edge never runs off-screen. `align` picks the
458
566
  // edge it lines up with the anchor: `start` (left) or `end` (right).
@@ -630,9 +738,18 @@ function buildFileMenu(deps: FileMenuDeps): PieceInternal {
630
738
 
631
739
  const render = (): void => {
632
740
  const rows: HTMLElement[] = [];
741
+ const expiresAt = deps.session.document?.expiresAt ?? null;
742
+ if (expiresAt !== null) {
743
+ const note = el('div', 'b-note b-expiry');
744
+ note.textContent = expiresInLabel(expiresAt);
745
+ rows.push(note);
746
+ }
633
747
  let group: string | null = null;
634
748
  for (const c of deps.session.commands) {
635
749
  if (c.surface !== 'menu') continue;
750
+ // A session command that is not enabled is not offered, so it is left
751
+ // out rather than greyed.
752
+ if (c.group === 'session' && !c.enabled) continue;
636
753
  // A rule wherever the family changes: the entries that put a
637
754
  // different document on screen, then the ones about this document.
638
755
  if (group !== null && c.group !== group) {
@@ -649,6 +766,8 @@ function buildFileMenu(deps: FileMenuDeps): PieceInternal {
649
766
  rows.push(b);
650
767
  }
651
768
  menu.pop.replaceChildren(...rows);
769
+ if (rows.length === 0) menu.close();
770
+ menu.root.hidden = rows.length === 0;
652
771
  };
653
772
  render();
654
773
  const unsub = deps.session.subscribe(render);
@@ -726,11 +845,11 @@ function buildDocumentTitle(deps: DocumentTitleDeps): PieceInternal {
726
845
  if (record.location_label !== null) {
727
846
  loc.textContent = record.location_label;
728
847
  loc.className = 'b-loc filed';
729
- loc.title = 'Move or rename this document';
848
+ loc.title = 'Move or rename';
730
849
  } else {
731
850
  loc.textContent = 'Add to project';
732
851
  loc.className = 'b-loc loose';
733
- loc.title = 'File this document in a project';
852
+ loc.title = 'Add to a project';
734
853
  }
735
854
  loc.hidden = false;
736
855
  } catch {
@@ -753,15 +872,24 @@ function buildDocumentTitle(deps: DocumentTitleDeps): PieceInternal {
753
872
  // Three states, not two. A bound doc whose name has not resolved yet
754
873
  // (a fresh create the server just numbered, a create/open still settling)
755
874
  // and an open in flight (`status` 'loading', `document` still null) both
756
- // leave the name UNKNOWN: render nothing rather than assert "No document",
875
+ // leave the name UNKNOWN: render nothing rather than assert "Nothing open",
757
876
  // the same way the session reports 'unknown' auth as a neutral state
758
877
  // instead of flashing 'signed-out'. Only the genuine empty state (no doc,
759
- // not loading) reads "No document".
878
+ // not loading) reads "Nothing open".
879
+ // A link the app opens only for signed-in people names the refusal,
880
+ // since the account control beside it is where the visitor signs in.
881
+ const refusal =
882
+ !doc &&
883
+ session.status === 'error' &&
884
+ session.error?.code === 'sign_in_required' &&
885
+ deps.onLink?.() === true
886
+ ? session.error.message
887
+ : null;
760
888
  const name = doc
761
889
  ? doc.name
762
890
  : session.status === 'loading'
763
891
  ? null
764
- : 'No document';
892
+ : (refusal ?? 'Nothing open');
765
893
  if (name === null) {
766
894
  title.hidden = true;
767
895
  } else {
@@ -800,15 +928,96 @@ function buildDocumentTitle(deps: DocumentTitleDeps): PieceInternal {
800
928
  };
801
929
  }
802
930
 
803
- function buildUndoRedo(deps: UndoRedoDeps): PieceInternal {
931
+ // Shows the aria-label of the icon button under the pointer or keyboard
932
+ // focus inside `root`, below the button and clamped to the viewport.
933
+ // One tip per document, outside every piece, so a piece's own children stay
934
+ // what it renders.
935
+ let sharedTip: HTMLElement | null = null;
936
+ function iconTip(): HTMLElement {
937
+ if (sharedTip?.isConnected) return sharedTip;
938
+ sharedTip = el('div', 'brass-ui b-tip');
939
+ sharedTip.setAttribute('aria-hidden', 'true');
940
+ sharedTip.setAttribute('popover', 'manual');
941
+ document.body.append(sharedTip);
942
+ return sharedTip;
943
+ }
944
+
945
+ function attachIconTooltips(root: HTMLElement): { hide(): void } {
946
+ const tip = iconTip();
947
+ const api = tip as HTMLElement & { showPopover?: () => void; hidePopover?: () => void };
948
+ let shownFor: HTMLElement | null = null;
949
+ const show = (btn: HTMLElement): void => {
950
+ const label = btn.getAttribute('aria-label');
951
+ if (!label) return;
952
+ shownFor = btn;
953
+ tip.textContent = label;
954
+ if (typeof api.showPopover === 'function') {
955
+ try {
956
+ if (!tip.matches(':popover-open')) api.showPopover();
957
+ } catch {
958
+ tip.classList.add('b-tip-open');
959
+ }
960
+ } else tip.classList.add('b-tip-open');
961
+ const r = btn.getBoundingClientRect();
962
+ const w = tip.offsetWidth;
963
+ const left = Math.max(4, Math.min(r.left + r.width / 2 - w / 2, window.innerWidth - w - 4));
964
+ tip.style.left = `${left}px`;
965
+ tip.style.top = `${r.bottom + 6}px`;
966
+ };
967
+ const hide = (): void => {
968
+ shownFor = null;
969
+ tip.classList.remove('b-tip-open');
970
+ try {
971
+ if (tip.matches(':popover-open')) api.hidePopover?.();
972
+ } catch {
973
+ // Not showing.
974
+ }
975
+ };
976
+ const iconOf = (t: EventTarget | null): HTMLElement | null =>
977
+ t instanceof Element ? t.closest<HTMLElement>('.b-tool-icon') : null;
978
+ root.addEventListener('pointerover', (e) => {
979
+ const btn = iconOf(e.target);
980
+ if (btn && e.pointerType === 'mouse') show(btn);
981
+ });
982
+ root.addEventListener('pointerout', (e) => {
983
+ if (iconOf(e.target) === shownFor && iconOf(e.relatedTarget) !== shownFor) hide();
984
+ });
985
+ root.addEventListener('pointerdown', hide);
986
+ root.addEventListener('focusin', (e) => {
987
+ const btn = iconOf(e.target);
988
+ if (btn && btn.matches(':focus-visible')) show(btn);
989
+ });
990
+ root.addEventListener('focusout', hide);
991
+ return { hide };
992
+ }
993
+
994
+ // `full` and `compact` are the toolbar's two copies, which the narrow-width
995
+ // rules swap. `standalone` is the one an app mounts itself: no copy stands in
996
+ // for it at a narrow width, so those rules must not hide it.
997
+ function buildUndoRedo(
998
+ deps: UndoRedoDeps,
999
+ variant: 'full' | 'compact' | 'standalone' = 'full',
1000
+ ): PieceInternal {
804
1001
  injectStyles(document);
805
- const root = el('div', 'brass-ui b-undo-redo');
1002
+ const root = el(
1003
+ 'div',
1004
+ variant === 'standalone' ? 'brass-ui b-undo-redo' : `brass-ui b-undo-redo b-undo-${variant}`,
1005
+ );
1006
+ const tips = attachIconTooltips(root);
806
1007
  const render = (): void => {
1008
+ // The pair shows only while one of them can act: before the first edit, on
1009
+ // a read-only document, and with nothing open they would only ever show
1010
+ // disabled, and rendering none lets the bar give their row back.
1011
+ const commands = deps.session.commands.filter((c) => c.surface === 'toolbar');
1012
+ tips.hide();
1013
+ if (!commands.some((c) => c.enabled)) {
1014
+ root.replaceChildren();
1015
+ return;
1016
+ }
807
1017
  root.replaceChildren(
808
- ...deps.session.commands
809
- .filter((c) => c.surface === 'toolbar')
1018
+ ...commands
810
1019
  .map((c) => {
811
- const b = renderCommandButton(c, true);
1020
+ const b = renderCommandIcon(c);
812
1021
  b.onclick = () => void c.run();
813
1022
  return b;
814
1023
  }),
@@ -820,6 +1029,7 @@ function buildUndoRedo(deps: UndoRedoDeps): PieceInternal {
820
1029
  root,
821
1030
  destroy(): void {
822
1031
  unsub();
1032
+ tips.hide();
823
1033
  },
824
1034
  };
825
1035
  }
@@ -836,9 +1046,28 @@ function buildPresenceRoster(deps: PresenceRosterDeps): PieceInternal {
836
1046
  let boundDoc: DocumentSession['document'] = null;
837
1047
  let presenceOff: (() => void) | null = null;
838
1048
 
1049
+ // The roster lists every other connection, so the reader's own other tabs,
1050
+ // and a page they navigated away from until its socket closes, are in it.
1051
+ // A pip stands for a person: one per sub, and never the reader. A visitor
1052
+ // on a shared link has an empty sub, so each such connection is its own
1053
+ // pip.
1054
+ const collaborators = (
1055
+ participants: readonly PresenceParticipant[],
1056
+ ): PresenceParticipant[] => {
1057
+ const own = deps.getUser?.()?.sub;
1058
+ const seen = new Set<string>();
1059
+ return participants.filter((p) => {
1060
+ if (own !== undefined && p.sub === own) return false;
1061
+ const person = p.sub !== '' ? p.sub : `connection:${p.connectionId}`;
1062
+ if (seen.has(person)) return false;
1063
+ seen.add(person);
1064
+ return true;
1065
+ });
1066
+ };
1067
+
839
1068
  const renderRoster = (participants: readonly PresenceParticipant[]): void => {
840
1069
  root.replaceChildren(
841
- ...participants.map((p) => {
1070
+ ...collaborators(participants).map((p) => {
842
1071
  const pip = el('span', 'b-pip');
843
1072
  pip.style.background = p.color;
844
1073
  const label = p.displayName ?? p.sub ?? '?';
@@ -895,6 +1124,33 @@ function buildPresenceRoster(deps: PresenceRosterDeps): PieceInternal {
895
1124
  };
896
1125
  }
897
1126
 
1127
+ // The guest's account-menu entry, and its wording on a document created
1128
+ // without an account, where signing in is what keeps it.
1129
+ const SIGN_IN_LABEL = 'Sign in';
1130
+ const SIGN_IN_TO_KEEP_LABEL = 'Sign in to keep your work';
1131
+ const KEEP_LABEL = 'Keep';
1132
+
1133
+ const DAY_MS = 24 * 60 * 60 * 1000;
1134
+
1135
+ // The file menu's line for a document the platform will remove: whole days
1136
+ // until then, rounded up, so the last day still reads as one.
1137
+ export function expiresInLabel(expiresAt: Date, now: number = Date.now()): string {
1138
+ const days = Math.max(1, Math.ceil((expiresAt.getTime() - now) / DAY_MS));
1139
+ return `Your work will be lost in ${days} ${days === 1 ? 'day' : 'days'}`;
1140
+ }
1141
+
1142
+ // Someone else's temporary work, to a signed-in reader who cannot keep it.
1143
+ export function removedInLabel(expiresAt: Date, now: number = Date.now()): string {
1144
+ const days = Math.max(1, Math.ceil((expiresAt.getTime() - now) / DAY_MS));
1145
+ return `This work will be removed in ${days} ${days === 1 ? 'day' : 'days'}`;
1146
+ }
1147
+
1148
+ // The strip's sentence: the same count as the file menu's line, led by the
1149
+ // state that explains it.
1150
+ export function notSignedInLabel(expiresAt: Date, now: number = Date.now()): string {
1151
+ return `You are not signed in. ${expiresInLabel(expiresAt, now)}.`;
1152
+ }
1153
+
898
1154
  // A monochrome 16px stroke glyph (inherits `color` via `currentColor`). Built
899
1155
  // in the SVG namespace because `createElement('svg')` yields an HTML element
900
1156
  // that won't render.
@@ -992,13 +1248,23 @@ function buildDriveSync(deps: DriveSyncDeps): PieceInternal {
992
1248
  // covering the gap between a click here and the first `publishing` frame
993
1249
  // arriving; the two combine as `isPublishing` in `render`.
994
1250
  let publishing = false;
995
- let publishError = false;
1251
+ // The server's refusal of the last publish from this panel, shown where
1252
+ // the resting sentence sits until the next attempt.
1253
+ let publishError: string | null = null;
996
1254
  let publishTimer: ReturnType<typeof setTimeout> | null = null;
997
1255
  // Set on a click, cleared by the next publish-state frame. Between the two,
998
1256
  // the held state describes the PREVIOUS attempt, and rendering it would
999
1257
  // report the last failure over the publish just started.
1000
1258
  let publishStateStale = false;
1001
1259
  let currentStatus: DocumentDriveStatus | null = null;
1260
+ // The panel is asking whether to publish over the drive's newer version.
1261
+ let confirmingOverwrite = false;
1262
+ pop.addEventListener('toggle', (event) => {
1263
+ if ((event as ToggleEvent).newState === 'closed' && confirmingOverwrite) {
1264
+ confirmingOverwrite = false;
1265
+ render(currentStatus);
1266
+ }
1267
+ });
1002
1268
  const clearPublishing = (): void => {
1003
1269
  publishing = false;
1004
1270
  // The stale mark is cleared with the hold it belongs to. It is otherwise
@@ -1019,6 +1285,16 @@ function buildDriveSync(deps: DriveSyncDeps): PieceInternal {
1019
1285
  // one.
1020
1286
  const renderPublishOffer = (target: DrivePublishTarget): void => {
1021
1287
  root.hidden = false;
1288
+ if (target.unpublishable) {
1289
+ // Nothing to publish, so the icon asks for no attention; the panel
1290
+ // names the drive and says why the file cannot go to it.
1291
+ root.classList.remove('has-changes', 'is-broken');
1292
+ button.title = `Cannot publish to ${target.driveName}`;
1293
+ const p = el('p', 'b-drive-unreadable');
1294
+ p.textContent = target.detail;
1295
+ pop.replaceChildren(p);
1296
+ return;
1297
+ }
1022
1298
  // A document the drive has never received is as far out of sync as one
1023
1299
  // whose edits have not been published, so the icon carries the same
1024
1300
  // attention an `ahead` document's does. What separates them is what the
@@ -1035,7 +1311,8 @@ function buildDriveSync(deps: DriveSyncDeps): PieceInternal {
1035
1311
  : `Publish to ${target.driveName}`;
1036
1312
 
1037
1313
  const failed =
1038
- !inFlight && (state?.failed === true || target.publishFailed === true);
1314
+ !inFlight &&
1315
+ (publishError !== null || state?.failed === true || target.publishFailed === true);
1039
1316
 
1040
1317
  const rows: HTMLElement[] = [];
1041
1318
  // One line, server-authored either way: the worker's phase copy while it
@@ -1046,7 +1323,7 @@ function buildDriveSync(deps: DriveSyncDeps): PieceInternal {
1046
1323
  const p = el('p', failed ? 'b-drive-unreadable' : 'b-drive-detail');
1047
1324
  p.textContent = inFlight
1048
1325
  ? (state?.publishing === true ? (state.detail ?? 'Publishing...') : 'Publishing...')
1049
- : (state?.detail ?? target.detail);
1326
+ : (publishError ?? state?.detail ?? target.detail);
1050
1327
  rows.push(p);
1051
1328
 
1052
1329
  const pub = el('button');
@@ -1054,13 +1331,13 @@ function buildDriveSync(deps: DriveSyncDeps): PieceInternal {
1054
1331
  pub.disabled = inFlight;
1055
1332
  pub.textContent = inFlight
1056
1333
  ? 'Publishing...'
1057
- : publishError || failed
1334
+ : failed
1058
1335
  ? `Publish to ${target.driveName}, retry`
1059
1336
  : `Publish to ${target.driveName}`;
1060
1337
  pub.onclick = () => {
1061
1338
  const doc = deps.session.document;
1062
1339
  if (!doc) return;
1063
- publishError = false;
1340
+ publishError = null;
1064
1341
  publishing = true;
1065
1342
  publishStateStale = true;
1066
1343
  // The same safety net the mirror panel keeps: a publish that neither
@@ -1071,9 +1348,9 @@ function buildDriveSync(deps: DriveSyncDeps): PieceInternal {
1071
1348
  render(currentStatus);
1072
1349
  }, PUBLISH_HOLD_MS);
1073
1350
  render(currentStatus);
1074
- void doc.pushToDrive().catch(() => {
1351
+ void doc.pushToDrive().catch((err: unknown) => {
1075
1352
  clearPublishing();
1076
- publishError = true;
1353
+ publishError = publishFailureText(err);
1077
1354
  render(currentStatus);
1078
1355
  });
1079
1356
  };
@@ -1083,6 +1360,7 @@ function buildDriveSync(deps: DriveSyncDeps): PieceInternal {
1083
1360
 
1084
1361
  const render = (status: DocumentDriveStatus | null): void => {
1085
1362
  currentStatus = status;
1363
+ if (status?.state !== 'diverged' || !status.canPush) confirmingOverwrite = false;
1086
1364
  // A document that mirrors nothing can still have somewhere to go: its
1087
1365
  // project may be linked to a drive that takes it as a new file, which is
1088
1366
  // what an upload into a linked project sits waiting for. Offer that
@@ -1105,7 +1383,7 @@ function buildDriveSync(deps: DriveSyncDeps): PieceInternal {
1105
1383
  // local hold (the server frame already has `publishing: false`).
1106
1384
  if (status.state === 'synced') {
1107
1385
  clearPublishing();
1108
- publishError = false;
1386
+ publishError = null;
1109
1387
  }
1110
1388
  root.hidden = false;
1111
1389
  // Either a local click hold or the server's own `publishing` flag means a
@@ -1172,6 +1450,11 @@ function buildDriveSync(deps: DriveSyncDeps): PieceInternal {
1172
1450
  warn.textContent = status.sourceUnreadable;
1173
1451
  rows.push(warn);
1174
1452
  }
1453
+ if (publishError !== null && !isPublishing) {
1454
+ const warn = el('p', 'b-drive-unreadable');
1455
+ warn.textContent = publishError;
1456
+ rows.push(warn);
1457
+ }
1175
1458
 
1176
1459
  if (isPublishing) {
1177
1460
  // Held while the publish is in flight (a local click hold, or the doc's
@@ -1182,16 +1465,13 @@ function buildDriveSync(deps: DriveSyncDeps): PieceInternal {
1182
1465
  pub.disabled = true;
1183
1466
  rows.push(pub);
1184
1467
  } else if (status.canPush) {
1185
- // `canPush` already encodes two-way + not broken + local edits + a
1186
- // server-writable format, so this never offers a publish the server
1187
- // would refuse.
1188
- const pub = el('button');
1189
- pub.type = 'button';
1190
- pub.textContent = publishError ? 'Publish failed, retry' : 'Publish changes';
1191
- pub.onclick = () => {
1468
+ // `canPush` is the server's publish decision, so this never offers a
1469
+ // publish the server would refuse.
1470
+ const startPublish = (): void => {
1192
1471
  const doc = deps.session.document;
1193
1472
  if (!doc) return;
1194
- publishError = false;
1473
+ confirmingOverwrite = false;
1474
+ publishError = null;
1195
1475
  publishing = true;
1196
1476
  // Safety net: if the publish neither errors nor reaches synced (the
1197
1477
  // webhook never confirms), release the held state so the popover
@@ -1201,12 +1481,45 @@ function buildDriveSync(deps: DriveSyncDeps): PieceInternal {
1201
1481
  render(currentStatus);
1202
1482
  }, PUBLISH_HOLD_MS);
1203
1483
  render(currentStatus);
1204
- void doc.pushToDrive().catch(() => {
1484
+ void doc.pushToDrive().catch((err: unknown) => {
1205
1485
  clearPublishing();
1206
- publishError = true;
1486
+ publishError = publishFailureText(err);
1207
1487
  render(currentStatus);
1208
1488
  });
1209
1489
  };
1490
+ // A diverged document publishes over a drive version it never took, so
1491
+ // the panel asks first, in place.
1492
+ const overwrites = status.state === 'diverged';
1493
+ if (overwrites && confirmingOverwrite) {
1494
+ const ask = el('p', 'b-drive-unreadable');
1495
+ ask.textContent = status.keepsPriorVersions
1496
+ ? `Publishing makes this doc the latest version in ${driveName}. ${driveName} keeps the newer version in its history.`
1497
+ : `Publishing replaces the newer version in ${driveName} with this doc.`;
1498
+ const confirm = el('button');
1499
+ confirm.type = 'button';
1500
+ confirm.textContent = `Publish over ${driveName}`;
1501
+ confirm.onclick = startPublish;
1502
+ const cancel = el('button');
1503
+ cancel.type = 'button';
1504
+ cancel.textContent = 'Cancel';
1505
+ cancel.onclick = () => {
1506
+ confirmingOverwrite = false;
1507
+ render(currentStatus);
1508
+ };
1509
+ rows.push(ask, confirm, cancel);
1510
+ pop.replaceChildren(...rows);
1511
+ return;
1512
+ }
1513
+ const pub = el('button');
1514
+ pub.type = 'button';
1515
+ const verb = publishError !== null ? 'Publish failed, retry' : 'Publish changes';
1516
+ pub.textContent = overwrites ? `${verb}...` : verb;
1517
+ pub.onclick = overwrites
1518
+ ? () => {
1519
+ confirmingOverwrite = true;
1520
+ render(currentStatus);
1521
+ }
1522
+ : startPublish;
1210
1523
  rows.push(pub);
1211
1524
  }
1212
1525
  // Resolve a divergence via the hosted take-source / keep-both surface. Not
@@ -1315,7 +1628,6 @@ function buildAccountMenu(deps: AccountMenuDeps): PieceInternal {
1315
1628
  ),
1316
1629
  'Open Brass dashboard',
1317
1630
  );
1318
- dashboardLink.href = `${deps.dashboardBaseUrl}/home`;
1319
1631
  dashboardLink.target = '_blank';
1320
1632
  dashboardLink.rel = 'noopener noreferrer';
1321
1633
  dashboardLink.onclick = () => menu.close();
@@ -1350,9 +1662,10 @@ function buildAccountMenu(deps: AccountMenuDeps): PieceInternal {
1350
1662
  // meaningless, so the menu offers a single "Sign in" that takes the top
1351
1663
  // window through the app's normal sign-in and returns to this URL.
1352
1664
  const signInBtn = el('button');
1665
+ const signInLabel = document.createTextNode(SIGN_IN_LABEL);
1353
1666
  signInBtn.append(
1354
1667
  strokeIcon('M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4', 'm10 17 5-5-5-5', 'M15 12H3'),
1355
- 'Sign in',
1668
+ signInLabel,
1356
1669
  );
1357
1670
  signInBtn.onclick = () => {
1358
1671
  menu.close();
@@ -1387,6 +1700,11 @@ function buildAccountMenu(deps: AccountMenuDeps): PieceInternal {
1387
1700
  dashboardLink.hidden = !signedIn;
1388
1701
  signOutBtn.hidden = !signedIn;
1389
1702
  signInBtn.hidden = signedIn;
1703
+ // On a document that expires, signing in is what keeps it.
1704
+ signInLabel.data =
1705
+ deps.session?.document?.expiresAt != null
1706
+ ? SIGN_IN_TO_KEEP_LABEL
1707
+ : SIGN_IN_LABEL;
1390
1708
  // The organization line names what the app is acting in, so it is drawn
1391
1709
  // only when there is something to name: an app that has not needed an
1392
1710
  // organization yet has not resolved one, and resolving one to fill this
@@ -1395,10 +1713,11 @@ function buildAccountMenu(deps: AccountMenuDeps): PieceInternal {
1395
1713
  orgEl.textContent = organizationName;
1396
1714
  orgEl.title = organizationName;
1397
1715
  orgEl.hidden = organizationName === '';
1716
+ const dashboardBaseUrl = deps.dashboardBaseUrl();
1398
1717
  dashboardLink.href =
1399
1718
  signedIn && organization
1400
- ? `${deps.dashboardBaseUrl}/organizations/${encodeURIComponent(organization.id)}`
1401
- : `${deps.dashboardBaseUrl}/home`;
1719
+ ? `${dashboardBaseUrl}/organizations/${encodeURIComponent(organization.id)}`
1720
+ : `${dashboardBaseUrl}/home`;
1402
1721
  // Offered to every signed-in reader, including one the line has nothing
1403
1722
  // to name and one who belongs to a single organization. The surface
1404
1723
  // behind it both moves the app and creates an organization, so gating it
@@ -1416,19 +1735,71 @@ function buildAccountMenu(deps: AccountMenuDeps): PieceInternal {
1416
1735
  organization = next;
1417
1736
  render();
1418
1737
  });
1738
+ const unsubSession = deps.session?.subscribe(render);
1419
1739
  return {
1420
1740
  root: menu.root,
1421
1741
  destroy(): void {
1422
1742
  unsub();
1423
1743
  unsubOrganization?.();
1744
+ unsubSession?.();
1424
1745
  menu.destroy();
1425
1746
  },
1426
1747
  };
1427
1748
  }
1428
1749
 
1750
+ // The strip under the bar on a document created without an account. It is
1751
+ // drawn whenever the active doc carries `expiresAt`, re-read on every session
1752
+ // notification: a sign-in claims the document and clears the expiry, and the
1753
+ // active document can change under the chrome. Empty otherwise, so the row
1754
+ // costs no height on an ordinary document.
1755
+ function buildNotSignedIn(deps: NotSignedInDeps): PieceInternal {
1756
+ injectStyles(document);
1757
+ const root = el('div', 'brass-ui b-guest');
1758
+ root.setAttribute('role', 'status');
1759
+ const message = el('span', 'b-guest-msg');
1760
+ const signInBtn = el('button');
1761
+ signInBtn.type = 'button';
1762
+ signInBtn.textContent = SIGN_IN_LABEL;
1763
+ signInBtn.onclick = () => deps.signIn();
1764
+ const keepBtn = el('button');
1765
+ keepBtn.type = 'button';
1766
+ keepBtn.textContent = KEEP_LABEL;
1767
+ keepBtn.onclick = () => deps.keep?.();
1768
+
1769
+ const render = (): void => {
1770
+ const expiresAt = deps.session.document?.expiresAt ?? null;
1771
+ if (expiresAt === null) {
1772
+ root.classList.remove('b-guest-on');
1773
+ root.replaceChildren();
1774
+ return;
1775
+ }
1776
+ if ((deps.getUser?.() ?? null) === null) {
1777
+ message.textContent = notSignedInLabel(expiresAt);
1778
+ root.replaceChildren(message, signInBtn);
1779
+ } else if (deps.canKeep?.() === true && deps.keep !== undefined) {
1780
+ message.textContent = `${expiresInLabel(expiresAt)}.`;
1781
+ root.replaceChildren(message, keepBtn);
1782
+ } else {
1783
+ message.textContent = `${removedInLabel(expiresAt)}.`;
1784
+ root.replaceChildren(message);
1785
+ }
1786
+ root.classList.add('b-guest-on');
1787
+ };
1788
+ render();
1789
+ const unsub = deps.session.subscribe(render);
1790
+ const unsubUser = deps.onUserChange?.(() => render());
1791
+ return {
1792
+ root,
1793
+ destroy(): void {
1794
+ unsub();
1795
+ unsubUser?.();
1796
+ },
1797
+ };
1798
+ }
1799
+
1429
1800
  // The historical-version preview bar. When the active doc is previewing an
1430
1801
  // earlier version (a hosted `history`-initiated checkout: `doc.readOnly` is
1431
- // true and `doc.version` is set), the SDK mounts the platform-served
1802
+ // true and `doc.previewVersion` is set), the SDK mounts the platform-served
1432
1803
  // `/embed/version-bar` inline as a strip and routes its intents (Prev / Next
1433
1804
  // checkout, return-to-latest, browse, branch-from) to the doc's internal
1434
1805
  // methods. Delivered as a server embed so the platform owns and grows the
@@ -1439,7 +1810,7 @@ function buildVersionPreview(deps: VersionPreviewDeps): PieceInternal {
1439
1810
  injectStyles(document);
1440
1811
  const root = el('div', 'brass-ui b-version');
1441
1812
  root.setAttribute('role', 'group');
1442
- root.setAttribute('aria-label', 'Document version');
1813
+ root.setAttribute('aria-label', 'Version');
1443
1814
 
1444
1815
  let frame: VersionBarFrame | null = null;
1445
1816
  // Once a preview starts (a checkout), the bar owns a browsing session that
@@ -1452,7 +1823,7 @@ function buildVersionPreview(deps: VersionPreviewDeps): PieceInternal {
1452
1823
  // The version the reader dismissed the bar at, when the document was left
1453
1824
  // holding one. A document that cannot return to its head (one pinned to a
1454
1825
  // version) stays on it, so without this the next render reads a non-null
1455
- // `doc.version` and latches the bar the reader just dismissed straight back
1826
+ // `doc.previewVersion` and latches the bar the reader just dismissed straight back
1456
1827
  // on. A dismissal at the live head records nothing: there is no version
1457
1828
  // left to latch on, and the head is where the next browse-in-the-app
1458
1829
  // request arrives, so a record there would swallow it.
@@ -1481,12 +1852,15 @@ function buildVersionPreview(deps: VersionPreviewDeps): PieceInternal {
1481
1852
  throw err;
1482
1853
  }
1483
1854
  };
1484
- const frameDeps: VersionBarDeps = {
1485
- dashboardBaseUrl: deps.dashboardBaseUrl,
1486
- ...(deps.hostedUiTheme ? { theme: deps.hostedUiTheme } : {}),
1855
+ // Assembled when the bar opens rather than at mount, so the frame's origin
1856
+ // and theme are the ones `Brass.init()` settled (see `AccountMenuDeps`).
1857
+ const frameDeps = (): VersionBarDeps => ({
1858
+ dashboardBaseUrl: deps.dashboardBaseUrl(),
1859
+ appId: deps.appId?.(),
1860
+ ...(deps.hostedUiTheme ? { theme: deps.hostedUiTheme() } : {}),
1487
1861
  // Read the live active document at call time: a bridge intent lands
1488
1862
  // async, so it must not close over a doc that may have been superseded.
1489
- currentVersion: () => session.document?.version ?? null,
1863
+ currentVersion: () => session.document?.previewVersion ?? null,
1490
1864
  checkoutVersion: async (version) => {
1491
1865
  const doc = session.document;
1492
1866
  if (!doc) return;
@@ -1514,7 +1888,7 @@ function buildVersionPreview(deps: VersionPreviewDeps): PieceInternal {
1514
1888
  // flag is cleared too, or the next render would latch the bar back on.
1515
1889
  const doc = session.document;
1516
1890
  browsing = false;
1517
- dismissedAt = doc?.version ?? undefined;
1891
+ dismissedAt = doc?.previewVersion ?? undefined;
1518
1892
  doc?._setVersionBrowsing(false);
1519
1893
  // Leaving the bar means leaving the preview: the bar is the only
1520
1894
  // surface that returns a document to its head, so a dismissal that
@@ -1527,7 +1901,7 @@ function buildVersionPreview(deps: VersionPreviewDeps): PieceInternal {
1527
1901
  // pick sets only `version`, so a stale `dismissedAt` equal to it would
1528
1902
  // otherwise leave the reader checked out, read-only, with no bar and
1529
1903
  // no exit).
1530
- if (doc && doc.version !== null) {
1904
+ if (doc && doc.previewVersion !== null) {
1531
1905
  void attempt(doc, () => doc.returnToHead())
1532
1906
  .then(() => {
1533
1907
  if (session.document === doc) dismissedAt = undefined;
@@ -1536,7 +1910,7 @@ function buildVersionPreview(deps: VersionPreviewDeps): PieceInternal {
1536
1910
  }
1537
1911
  hideBar();
1538
1912
  },
1539
- };
1913
+ });
1540
1914
 
1541
1915
  const render = (): void => {
1542
1916
  const doc = session.document;
@@ -1550,7 +1924,7 @@ function buildVersionPreview(deps: VersionPreviewDeps): PieceInternal {
1550
1924
  browsing = false;
1551
1925
  dismissedAt = undefined;
1552
1926
  }
1553
- // A live checkout (`doc.version !== null`) starts / keeps the browsing
1927
+ // A live checkout (`doc.previewVersion !== null`) starts / keeps the browsing
1554
1928
  // session. Other read-only states (a view-only share link, a still-loading
1555
1929
  // doc) are not this bar's concern, and `doc.id` is null only on the offline
1556
1930
  // / test path the embed can't serve.
@@ -1560,8 +1934,8 @@ function buildVersionPreview(deps: VersionPreviewDeps): PieceInternal {
1560
1934
  if (
1561
1935
  doc != null &&
1562
1936
  docId !== null &&
1563
- (doc.version !== null || doc.versionBrowsing) &&
1564
- (doc.versionBrowsing || doc.version !== dismissedAt)
1937
+ (doc.previewVersion !== null || doc.versionBrowsing) &&
1938
+ (doc.versionBrowsing || doc.previewVersion !== dismissedAt)
1565
1939
  ) {
1566
1940
  browsing = true;
1567
1941
  dismissedAt = undefined;
@@ -1573,14 +1947,14 @@ function buildVersionPreview(deps: VersionPreviewDeps): PieceInternal {
1573
1947
  doc._setVersionBrowsing(true);
1574
1948
  }
1575
1949
  // The bar stays open for the whole session, including at the live head
1576
- // (`doc.version === null`, browsing still true), until `close`.
1950
+ // (`doc.previewVersion === null`, browsing still true), until `close`.
1577
1951
  if (doc != null && docId !== null && browsing) {
1578
1952
  root.classList.add('b-version-on');
1579
- if (!frame) frame = createVersionBarFrame(root, frameDeps);
1953
+ if (!frame) frame = createVersionBarFrame(root, frameDeps());
1580
1954
  frame.update({
1581
1955
  docId,
1582
1956
  branch: doc.branchId ?? undefined,
1583
- version: doc.version,
1957
+ version: doc.previewVersion,
1584
1958
  branchName: doc._currentBranchName() ?? undefined,
1585
1959
  pinned: doc._isVersionPinned,
1586
1960
  });
@@ -1638,7 +2012,7 @@ export function createUndoRedo(
1638
2012
  container: HTMLElement,
1639
2013
  deps: UndoRedoDeps,
1640
2014
  ): MountedPiece {
1641
- return mountPiece(container, buildUndoRedo(deps));
2015
+ return mountPiece(container, buildUndoRedo(deps, 'standalone'));
1642
2016
  }
1643
2017
 
1644
2018
  // The presence roster (coloured initials of who's on the active doc).
@@ -1708,11 +2082,17 @@ function buildBarShell(): BarShell {
1708
2082
  // row-gap, leaving asymmetric padding. "Empty" = every child is blank (no
1709
2083
  // text, no element children), so an empty save-indicator span or a
1710
2084
  // command-less undo/redo piece both count.
2085
+ // `b-app-tools` marks a strip holding something of the app's beside
2086
+ // undo / redo, which the narrow bar shows on a row of its own.
1711
2087
  function updateToolsVisibility(): void {
1712
- const allBlank = Array.from(tools.children).every(
1713
- (c) => (c.textContent ?? '').trim() === '' && c.childElementCount === 0,
2088
+ const blank = (c: Element): boolean =>
2089
+ (c.textContent ?? '').trim() === '' && c.childElementCount === 0;
2090
+ const children = Array.from(tools.children);
2091
+ tools.style.display = children.every(blank) ? 'none' : '';
2092
+ bar.classList.toggle(
2093
+ 'b-app-tools',
2094
+ children.some((c) => !c.classList.contains('b-undo-full') && !blank(c)),
1714
2095
  );
1715
- tools.style.display = allBlank ? 'none' : '';
1716
2096
  }
1717
2097
  // The app (and the undo/redo subscription) mutates `tools` after mount, so
1718
2098
  // re-evaluate visibility on any change.
@@ -1759,10 +2139,15 @@ export function createSessionToolbar(
1759
2139
  pieces.push(drivePiece);
1760
2140
  }
1761
2141
 
1762
- // Middle: SDK toolbar-surface commands (undo/redo) + the app's slot.
2142
+ // Middle: SDK toolbar-surface commands (undo/redo) + the app's slot. On a
2143
+ // narrow screen the middle wraps to a row of its own, so Undo and Redo show
2144
+ // there instead as icons in the right cluster (CSS swaps the two), and the
2145
+ // middle row only appears for the app's own tools.
1763
2146
  const undoRedo = buildUndoRedo(deps);
1764
2147
  shell.tools.append(undoRedo.root);
1765
- pieces.push(undoRedo);
2148
+ const compactUndo = buildUndoRedo(deps, 'compact');
2149
+ shell.end.append(compactUndo.root);
2150
+ pieces.push(undoRedo, compactUndo);
1766
2151
 
1767
2152
  // Right cluster: presence roster + account menu.
1768
2153
  if (showPresence) {
@@ -1776,12 +2161,14 @@ export function createSessionToolbar(
1776
2161
  pieces.push(accountPiece);
1777
2162
  }
1778
2163
 
1779
- // The historical-version preview bar sits below the bar as a full-width
1780
- // strip: mounting the toolbar is all an app does to get correct version-
1781
- // preview UX, so it never hand-rolls one.
2164
+ // Two full-width strips sit below the bar: the not-signed-in notice on a
2165
+ // document created without an account, and the historical-version preview
2166
+ // bar. Mounting the toolbar is all an app does to get both, so it never
2167
+ // hand-rolls either.
2168
+ const guestPiece = buildNotSignedIn(deps);
1782
2169
  const versionPiece = buildVersionPreview(deps);
1783
- pieces.push(versionPiece);
1784
- container.replaceChildren(shell.bar, versionPiece.root);
2170
+ pieces.push(guestPiece, versionPiece);
2171
+ container.replaceChildren(shell.bar, guestPiece.root, versionPiece.root);
1785
2172
 
1786
2173
  return {
1787
2174
  tools: shell.tools,
@@ -1799,15 +2186,18 @@ export function createSessionToolbar(
1799
2186
  // never wraps when `tools` does.
1800
2187
  export function createSessionHeader(
1801
2188
  container: HTMLElement,
1802
- deps?: VersionPreviewDeps,
2189
+ deps?: VersionPreviewDeps & NotSignedInDeps,
1803
2190
  ): SessionHeaderHandle {
1804
2191
  const shell = buildBarShell();
1805
- // A header shell built with session access includes the historical-version
1806
- // preview bar below the bar, so a composable-chrome app gets the same
1807
- // correct preview UX the full toolbar does without a separate mount.
2192
+ // A header shell built with session access includes the two strips below
2193
+ // the bar (the not-signed-in notice and the historical-version preview
2194
+ // bar), so a composable-chrome app gets what the full toolbar renders
2195
+ // there without a separate mount.
2196
+ const guestPiece = deps ? buildNotSignedIn(deps) : null;
1808
2197
  const versionPiece = deps ? buildVersionPreview(deps) : null;
1809
2198
  container.replaceChildren(
1810
2199
  shell.bar,
2200
+ ...(guestPiece ? [guestPiece.root] : []),
1811
2201
  ...(versionPiece ? [versionPiece.root] : []),
1812
2202
  );
1813
2203
  return {
@@ -1815,6 +2205,7 @@ export function createSessionHeader(
1815
2205
  tools: shell.tools,
1816
2206
  end: shell.end,
1817
2207
  destroy(): void {
2208
+ guestPiece?.destroy();
1818
2209
  versionPiece?.destroy();
1819
2210
  shell.destroy();
1820
2211
  container.replaceChildren();